Hit-stop (per-damage freeze frames)
Hit-stop is a brief sim freeze that fires on impact moments to sell weight and read. The pause is measured in wall-time seconds, not sim-time, so it survives any time-dilation or speed-test multiplier without warping its duration. While active, the entire fixed-timestep sim is bypassed — only render and a small set of wall-time UI timers continue to tick.
State
A single scalar on GameState, game._hitFreezeTimer (seconds remaining). Initialized to 0 in engine/core/state.ts and typed in engine/core/types.ts. While > 0, the freeze is active; loop drains it each frame in raw wall-time.
The companion channel is game.timeDilation, which is forced to 0 when a freeze starts and restored to game._testSpeedMult || 1 when the timer drains. Hit-stop and timeDilation are coupled — anything that sets the timer also clamps dilation, and the loop is the only thing that restores it.
Loop fast-path
engine/core/loop.ts checks _hitFreezeTimer > 0 before the sim accumulator. When active:
- Decrement timer by
rawDt(capped at 0.033s, the same 30fps clamp the loop uses). - If the decrement drains it, set timer to
0and restoretimeDilationtogame._testSpeedMult || 1. - Tick
game._invertScreenTimerin wall-time so the shield-break invert overlay survives the freeze. - Update wall-clocks (
game._rawDt,game.wallTime,game.uiTime), setgame._stepsThisFrame = 0, and early-return before the accumulator and sim loop. No sim steps run.
Rendering is decoupled — bridge.ts drives the canvas independently — so the screen keeps repainting (camera shake, hit flash, particles whose positions were set just before the freeze, etc.) even though no enemy moves and no bullet advances.
Sources
Every hit-stop trigger writes directly to game._hitFreezeTimer and zeroes game.timeDilation. Five sources currently fire:
| Source | Duration (s) | Where | Notes |
|---|---|---|---|
| Shield hit | 0.025 + shieldFrac * 0.04 (≈0.025–0.065) | engine/combat/damage.ts:934-937 | Guarded by _hitFreezeTimer <= 0 — first hit wins, won’t extend an in-progress freeze. shieldFrac = min(1, absorbed / shieldMax). |
| Hull hit | 0.04 + hullFracForFx * 0.06 (≈0.04–0.10) | engine/combat/damage.ts:1130-1133 | Same <= 0 guard. hullFracForFx = min(1, hullDmg / hpMax). |
| Shield break | 0.10 (via Math.max) | engine/combat/damage.ts:1034-1036 | Extends any in-flight freeze rather than guarding — Math.max(current, 0.10). Pairs with _invertScreenTimer = 0.10 for the “system shock” invert overlay. |
| Combo-kill milestone | 0.014 / 0.03 / 0.07 | engine/combat/damage.ts:115-138 | _COMBO_PARAMS indexed by kills-in-window (1 / 2 / 3+). Only fires when the _COMBO_KILL_WINDOW (0.1s) has accumulated kills and the _COMBO_SHAKE_COOLDOWN (0.6s) is clear. Set unconditionally when freeze > 0. |
| Charger electric slam | CHARGER_HIT_FREEZE | engine/enemies/behaviors.ts:1116-1117 | Mini-boss lunge connect — sells the heavy hit alongside knockback, _electricHit flag, and red flash. Set unconditionally. |
Guard semantics
Two distinct merge rules exist across sources:
- First-write-wins (shield hit, hull hit):
if (_hitFreezeTimer <= 0). A second damage event during an existing freeze does not extend it. Prevents simultaneous shield+hull hits from compounding into a noticeably long pause. - Max-merge (shield break):
_hitFreezeTimer = Math.max(_hitFreezeTimer, 0.10). The break is louder than any concurrent shield hit, so it raises the floor without shortening anything. - Unconditional overwrite (combo-kill, charger slam): direct assignment, no guard. These fire in narrow windows (combo-kill has its own 0.6s cooldown; charger slam is a once-per-lunge event) so they don’t interfere with each other in practice.
Auto-clear
Restoration is centralized in the loop: when the timer drains to <= 0, timeDilation is set back to _testSpeedMult || 1 (test speed multiplier respected; default 1). No call site is responsible for un-freezing — sources set the timer and forget. This is why a stuck timeDilation = 0 outside hit-stop would be a bug: the only path back to 1 runs through the loop’s drain branch.
Why wall-time, not sim-time
The freeze must work the same whether the sim is running at 1×, 4× (speed test), or under a juiceDilation slow-mo cinematic. Decrementing in rawDt makes the duration a real human-perceptible pause regardless of dilation channels. The trade-off: hit-stop also pauses cleanly during a boss slow-mo without compounding the slowdown.
Related
- time-dilation — the
timeDilation/juiceDilation/levelSpeedMulttriple that hit-stop’s0write coexists with. - combo-kill-system — the rolling-window kill tracker that owns the milestone freeze trigger.
- juice-system — the
Juice.fire('shield_hit' | 'player_hull_hit' | 'shield_broken')channel that runs alongside each freeze.