Physics Timestep Patterns — Fixed dt, Sub-stepping, Deterministic Replay

The choice of timestep strategy determines whether your simulation explodes, jitters, or runs identically on a 30 Hz laptop and a 144 Hz gaming monitor. This tip covers the standard patterns used in physics engines, game servers, and our web simulations, with copy-paste JavaScript implementations.

Why Timestep Matters

Numerical integration of physics equations is not exact. Euler integration accumulates error proportional to dt² per step. If you double the timestep, you quadruple the error. More importantly, many systems go numerically unstable above a critical timestep — energy grows exponentially and the simulation explodes, regardless of how physically accurate the equations are.

Simultaneously, the browser's render loop does not tick at a deterministic rate. requestAnimationFrame tries to sync to the display refresh (60/120/144 Hz), but background tabs, system load, and display scaling can produce any interval between 4 ms and 60+ ms. If you feed that variable dt directly into physics, the simulation behaves differently on every machine.

Pattern 1 — Variable dt (Naïve)

❌ Don't do this

Pass performance.now() - lastTime directly to physics.step(dt). One GC pause and your objects tunnel through walls. Not deterministic. Different results on every machine.

✅ When it's acceptable

Pure particle systems with no collision detection, visual-only effects, animations. Anywhere physical correctness doesn't matter and jitter is invisible.

Pattern 2 — Fixed dt Accumulator (Recommended)

Accumulate wall-clock time, then consume it in fixed-size bites. This decouples the physics from the render rate and guarantees identical simulation results regardless of frame rate.

JavaScript — Fixed Timestep Accumulator
const FIXED_DT = 1 / 120;   // 120 Hz physics, independent of render rate
const MAX_STEPS = 8;        // safety cap — prevents spiral of death
let accumulator = 0;
let lastTime = performance.now() / 1000;

function loop(now) {
  requestAnimationFrame(loop);
  const elapsed = now / 1000 - lastTime;
  lastTime = now / 1000;

  accumulator += Math.min(elapsed, MAX_STEPS * FIXED_DT); // cap on lag spike

  while (accumulator >= FIXED_DT) {
    physics.step(FIXED_DT);   // always called with the exact same dt
    accumulator -= FIXED_DT;
  }

  const alpha = accumulator / FIXED_DT;  // 0..1 — for render interpolation
  renderer.render(physics.interpolate(alpha));
}

The spiral of death: if your physics step takes longer than FIXED_DT wall time, the accumulator grows faster than it drains. The simulation falls behind, tries to catch up, gets slower, falls further behind. The MAX_STEPS cap trades accuracy for survival — the sim slows down in real time rather than locking the browser.

Pattern 3 — Sub-stepping for Stiff Constraints

Stiff systems (rigid bodies, cloth, springs with high stiffness) require a very small dt for stability. Instead of running the entire simulation at 2000 Hz, run it at 60 Hz but divide each step into N sub-steps internally:

JavaScript — Sub-stepping
function step(dt) {
  const SUB_STEPS = 8;
  const subDt = dt / SUB_STEPS;
  for (let i = 0; i < SUB_STEPS; i++) {
    integrateBodies(subDt);      // velocity Verlet or semi-implicit Euler
    resolveConstraints(subDt);   // PBD, impulse, or penalty forces
  }
}

Pattern 4 — Render Interpolation

With any fixed-physics/variable-render setup, the render state is always slightly "behind" — it shows the last committed physics state, not the exact current one. To eliminate visible jitter, lerp between the previous physics state and the current one using the accumulated fraction (alpha):

JavaScript — State Interpolation
// In your physics body class:
class Body {
  commit() {
    this.prevPos.copy(this.pos);
    this.prevRot.copy(this.rot);
  }
  interpolate(alpha) {
    renderPos.lerpVectors(this.prevPos, this.pos, alpha);
    renderRot.slerpQuaternions(this.prevRot, this.rot, alpha);
  }
}

Deterministic Replay and Network Sync

A fixed-dt simulation with the same PRNG seed and initial state will produce exactly the same output on every run — this enables:

Floating-point determinism caveat: IEEE 754 arithmetic is only deterministic for the same instruction order. Different browsers, JIT tiers, and CPU architectures can produce subtly different results. For true cross-platform determinism, use integer fixed-point math or restrict to 32-bit floats with explicit FMA control.

Quick Reference

Fixed Timestep Accumulator Semi-implicit Euler Velocity Verlet Sub-stepping Render State Interpolation Spiral-of-Death Cap Deterministic Replay Lockstep Networking State Hash Desync Detection