Three.js From Zero · Article s8-03

S8-03 Game Loop Architectures

Season 8 · Article 03

Game Loop Architectures

Fixed timestep for physics. Variable rendering for smooth display. The "fix your timestep" article by Glenn Fiedler — codified here in Three.js.

1. The naïve loop is wrong

function tick() {
  const dt = (now - last) / 1000;
  physics.step(dt);       // Bug: variable dt makes physics non-deterministic
  render();
  requestAnimationFrame(tick);
}

If the browser drops a frame, dt spikes. Physics blows up (fast collisions tunnel through walls). Replays desync.

2. The fix: accumulator pattern

const FIXED_DT = 1 / 60;  // physics at 60Hz
let acc = 0, last = performance.now();

function tick(now) {
  const frameTime = Math.min((now - last) / 1000, 0.25); // clamp to avoid spiral of death
  last = now;
  acc += frameTime;

  while (acc >= FIXED_DT) {
    physics.step(FIXED_DT);   // always exactly 1/60
    acc -= FIXED_DT;
  }

  const alpha = acc / FIXED_DT;  // 0..1 interpolation
  render(alpha);
  requestAnimationFrame(tick);
}

3. Interpolation for smooth render

Between physics steps, render interpolates:

function render(alpha) {
  for (const obj of renderables) {
    obj.position.lerpVectors(obj.prevPosition, obj.currentPosition, alpha);
  }
  renderer.render(scene, camera);
}

120Hz display + 60Hz physics = perfectly smooth. Physics stays stable.

4. Three.js + Rapier example

const world = new RAPIER.World(gravity);
const FIXED_DT = 1 / 60;
world.timestep = FIXED_DT;

let acc = 0, last = performance.now();
function tick(now) {
  const dt = Math.min((now - last) / 1000, 0.25);
  last = now;
  acc += dt;

  while (acc >= FIXED_DT) {
    // Save positions BEFORE step
    for (const [mesh, body] of pairs) {
      mesh.userData.prevPos.copy(mesh.position);
    }
    world.step();
    // Copy body positions to mesh
    for (const [mesh, body] of pairs) {
      mesh.position.copy(body.translation());
    }
    acc -= FIXED_DT;
  }

  const alpha = acc / FIXED_DT;
  for (const [mesh, body] of pairs) {
    mesh.position.lerpVectors(mesh.userData.prevPos, body.translation(), alpha);
  }
  renderer.render(scene, camera);
  requestAnimationFrame(tick);
}

5. Live demo — naive vs fixed timestep

Toggle between naive (variable) and fixed-step. Throttle CPU via stalls. Watch physics stability.

6. Spiral of death

If one physics step takes > 1/60s, accumulator grows faster than drained. Next frame accumulates more. Spiral.

Fix: clamp frameTime = min(frameTime, 0.25). Accept some slow-motion during spikes instead of lockup.

7. Variable render / fixed update (Unity pattern)

  • Update(): variable, per-frame. Input, camera, visuals.
  • FixedUpdate(): fixed, per-physics-step. Physics, network send.
  • LateUpdate(): variable, after Update. Camera follow, final adjustments.

8. Deterministic replays

With fixed timestep + seeded RNG + deterministic physics: same inputs → same simulation → replay works. Essential for multiplayer reconciliation, ghosts, esports replays.

9. Takeaways

  • Never give physics a variable dt. Always fixed.
  • Accumulator pattern: drain in fixed chunks, render between.
  • Interpolate between physics states for smooth rendering.
  • Clamp frame time to avoid spiral of death.
  • Deterministic replays need fixed timestep.