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-levelletbindings. - Fixed-timestep constants exported for reuse:
SIM_DT = 1 / 60— simulation step (matches RapierFIXED_DTinrapier-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 bybridge.ts.resetFrameTiming()— clearslastFrameTimeandaccumulator(used by the test runner before pumping synthetic frames).startNewGame()— full game-state reset; setsgame.phase = 'playing'.
READS FROM
./state—game,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.
- Reads:
./(barrel) —Modifiers,Sig,Entity.../physics/movement—Physics.update(simDt).../physics/rapier-world—RapierWorld.isReady(),RapierWorld.step(simDt),RapierWorld.resetAccumulator().../physics/rapier-ship—RapierShip.syncFromRapier(alpha)returns the interpolated{x, y, vx, vy}for the ship.../input—Input.update(), optionalInput.pollGamepad().../rendering/camera—Camera.update(simDt)../fps—resetSmoothedFps()(on new game).../world/warp-puddle-effects—applyWarpPuddleOverrides(ship)(after Modifiers recalc)../clock—Clock.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 whengame.phaseis'playing'or'birth').RapierWorld.step(simDt)+ writesship.x,ship.y,ship.vx,ship.vyfromRapierShip.syncFromRapier(alpha).Modifiers.tick(simDt)and conditionalModifiers.recalc(ship, ship._base)whenship._baseis populated.applyWarpPuddleOverrides(ship).Camera.update(simDt).- Mission-timer mutation: decrements
game.missionTimerwhile playing (skipped whenovertime, sandbox, or boss-room is active); when it hits zero setsgame.overtime = true. Whileovertime, accumulatesgame.overtimeElapsed. game.time += simDt.
Per RAF frame (inside gameLoopTick):
- Hit-freeze branch: decrements
game._hitFreezeTimerin wall-time, restoresgame.timeDilation = game._testSpeedMult || 1on expiry, ticksgame._invertScreenTimerin wall-time, setsgame._rawDt/game._stepsThisFrame = 0, advancesgame.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, plusgame.wallTime += rawDt * 1000andgame.uiTime += rawDt. - On long stall (
elapsed > 0.2s) or excess accumulator cap-out: zeroes the localaccumulatorand callsRapierWorld.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;gameLoopTickonly advances simulation and publishes timing scalars. - Does not subscribe to or emit signals on
Sig(only clears them instartNewGame). - Does not own the boss-cinematic
juiceDilationrevert — it only consumes and clamps the value. The boss code that writesjuiceDilationowns 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.updateor 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, orlevelSpeedMultitself (other than the post-freeze restore oftimeDilation).
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 perrequestAnimationFramefrombridge.tsbetweendiagBeginPass('gameLoop')anddiagEndPass('gameLoop').startNewGame(): void— called bybridge.tswhen starting a new run (resets state and registers a fresh telemetry run id elsewhere).resetFrameTiming(): void— called bybridge.tstest-runner code before pumping synthetic timestamps so the first call computes a saneelapsedinstead 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 wholeSIM_DTchunks viawhile (accumulator >= SIM_DT && steps < maxSteps). Sim step count per RAF varies with display refresh: on a 60Hz displaystepsis usually 1, on 120Hzsteps = 0half the time (system skips that frame), on 30Hzsteps = 2. Rapier always getsSIM_DTper inner step. simRatecomposition.simRate = game.timeDilation * game.levelSpeedMult * juiceClamped, wherejuiceClamped = 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 byrawDt * 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, anduiTimeadvance. On expiry,game.timeDilationis restored togame._testSpeedMult || 1. - Wall-clock vs sim-clock split.
game.wallTime/game.uiTimealways advance withrawDt(outside the sim loop).game.timeadvances only insidestepSimulationbySIM_DT.Clock.advanceSimStep()provides the deterministic sim-clock surface separate from wall-time. - Rapier interpolation.
RapierWorld.step(simDt)returns analphavalue thatRapierShip.syncFromRapier(alpha)uses to interpolate the ship transform between two physics states; the loop writes the interpolated{x, y, vx, vy}directly ontoship. game._dtsemantics for frame-level systems.game._dt = steps * SIM_DT— the total sim time advanced this RAF frame. Frame-level systems inbridge.ts(GameMaster.tick, WeaponManager.updateCooldowns, enemy AI, pickups, crates) consumegame._dtso they stay at true 60Hz sim rate regardless of display refresh. Rapier and per-step systems still useSIM_DTper 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, andsimFrameare module-scopedletbindings — there is only ever one game loop.startNewGameandresetFrameTimingare the two reset surfaces. - Warp-puddle ordering.
applyWarpPuddleOverrides(ship)runs afterModifiers.recalcso altered-state thrust/drag/heat layer on top of base modifiers, scaled smoothly byship.warpTfor entry/exit tweening.