loop.ts

PURPOSE

Main game loop with a fixed-timestep accumulator. Driven once per RAF frame by bridge.ts via gameLoopTick(timestamp). Runs zero or more deterministic sim steps at exactly SIM_DT = 1/60 per RAF frame, feeds Rapier its fixed step, runs Input / Physics / Modifiers / Camera / mission timers per step, and publishes per-frame timing scalars (game._dt, game._rawDt, game._stepsThisFrame, game._simFrame) for frame-level systems to consume.

OWNS

  • Frame-timing module state (lastFrameTime, accumulator, simFrame) held as module-level let bindings.
  • Fixed-timestep constants exported for reuse:
    • SIM_DT = 1 / 60 — simulation step (matches Rapier FIXED_DT in rapier-world.ts).
    • MAX_STEPS_BASE = 4 — spiral-of-death cap at 1× speed (≈ 60ms budget per RAF, handles ~15fps phones).
    • MAX_ACCUM = 0.25 — accumulator ceiling (250ms) that drops excess on tab resume / long stalls.
  • The stepSimulation(simDt) helper that encapsulates the per-step pipeline.
  • Three exports:
    • gameLoopTick(timestamp) — called once per RAF by bridge.ts.
    • resetFrameTiming() — clears lastFrameTime and accumulator (used by the test runner before pumping synthetic frames).
    • startNewGame() — full game-state reset; sets game.phase = 'playing'.

READS FROM

  • ./stategame, ship, world, camera, resetState.
    • Reads: game.phase, game.overtime, game.runDef?.sandbox, game.bossRoom, game.missionTimer, game._hitFreezeTimer, game._invertScreenTimer, game.timeDilation, game.levelSpeedMult, game.juiceDilation, game._testSpeedMult, ship._base.
  • ./ (barrel) — Modifiers, Sig, Entity.
  • ../physics/movementPhysics.update(simDt).
  • ../physics/rapier-worldRapierWorld.isReady(), RapierWorld.step(simDt), RapierWorld.resetAccumulator().
  • ../physics/rapier-shipRapierShip.syncFromRapier(alpha) returns the interpolated {x, y, vx, vy} for the ship.
  • ../inputInput.update(), optional Input.pollGamepad().
  • ../rendering/cameraCamera.update(simDt).
  • ./fpsresetSmoothedFps() (on new game).
  • ../world/warp-puddle-effectsapplyWarpPuddleOverrides(ship) (after Modifiers recalc).
  • ./clockClock.advanceSimStep() per sim step, Clock.reset() on new game.

PUSHES TO

Per sim step (inside stepSimulation):

  • Clock.advanceSimStep() — advances the deterministic sim clock by exactly 1/60s.
  • Input.update() / Input.pollGamepad().
  • Physics.update(simDt) (only when game.phase is 'playing' or 'birth').
  • RapierWorld.step(simDt) + writes ship.x, ship.y, ship.vx, ship.vy from RapierShip.syncFromRapier(alpha).
  • Modifiers.tick(simDt) and conditional Modifiers.recalc(ship, ship._base) when ship._base is populated.
  • applyWarpPuddleOverrides(ship).
  • Camera.update(simDt).
  • Mission-timer mutation: decrements game.missionTimer while playing (skipped when overtime, sandbox, or boss-room is active); when it hits zero sets game.overtime = true. While overtime, accumulates game.overtimeElapsed.
  • game.time += simDt.

Per RAF frame (inside gameLoopTick):

  • Hit-freeze branch: decrements game._hitFreezeTimer in wall-time, restores game.timeDilation = game._testSpeedMult || 1 on expiry, ticks game._invertScreenTimer in wall-time, sets game._rawDt / game._stepsThisFrame = 0, advances game.wallTime / game.uiTime, and returns early (sim is skipped).
  • Normal branch publishes: game._dt = steps * SIM_DT, game._rawDt = rawDt, game._stepsThisFrame = steps, game._simFrame = simFrame, plus game.wallTime += rawDt * 1000 and game.uiTime += rawDt.
  • On long stall (elapsed > 0.2s) or excess accumulator cap-out: zeroes the local accumulator and calls RapierWorld.resetAccumulator().

On startNewGame(): calls resetState(), Entity.clear(), Modifiers.clear(), Sig.clear(), resetSmoothedFps(), Clock.reset(), zeroes lastFrameTime / accumulator / simFrame, sets game.phase = 'playing'.

