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