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.
Fixed timestep
Section titled “Fixed timestep”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 ceilinglet 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;alphais the remaining accumulator fraction anddisplayDtis that display frame’s clamped, time-scaled delta.Game.onRenderStepis the display-rate hook. - Spiral-of-death guard, with a hard accumulator cap.
rawDtis clamped tomaxAccumulator(fixedDt * maxSubSteps, default 8 steps) before it’s added to the accumulator, and after adding, the accumulator itself is clamped back down tomaxAccumulatorif it overflows — regardless oftimeScaleor 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. timeScaleis clamped to[0, 8](values outside the range are clamped with a console warning):0pauses,0.5is slow-motion, up to8for fast-forward. TheGameSession’spause()/resume()/step()controls build on this.
The eight phases
Section titled “The eight phases”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):
| Phase | Purpose |
|---|---|
input | Sample keyboard / mouse / gamepad. |
prePhysics | Apply forces / intents to rigid bodies before the step. |
physics | Step the Rapier world. |
postPhysics | Read results back; sync body transforms to Object3D; dispatch triggers. |
gameLogic | General gameplay systems. |
animation | Advance animation graphs / mixers. |
preRender | Camera / HUD fixups before drawing. |
render | Draw 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.
Systems
Section titled “Systems”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.
See also
Section titled “See also”- Entities & world code — how each root owns behavior.
- Worlds and roots — what the loop is stepping.
- Engine API reference — the generated phase and system contracts.
- Design philosophy — why ordering is sacred.