Deterministic Game Loops in JavaScript (Fixed Timestep + Interpolation)
Why your physics jitters — and how to fix it with a fixed update, accumulator, and smooth rendering.
Most browser tutorials wire everything inside requestAnimationFrame with a variable dt. That’s fine
for toys, but physics gets inconsistent and collisions go spicy when frames stall. The fix: a fixed timestep
update loop with an accumulator, optional interpolation for butter visuals, and a hiccup clamp
so tab switches don’t explode your simulation.
The Problem with Variable dt
With variable dt, physics integrates with different step sizes each frame.
Big frames (CPU hiccup, tab switch) make objects tunnel through walls and make friction/drag inconsistent.
Capping dt helps, but still changes the step size unpredictably.
Fixed Timestep Pattern
Run simulation updates in constant chunks (e.g., 1/60s). Render whenever you can.
// loop.js — fixed timestep (60 Hz) with accumulator
const FIXED_DT = 1/60; // simulation step in seconds
const MAX_STEPS = 5; // safety: avoid spiral of death
let acc = 0; // unconsumed time
let last = performance.now() / 1000;
function tick(update, render){
const now = performance.now() / 1000;
let frame = now - last; last = now;
// Hard clamp a single frame gap (tab switches can be 10+ seconds)
if (frame > 0.25) frame = 0.25;
acc += frame;
// Multiple fixed updates if needed
let steps = 0;
while (acc >= FIXED_DT && steps < MAX_STEPS){
update(FIXED_DT);
acc -= FIXED_DT;
steps++;
}
// alpha in [0..1): fraction to next simulation step (for interpolation)
const alpha = acc / FIXED_DT;
render(alpha);
requestAnimationFrame(() => tick(update, render));
}
Visual Interpolation (Alpha)
To keep visuals smooth between fixed updates, interpolate render positions between the last
previous state and the current state using alpha. Physics stays deterministic; visuals get 120Hz vibes.
// entity.js — keep previous and current transforms
class Body {
constructor(x=0,y=0){
this.x = x; this.y = y; // current state
this.px = x; this.py = y; // previous state (for interpolation)
this.vx = 0; this.vy = 0;
}
preStep(){ this.px = this.x; this.py = this.y; } // snapshot before integrating
integrate(dt){
this.vy += 800 * dt; // gravity example
this.x += this.vx * dt;
this.y += this.vy * dt;
}
draw(ctx, alpha){
const ix = this.px + (this.x - this.px) * alpha;
const iy = this.py + (this.y - this.py) * alpha;
ctx.fillRect(ix, iy, 20, 20);
}
}
Hiccup Clamp & Spiral of Death
If the page lags, your accumulator can fill up and you’ll try to run hundreds of updates in one frame.
Use MAX_STEPS to cap updates per render. If you hit the cap, drop the extra time (time dilation) or
fast-forward non-critical systems. This avoids a death spiral where more work causes more lag.
Complete Minimal Example
Copy into index.html and open. Arrow keys move, space jumps. Resize-safe & deterministic.
<!doctype html>
<meta charset="utf-8">
<title>Fixed Timestep Demo</title>
<style>
html,body{margin:0;height:100%;background:#0b0f1a;color:#e9f2ff;font:14px system-ui}
#wrap{max-width:960px;margin:0 auto;padding:12px}
canvas{width:100%;height:420px;display:block;background:#0e1426;border-radius:12px;border:1px solid #1a2442}
.row{display:flex;gap:12px;align-items:center;justify-content:space-between}
</style>
<div id="wrap">
<h1>Fixed Timestep + Interpolation</h1>
<div class="row"><div>Use ←↑↓→ and Space</div><div id="stats"></div></div>
<canvas id="game" width="960" height="420"></canvas>
</div>
<script>
const DPR = Math.min(2, window.devicePixelRatio || 1);
const FIXED_DT = 1/60, MAX_STEPS = 5;
const K = new Set();
addEventListener('keydown', e => K.add(e.key));
addEventListener('keyup', e => K.delete(e.key));
const c = document.getElementById('game');
const ctx = c.getContext('2d');
function resize(){
const cssW = c.clientWidth, cssH = c.clientHeight;
c.width = Math.round(cssW * DPR); c.height = Math.round(cssH * DPR);
ctx.setTransform(DPR,0,0,DPR,0,0); // map logical pixels
}
addEventListener('resize', resize); resize();
class Body {
constructor(x,y){ this.x=x; this.y=y; this.px=x; this.py=y; this.vx=0; this.vy=0; this.w=22; this.h=22; this.grounded=false; }
preStep(){ this.px=this.x; this.py=this.y; }
integrate(dt){
const ax = (K.has('ArrowRight')?1:0) - (K.has('ArrowLeft')?1:0);
if (K.has(' ') && this.grounded){ this.vy = -320; this.grounded=false; }
this.vx = ax * 180;
this.vy += 900 * dt;
this.x += this.vx * dt;
this.y += this.vy * dt;
// simple floor at y=360
if (this.y + this.h > 360){ this.y = 360 - this.h; this.vy = 0; this.grounded = true; }
// walls
if (this.x < 0) this.x = 0;
if (this.x + this.w > 960) this.x = 960 - this.w;
}
draw(alpha){
const ix = this.px + (this.x - this.px) * alpha;
const iy = this.py + (this.y - this.py) * alpha;
ctx.fillStyle = '#6cf'; ctx.fillRect(ix, iy, this.w, this.h);
}
}
const player = new Body(100, 100);
let acc = 0, last = performance.now()/1000, frames=0, fps=0, stamp=last;
function updateFixed(dt){
player.preStep();
player.integrate(dt);
}
function render(alpha){
ctx.clearRect(0,0,c.width,c.height);
// ground
ctx.fillStyle='#182448'; ctx.fillRect(0,360,960,60);
// player
player.draw(alpha);
}
function loop(){
const now = performance.now()/1000;
let frame = now - last; last = now;
if(frame > 0.25) frame = 0.25; // hiccup clamp
acc += frame;
let steps=0;
while(acc >= FIXED_DT && steps < MAX_STEPS){
updateFixed(FIXED_DT);
acc -= FIXED_DT; steps++;
}
const alpha = acc / FIXED_DT;
render(alpha);
// FPS display (smoothed)
frames++;
if (now - stamp >= 0.5){ fps = Math.round(frames / (now - stamp)); frames=0; stamp=now; }
document.getElementById('stats').textContent = fps + ' fps • steps:' + steps;
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
Testing & Debug Tips
- Artificial lag: from DevTools → Performance → CPU Throttling (4x) to watch the accumulator work.
- Jitter check: draw a grid; watch the body glide smoothly thanks to interpolation.
- Determinism: record inputs and re-play; with fixed steps, physics should reproduce.
Next Steps
- Promote collisions to fixed-step only; render uses interpolated transforms.
- Bucket work: AI at 30 Hz, particles at 60 Hz, pathfinding budgeted across frames.
- Expose
FIXED_DTas 1/120 on fast devices for crisper simulation (keep render interpolated).