Client
The places handling time and hit resolution leaked. Every one of them failed quietly, without throwing.
Unity 6, URP. What’s written here isn’t about the framework — it’s the places handling time and hit resolution actually leaked. They have one thing in common: none of them throws.
Record dimensionless, resolve every frame
Telegraph duration has to be adjustable in real time, and the first version instead took a fixed value once and waited on it. Measured: 43 places doing that against 22 resolving per frame.
The cause isn’t “there’s no live editor.” It’s when the dimension gets frozen. The single line yield return new WaitForSeconds(x) freezes x at that moment. After that you can pause the game and drag the inspector all you like — the value is already copied inside the coroutine.
yield return new WaitForSeconds(x);
float t0 = Time.time; while (Time.time - t0 < x) yield return null;
The second one re-reads x every frame. So a telegraph already in flight changes where it stands.
A const float is worse than freezing it in a coroutine. The coroutine at least picks up the change on the next play; a constant requires a rebuild. Presentation durations go in fields, no exceptions.
When there’s more than one segment, they share one start time. Give a fade and a flash their own start times and, when the total duration changes mid-run, the two segments no longer sum to the total — meaning the telegraph and the actual hit come apart. Compute one progress value per frame and slice the segments out of it.
Touch time and three places leak
Adding hit-stop and slow motion produced three of them on the same day.
| Symptom | Cause |
|---|---|
| At 0.3× speed a 3-second wait actually takes 10 | The wait follows game time |
| Change scenes mid-slow and the scale stays pinned at 0.3 | It exits without passing through the restore |
| A hitbox fires after death | Died during a charge wait → the wait completed and ran anyway |
The third is the nastiest. A coroutine doesn’t know whether the object that started it is still alive. So now every yield is followed by asking again.
All three throw nothing. The game just freezes, or a dead enemy hits you, or a wait takes three times as long.
A guard in Update() only blocks half of it
For stretches where input must not be accepted — a pause, a cutscene — I added a guard, checked inside Update().
It blocked about half.
Update() stops when the time scale does, but input-action callbacks, event callbacks, and anything on unscaled time keep running. You end up in a state where the picture is frozen and input still lands.
“Stopped” is not one thing. Time stopping, the frame loop stopping, and input not arriving are three different switches. A guard has to sit on the same layer as the thing it’s blocking.
Blocked, and hit anyway
This came out of the counter system, and it’s the bug I learned the most from.
When a form got countered, I removed that pending hit from the list immediately. It looked clean. But later, when damage resolution asked “was this attack blocked?”, the entry wasn’t in the list, so it came back as none — and none reads as “not blocked,” so the clean hit went straight through.
The fix was to mark rather than remove. The block is recorded as a result, and cleanup happens at consumption time. Then even if the caller forgets its early return, the damage comes out as zero — blocked twice over.
The lesson: “handled, so delete it” is only correct when nobody is going to ask about it later.
Don’t reuse a signal
The counter-success signal was routed through the existing “attack animation” event. It was already there, so it was convenient.
That event also fires on basic attacks. The result: mashing left-click broke enemy forms. Regardless of lineage, regardless of timing.
Now there’s a dedicated event with its firing point pinned to one place — immediately after a form successfully starts. Saving one signal made an entire system meaningless.
activeSelf does not mean “alive”
Object pooling burned me twice.
Returning to the pool fires no physics callback. SetActive(false) sends it back without triggering OnTriggerExit, so a returned object stays on the “currently inside my range” roster. Respawn that same instance across the map and you get hit from there.
Death doesn’t switch it off immediately, because of game feel. While the death animation plays, activeSelf is still true — so a scanner that only checks that treats a corpse as a valid target.
Both point the same way. Stop using activeSelf as a synonym for alive — put actual health or a death flag in the condition, and re-verify position when the roster is consumed. Better to build so that it holds regardless of what the contamination source is.