DOES NOT

  • Does not render. Rendering is driven separately from bridge.ts; gameLoopTick only advances simulation and publishes timing scalars.
  • Does not subscribe to or emit signals on Sig (only clears them in startNewGame).
  • Does not own the boss-cinematic juiceDilation revert — it only consumes and clamps the value. The boss code that writes juiceDilation owns its own revert-on-timer.
  • Does not run sim while game.phase === 'menu' (early return) or during hit-freeze (separate wall-time branch, sim entirely skipped).
  • Does not run Physics.update or Rapier step in phases other than 'playing' and 'birth'. Modifiers, Camera, mission timers, and clock still tick in any non-menu, non-freeze phase.
  • Does not write juiceDilation, timeDilation, or levelSpeedMult itself (other than the post-freeze restore of timeDilation).

Signals

None. loop.ts does not emit or subscribe to signals. Sig.clear() is called only inside startNewGame() as part of the new-run reset.

Entry points

  • gameLoopTick(timestamp: number): void — called once per requestAnimationFrame from bridge.ts between diagBeginPass('gameLoop') and diagEndPass('gameLoop').
  • startNewGame(): void — called by bridge.ts when starting a new run (resets state and registers a fresh telemetry run id elsewhere).
  • resetFrameTiming(): void — called by bridge.ts test-runner code before pumping synthetic timestamps so the first call computes a sane elapsed instead of using stale state.

Pattern notes

  • Fixed-timestep accumulator. The classic “Fix Your Timestep” pattern: each RAF frame adds scaled wall-time to accumulator, then drains it in whole SIM_DT chunks via while (accumulator >= SIM_DT && steps < maxSteps). Sim step count per RAF varies with display refresh: on a 60Hz display steps is usually 1, on 120Hz steps = 0 half the time (system skips that frame), on 30Hz steps = 2. Rapier always gets SIM_DT per inner step.
  • simRate composition. simRate = game.timeDilation * game.levelSpeedMult * juiceClamped, where juiceClamped = clamp(game.juiceDilation, 0.1, 1.0). The clamp prevents a stuck or dropped juice revert from freezing the sim (< 0.1) or accelerating it past baseline (> 1.0). Accumulator increments by rawDt * simRate.
  • Adaptive step cap. maxSteps = max(MAX_STEPS_BASE, ceil(simRate * MAX_STEPS_BASE)). At 1× speed this is 4 sim steps per RAF; at 4× test speed it scales to 16, preventing high-speed test runs from hitting the cap on minor frame-time jitter.
  • Accumulator drain on cap-out. If steps === maxSteps && accumulator >= SIM_DT, the remaining accumulator is zeroed rather than rolled into the next frame — accepts visual hitching over runaway sim debt.
  • Tab-resume / long-stall handling. When elapsed > 0.2s (200ms), the frame is dropped: accumulator = 0, RapierWorld.resetAccumulator(), early return.
  • Raw dt cap. rawDt = min(0.033, elapsed) caps any single frame’s dt at ~30fps equivalent to prevent runaway physics on short stalls below the long-stall threshold.
  • Hit-freeze is wall-time only. When game._hitFreezeTimer > 0, the sim path is skipped entirely; only the freeze timer, invert-overlay timer, wallTime, and uiTime advance. On expiry, game.timeDilation is restored to game._testSpeedMult || 1.
  • Wall-clock vs sim-clock split. game.wallTime / game.uiTime always advance with rawDt (outside the sim loop). game.time advances only inside stepSimulation by SIM_DT. Clock.advanceSimStep() provides the deterministic sim-clock surface separate from wall-time.
  • Rapier interpolation. RapierWorld.step(simDt) returns an alpha value that RapierShip.syncFromRapier(alpha) uses to interpolate the ship transform between two physics states; the loop writes the interpolated {x, y, vx, vy} directly onto ship.
  • game._dt semantics for frame-level systems. game._dt = steps * SIM_DT — the total sim time advanced this RAF frame. Frame-level systems in bridge.ts (GameMaster.tick, WeaponManager.updateCooldowns, enemy AI, pickups, crates) consume game._dt so they stay at true 60Hz sim rate regardless of display refresh. Rapier and per-step systems still use SIM_DT per inner step.
  • Mission-timer gating. The mission timer freezes once the end-of-level arena (boss room) spawns: the closing ring + portal is the level end, and the player gets unlimited time to fly through. Sandbox runs (game.runDef?.sandbox) also bypass the timer.
  • Module-level singleton state. lastFrameTime, accumulator, and simFrame are module-scoped let bindings — there is only ever one game loop. startNewGame and resetFrameTiming are the two reset surfaces.
  • Warp-puddle ordering. applyWarpPuddleOverrides(ship) runs after Modifiers.recalc so altered-state thrust/drag/heat layer on top of base modifiers, scaled smoothly by ship.warpT for entry/exit tweening.