Frame Pacing
Starship Survivors runs a fixed-timestep simulation at 60Hz decoupled from display refresh. gameLoopTick(timestamp) in engine/core/loop.ts is the single rAF entry point; it advances a per-frame accumulator, runs zero or more sim steps of exactly SIM_DT = 1/60s, and writes frame state for downstream systems.
Fixed timestep
The sim step is SIM_DT = 1 / 60 seconds and matches the Rapier physics FIXED_DT — physics, gameplay, and the deterministic Clock all advance in lockstep. Each call to stepSimulation(simDt) runs:
Clock.advanceSimStep()— deterministic clock, no wall-clock input.Input.update()and optionalInput.pollGamepad().Physics.update(simDt)andRapierWorld.step(simDt)+RapierShip.syncFromRapier(alpha)(when phase isplayingorbirth).Modifiers.tick(simDt)plusModifiers.recalc(ship, ship._base)when the ship has a base snapshot.applyWarpPuddleOverrides(ship)— layered on top of recalc so thrust/drag/heat get warp treatment, scaled bywarpT.Camera.update(simDt).- Mission timer drain (skipped while
overtime,runDef.sandbox, orbossRoomis set; oncemissionTimer <= 0it clamps to 0 and flipsovertime = true; in overtimeovertimeElapsedaccrues). game.time += simDt.
Rendering is not called from loop.ts — it runs once per rAF, driven separately from bridge.ts.
Accumulator and step cap
Per rAF frame:
elapsed = (timestamp - lastFrameTime) / 1000measures wall-clock delta.- If
elapsed > 0.2s(tab resume / long stall) the accumulator andRapierWorldaccumulator are zeroed and the frame returns without simulating. - Otherwise
rawDt = min(0.033, elapsed)caps the working delta at ~30fps to prevent runaway physics. accumulator += rawDt * simRate, then clamped toMAX_ACCUM = 0.25sso a long stall drops excess time instead of catching up.- The sim runs
while (accumulator >= SIM_DT && steps < maxSteps), subtractingSIM_DTand incrementingstepsandsimFrameeach iteration. - If the cap is hit and the accumulator still has a step in it, the accumulator is drained to 0 (spiral-of-death guard).
Step cap is MAX_STEPS_BASE = 4 (60ms budget per rAF at 1× speed, enough to keep ~15fps phones playable), scaled by simRate: maxSteps = max(MAX_STEPS_BASE, ceil(simRate * MAX_STEPS_BASE)). At 4× test speed this is 16 steps, so minor frame-time jitter doesn’t trip the cap.
simRate and time dilation
The sim rate is the product of three channels:
simRate = game.timeDilation * game.levelSpeedMult * juiceClamped
game.timeDilation— global slow/fast knob (test speed multipliers, hit-freeze revert target).game.levelSpeedMult— per-level pacing multiplier.juiceDilation— visual-juice channel for boss cinematics (Awakened Mech phase transitions write here for short slow-mo windows). Clamped to[0.1, 1.0]so a stuck/dropped revert can’t freeze the sim or push it past baseline. The boss code that writesjuiceDilationowns its own revert-on-timer; the loop is a pure consumer.
accumulator += rawDt * simRate, so the number of sim steps per rAF scales with the combined dilation.
Hit-freeze fast-path
When game._hitFreezeTimer > 0 the loop:
- Decrements
_hitFreezeTimerin wall time (rawDt), not sim time. - When the timer hits 0, restores
game.timeDilation = game._testSpeedMult || 1. - Ticks
_invertScreenTimerin wall time so the invert overlay survives the freeze. - Writes
game._rawDt, sets_stepsThisFrame = 0, and advanceswallTime+uiTime. - Returns without running any sim steps.
Outside the freeze branch, _invertScreenTimer is still ticked in wall time so the overlay never freezes with the sim.
Pause and menu
game.phase === 'menu' returns immediately after lastFrameTime and rawDt are computed — no accumulator update, no sim, no timers. Physics only runs while phase is playing or birth. Mission timer only drains while phase === 'playing'. The accumulator persists across pauses but is bounded by MAX_ACCUM, so a long pause drops back to a 250ms ceiling on resume.
Frame state writes
After the step loop, the loop publishes:
game._dt = steps * SIM_DT— total sim time advanced this rAF. Frame-level systems inbridge.ts(GameMaster.tick, WeaponManager.updateCooldowns, enemy AI, pickups, crates) consume this so they stay at true 60Hz regardless of display refresh. On 120Hz displayssteps = 0half the time (system skips); on 30Hz displayssteps = 2(system gets 2× dt); on 60Hzsteps = 1, dt = SIM_DT.game._rawDt— wall-clock delta this frame.game._stepsThisFrame— number of sim steps actually run.game._simFrame— monotonic sim-step counter.game.wallTime += rawDt * 1000andgame.uiTime += rawDtalways advance (outside the sim loop), so UI animations and wall-clock telemetry never freeze.
Rapier physics inside stepSimulation always uses SIM_DT per step — fixed-step is correct regardless of how many sim steps the outer loop chooses to run.
Reset and start
resetFrameTiming()zeroslastFrameTimeandaccumulator— used by the test runner before pumping synthetic frames, so the firstelapsedis computed against frame 0 instead of a stale value.startNewGame()resets game state, entities, modifiers, signals, smoothed FPS, the deterministicClock,lastFrameTime,accumulator, andsimFrame, then flipsgame.phase = 'playing'.
Key file
src/starship-survivors/engine/core/loop.ts— fixed-timestep loop, accumulator, hit-freeze fast-path.
Key constants
| Constant | Value | Meaning |
|---|---|---|
SIM_DT | 1/60 s | One sim step. Matches Rapier FIXED_DT. |
MAX_STEPS_BASE | 4 | Base spiral-of-death cap (60ms budget at 1×). Scaled by simRate. |
MAX_ACCUM | 0.25 s | Accumulator ceiling; drops excess on tab resume / long stall. |
rawDt cap | 0.033 s | ~30fps floor on working delta. |
| Stall threshold | > 0.2 s elapsed | Resets accumulator and skips the frame. |
juiceDilation clamp | [0.1, 1.0] | Boss-cinematic slow-mo bounds. |