The game loop: why delta time is not optional
Moving 5 pixels per frame runs twice as fast on a 60Hz screen and two and a half times as fast at 144Hz. Multiply by elapsed time to decouple physics from refresh rate, then cap it.
The same “move 5 pixels per frame” is fine at 60Hz, two and a half times too fast at 144Hz, and crawls on a machine dropping frames. Frame rate is not a constant and cannot stand in for time.
Wrong versus right
// wrong: speed follows the refresh rate
function frame() {
player.x += 5;
}
// right: speed is proportional to time
let last = performance.now();
function frame(now: number) {
const dt = (now - last) / 1000; // seconds
last = now;
player.x += 300 * dt; // 300 pixels per second
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Once the unit is per second, the speed no longer depends on frame rate.
Collisions and tunnelling
A large dt can step over a whole wall. At 300 pixels per second and a 100ms frame, that is 30 pixels in one step — straight through a thin wall.
Three options, cheapest first:
| Approach | Cost |
|---|---|
| Cap dt at 1/30 s | the game slows during drops but never tunnels |
| Grid or swept AABB | needs a different collision algorithm |
| Fixed-step physics plus interpolated render | most stable, most complex |
A small game only needs the first:
const dt = Math.min((now - last) / 1000, 1 / 30);
The cap is not optional. Switch away from a tab and back, and some browsers hand you a dt of several seconds; without a cap the character teleports off screen.
Fixed step: for when you need determinism
If replays, networked sync, or physical stability require identical results every run, variable dt will not do:
const STEP = 1 / 60;
let acc = 0;
function frame(now: number) {
acc += Math.min((now - last) / 1000, 0.25);
last = now;
while (acc >= STEP) {
update(STEP); // physics only ever sees STEP
acc -= STEP;
}
render(acc / STEP); // interpolate to hide the stair steps
}
Physics always advances in 1/60 second slices while rendering interpolates with the leftover. This is the only way to get determinism and smoothness together.
Do not time with Date.now
Date.now() may be coarsened to milliseconds or worse, and it jumps when the system clock is corrected. Use performance.now():
- monotonic, unaffected by clock adjustments
- sub-millisecond resolution
Game timing, animation and profiling should all use it.
Stop when the tab is hidden
Browsers nearly stop rAF in hidden tabs. The dt on return is enormous. Besides the cap, listen to visibility:
document.addEventListener('visibilitychange', () => {
if (document.hidden) pause();
else last = performance.now(); // realign and discard hidden time
});
Resetting last on resume is more thorough than relying on the dt cap.
A frame is the renderer’s beat, not a unit of time. Everything time-related must be multiplied by dt, and dt must have a ceiling.

Comments
…