Deterministic fixed timestep physics simulation decoupled from variable display refresh rates using state accumulator and alpha render interpolation in TypeScript.
Working code for the article "Physics with a Metronome: Fixed-Timestep Game Loop and Render Interpolation in TypeScript". It upgrades a variable-dt physics loop into a deterministic fixed timestep loop that produces byte-for-byte identical results on every machine, then decouples drawing from physics and interpolates between two ticks.
What's inside:
- Mini physics engine (
src/world.ts) — semi-implicit Euler + wall bounce.Bodygains aprevfield for interpolation, andWorldgainssnapshot(), which is called before each step. - Accumulator loop (
src/sim.ts) — accumulates real time and steps in fixedSTEPslices; includes death-spiral protection (maxFrameclamping +maxStepsceiling + debt clearing) and thealpha = acc / STEPinterpolation ratio. - Live demo (
src/demo.ts+index.html) — bouncing ball, interpolation on/off, a "simulate low FPS" toggle and a "Physics Hz" slider, live FPS/tick counters. - Determinism proof (
test/sim.test.ts) — same total time, different frame splits → fixed step stays byte-identical, variable dt diverges.
npm installnpm run devThe demo opens at http://localhost:5173/. It runs entirely in the browser; no
separate Node server process is needed in the background (the Vite dev server is enough).
Do not open
index.htmlwithfile://— the ES modules will not resolve and you will get a blank screen. The Vite dev server is required.
What to try: drag the "Physics Hz" slider down to 4. With interpolation off,
the ball teleports 4 times per second (visible jitter); the moment you turn it on,
the gap between those same 4 ticks is filled in at 60 FPS and the ball glides. The
physics is identical in both cases; the only thing that changed is what your eye sees.
Use "simulate low FPS" to produce a large frameTime and watch the accumulator drain
multiple ticks in a single frame.
npm testExpected: 5 tests pass.
- Determinism (3 asserts): the same 10 seconds is split into 60/15/30 FPS; the
fixed-step state (
pos,vel) istoEqualin all three — bit identical. - Divergence: the same 0.8 seconds of free fall, split into different
dts under variable dt, givesnot.toEqual— semi-implicit Euler's dependence ondt. - Interpolation math (3 tests):
lerpatt=0,t=1,t=0.5.
npm run benchA deterministic measurement (no browser, vite-node): it feeds the same 10 seconds
through three FPS scenarios. Sample output:
FIXED STEP (accumulator) — same total duration, three frame splits:
60 FPS (600x) pos=( 393.120000, 580.000000) vel=( 140.800000, -6.666667)
15 FPS (150x4) pos=( 393.120000, 580.000000) vel=( 140.800000, -6.666667)
30 FPS (300x2) pos=( 393.120000, 580.000000) vel=( 140.800000, -6.666667)
-> max |Δpos| = (0, 0) EXACTLY IDENTICAL
VARIABLE dt (old loop) — same total duration, two frame splits:
60 FPS (600x) pos=( 393.120000, 580.000000) vel=( 140.800000, -6.666667)
15 FPS (150x4) pos=( 386.080000, 580.000000) vel=( 140.800000, -26.666387)
-> |Δpos| = (7.0400, 0.0000) DIVERGED
Summary: fixed step diverges 0 px, variable dt diverges 7.0 px (same 10 s).
npm run buildtsc (type check, noEmit) + vite build (production build). Both must pass
without errors.
src/
vec.ts # Vec2 + vec/add/scale + lerp (interpolation)
world.ts # Body (prev), createBody, World.snapshot/step/collideWalls
sim.ts # Accumulator (with EPS), runFixed, runVariable
demo.ts # Browser demo: rAF loop + controls + HUD
bench-cli.ts # Deterministic bench (vite-node)
test/
sim.test.ts # Determinism + divergence + lerp tests
index.html # Canvas + controls
MIT