Skip to content

The game loop & phases

The game loop separates fixed-rate simulation from display-rate presentation. Gameplay and physics consume fixed substeps from an accumulator; a normal browser host presents exactly once per animation frame, including frames that consume no simulation step.

createGameLoop (packages/engine/src/core/game-loop.ts) advances simulation in fixed increments — fixedTimestep defaults to 1/60 (60 Hz). Each animation frame it adds the elapsed real time to an accumulator and runs as many fixed steps as fit:

// game-loop.ts (shape)
const fixedDt = config.fixedTimestep ?? 1 / 60;
const maxSubSteps = config.maxSubSteps ?? 8;
const maxAccumulator = fixedDt * maxSubSteps; // also the accumulator's hard ceiling
let accumulator = 0;
// per display frame (shape):
const paced = paceFrame(accumulator, rawDt, timeScale, { fixedDt, maxSubSteps });
accumulator = paced.accumulator;
for (let i = 0; i < paced.steps; i++) config.update(fixedDt);
config.render?.(paced.alpha, paced.displayDt);

Three things to note:

  • Simulation and presentation have different clocks. config.update(fixedDt) runs once per consumed fixed substep. config.render(alpha, displayDt) runs once per display frame; alpha is the remaining accumulator fraction and displayDt is that display frame’s clamped, time-scaled delta. Game.onRenderStep is the display-rate hook.
  • Spiral-of-death guard, with a hard accumulator cap. rawDt is clamped to maxAccumulator (fixedDt * maxSubSteps, default 8 steps) before it’s added to the accumulator, and after adding, the accumulator itself is clamped back down to maxAccumulator if it overflows — regardless of timeScale or how large the frame gap was. Time that can’t possibly be caught up on is dropped, never carried forward, so a long stall can’t snowball into an ever-growing backlog.
  • timeScale is clamped to [0, 8] (values outside the range are clamped with a console warning): 0 pauses, 0.5 is slow-motion, up to 8 for fast-forward. The GameSession’s pause() / resume() / step() controls build on this.

Every fixed tick, systems and component updates run in one fixed, ordered sequence. The order is defined once in PHASE_ORDER (packages/engine/src/core/types.ts):

The eight phases in order: input, prePhysics, physics, postPhysics, gameLogic, animation, preRender, render
PHASE_ORDER — the fixed sequence every tick follows.
PhasePurpose
inputSample keyboard / mouse / gamepad.
prePhysicsApply forces / intents to rigid bodies before the step.
physicsStep the Rapier world.
postPhysicsRead results back; sync body transforms to Object3D; dispatch triggers.
gameLogicGeneral gameplay systems.
animationAdvance animation graphs / mixers.
preRenderCamera / HUD fixups before drawing.
renderDraw the frame.

This order is sacred: data flows one way through it, so you never have to reason about “did this run before or after physics?” A component that reads physics results belongs in postPhysics or later; one that pushes forces belongs in prePhysics.

A system is just a function of delta time: type SystemFn = (dt: number) => void. The SystemRunner (packages/engine/src/core/system-runner.ts) registers systems into phase buckets and runs each bucket in PHASE_ORDER. A richer SystemDef adds init() and dispose() lifecycle hooks alongside its phase and update.

Within a phase, runPhase executes two structural buckets: engine systems registered before markEngineBoundary(), then game systems registered afterward. Systems remain in registration order within each bucket. There is no implicit dependency resolver and no component-manager tick between them; first-party TSX worlds use their renderer’s native frame hooks.