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.
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:
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):
// 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:
- Replay systems β record only inputs (key presses, parameter changes) and replay them through the same simulation to recreate any moment exactly.
- Network lockstep β all clients advance together; the host only broadcasts inputs, not positions. One check per step: if state hashes diverge, a desync is detected.
- Regression testing β run the simulation for 10 000 steps in headless mode and assert that key values match a stored snapshot.
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.