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:

  1. Clock.advanceSimStep() — deterministic clock, no wall-clock input.
  2. Input.update() and optional Input.pollGamepad().
  3. Physics.update(simDt) and RapierWorld.step(simDt) + RapierShip.syncFromRapier(alpha) (when phase is playing or birth).
  4. Modifiers.tick(simDt) plus Modifiers.recalc(ship, ship._base) when the ship has a base snapshot.
  5. applyWarpPuddleOverrides(ship) — layered on top of recalc so thrust/drag/heat get warp treatment, scaled by warpT.
  6. Camera.update(simDt).
  7. Mission timer drain (skipped while overtime, runDef.sandbox, or bossRoom is set; once missionTimer <= 0 it clamps to 0 and flips overtime = true; in overtime overtimeElapsed accrues).
  8. 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) / 1000 measures wall-clock delta.
  • If elapsed > 0.2s (tab resume / long stall) the accumulator and RapierWorld accumulator 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 to MAX_ACCUM = 0.25s so a long stall drops excess time instead of catching up.
  • The sim runs while (accumulator >= SIM_DT && steps < maxSteps), subtracting SIM_DT and incrementing steps and simFrame each 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 writes juiceDilation owns 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 _hitFreezeTimer in wall time (rawDt), not sim time.
  • When the timer hits 0, restores game.timeDilation = game._testSpeedMult || 1.
  • Ticks _invertScreenTimer in wall time so the invert overlay survives the freeze.
  • Writes game._rawDt, sets _stepsThisFrame = 0, and advances wallTime + 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 in bridge.ts (GameMaster.tick, WeaponManager.updateCooldowns, enemy AI, pickups, crates) consume this so they stay at true 60Hz regardless of display refresh. On 120Hz displays steps = 0 half the time (system skips); on 30Hz displays steps = 2 (system gets 2× dt); on 60Hz steps = 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 * 1000 and game.uiTime += rawDt always 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() zeros lastFrameTime and accumulator — used by the test runner before pumping synthetic frames, so the first elapsed is computed against frame 0 instead of a stale value.
  • startNewGame() resets game state, entities, modifiers, signals, smoothed FPS, the deterministic Clock, lastFrameTime, accumulator, and simFrame, then flips game.phase = 'playing'.

Key file

  • src/starship-survivors/engine/core/loop.ts — fixed-timestep loop, accumulator, hit-freeze fast-path.

Key constants

ConstantValueMeaning
SIM_DT1/60 sOne sim step. Matches Rapier FIXED_DT.
MAX_STEPS_BASE4Base spiral-of-death cap (60ms budget at 1×). Scaled by simRate.
MAX_ACCUM0.25 sAccumulator ceiling; drops excess on tab resume / long stall.
rawDt cap0.033 s~30fps floor on working delta.
Stall threshold> 0.2 s elapsedResets accumulator and skips the frame.
juiceDilation clamp[0.1, 1.0]Boss-cinematic slow-mo bounds.