The game loop & phases
The game loop is the heartbeat of the engine. vgai uses a fixed-timestep loop with an accumulator, so gameplay and physics advance at a steady rate no matter the display frame rate. Rendering is deliberately fixed-rate too (decided — D1): there is no per-real-frame render step and no display-refresh interpolation.
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 frame:const dt = Math.min(rawDt, maxAccumulator) * timeScale;accumulator += dt;if (accumulator > maxAccumulator) accumulator = maxAccumulator;
let steps = 0;while (accumulator >= fixedDt && steps < maxSubSteps) { config.update(fixedDt); // one fixed tick — rendering happens *inside* this call accumulator -= fixedDt; steps++;}Three things to note:
- No
render(alpha), no interpolation.config.update(fixedDt)is called once per consumed fixed substep, and per the documented phase order (PHASE_ORDERincore/types.ts) therenderphase runs as the last phase inside that same call — there is no separate render callback and nothing to interpolate between. A real frame whose accumulator produces zero substeps callsupdatezero times and therefore renders zero times that frame. - 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. The default phase for GameComponents. |
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 single phase, runPhase executes three structural buckets in a fixed order:
engine systems (everything registered before the engine boundary is marked), then the
phase’s one component tick (a dedicated slot the ComponentManager owns — see
Entities & components), then game systems
(everything a game’s setup() registers via ctx.systems.add). Within each bucket,
systems run in registration order — there is no implicit dependency resolution. The
bucket order is a structural guarantee: engine systems always run before component ticks,
and component ticks before game callbacks, no matter when each was registered. Ordering
beyond that is explicit by design (the “one obvious way” principle).
See also
Section titled “See also”- Entities & components — how
update()plugs into a phase. - Scenes & prefabs — what runs at load time.
- Engine API reference — the generated phase list + GameComponent contract.
- Design philosophy — why ordering is sacred.