Skip to content

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.

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;
let accumulator = 0;
// per frame:
const dt = Math.min(rawDt, fixedDt * maxSubSteps) * timeScale;
accumulator += dt;
let steps = 0;
while (accumulator >= fixedDt && steps < maxSubSteps) {
config.update(fixedDt); // one fixed tick
accumulator -= fixedDt;
steps++;
}
const alpha = accumulator / fixedDt; // interpolation factor
config.render?.(alpha);

Three things to note:

  • Spiral-of-death guard. rawDt is clamped to fixedDt * maxSubSteps and the loop runs at most maxSubSteps (default 8) fixed updates per frame, so a long stall can’t snowball into an ever-growing backlog.
  • Interpolation alpha. render(alpha) is called once per frame with alpha = accumulator / fixedDt — the fraction of a step left over — so rendering can interpolate between physics states for smoothness.
  • timeScale. Setting timeScale (clamped to >= 0) scales elapsed time: 0 pauses, 0.5 is slow-motion. 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. The default phase for GameComponents.
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 single phase, systems run in registration order — there is no implicit dependency resolution. Ordering is explicit by design (the “one obvious way” principle).