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 0 and restore timeDilation to game._testSpeedMult || 1.
  • Tick game._invertScreenTimer in wall-time so the shield-break invert overlay survives the freeze.
  • Update wall-clocks (game._rawDt, game.wallTime, game.uiTime), set game._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:

SourceDuration (s)WhereNotes
Shield hit0.025 + shieldFrac * 0.04 (≈0.025–0.065)engine/combat/damage.ts:934-937Guarded by _hitFreezeTimer <= 0 — first hit wins, won’t extend an in-progress freeze. shieldFrac = min(1, absorbed / shieldMax).
Hull hit0.04 + hullFracForFx * 0.06 (≈0.04–0.10)engine/combat/damage.ts:1130-1133Same <= 0 guard. hullFracForFx = min(1, hullDmg / hpMax).
Shield break0.10 (via Math.max)engine/combat/damage.ts:1034-1036Extends 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 milestone0.014 / 0.03 / 0.07engine/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 slamCHARGER_HIT_FREEZEengine/enemies/behaviors.ts:1116-1117Mini-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.

  • time-dilation — the timeDilation / juiceDilation / levelSpeedMult triple that hit-stop’s 0 write 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.