Write an update/draw loop with requestAnimationFrame and delta time so movement is the same at any frame rate.
Why this matters
Every real-time game runs the same heartbeat dozens of times per second: read input, update the world, draw it. That heartbeat is the game loop. Get it wrong and your game runs twice as fast on a 120 Hz monitor, stutters when a laptop is busy, or teleports objects through walls after the player switches tabs. Get it right once and every prototype you build afterwards feels consistent.
requestAnimationFrame
In the browser, requestAnimationFrame(callback) asks the browser to call your function just before the next screen repaint. It passes a high-resolution timestamp in milliseconds. To keep looping, the callback requests the next frame itself:
function loop(timestamp) {
tick(timestamp); // do one frame of work
requestAnimationFrame(loop); // schedule the next one
}
requestAnimationFrame(loop);
Why not setInterval(tick, 16)? requestAnimationFrame lines up with the display's refresh, pauses in background tabs (saving battery), and gives you an accurate timestamp. setInterval drifts and keeps running when nobody is watching.
The problem: frame rate varies
A naive update moves a fixed amount per frame:
ball.x += 2; // 2 pixels per frame
At 60 frames per second that is 120 pixels per second. On a 144 Hz monitor it is 288. On a struggling laptop running at 30 fps it is 60. The same game now has three different speeds, which wrecks balance and makes difficulty impossible to tune.
The fix: delta time
Measure how much time passed since the last frame, called delta time or dt, and express speeds per second:
let lastTime = null;
function tick(timestamp) {
if (lastTime === null) lastTime = timestamp; // first frame: dt = 0
let dt = (timestamp - lastTime) / 1000; // ms -> seconds
lastTime = timestamp;
update(dt);
draw();
}
function update(dt) {
ball.x += ball.vx * dt; // vx is in pixels per SECOND
}
With vx = 120, the ball moves 120 pixels every second whether that second contains 30, 60 or 144 frames. A quick self-test: two updates of 0.25 seconds must leave the ball exactly where one update of 0.5 seconds does.
Clamp dt after a pause
When the player switches tabs, the browser stops calling your loop. When they come back, the first dt might be 30 seconds. Multiply a velocity by 30 and your ball leaves the level, passing through every wall on the way. The standard defence is a cap:
const MAX_DT = 0.1; // never simulate more than 100 ms in one step
dt = Math.min(dt, MAX_DT);
The game appears to "pause" during the gap instead of jumping forward. Many engines also use a fixed timestep (always step physics by exactly 1/60 s, running several steps if needed) for repeatable physics. For prototypes, variable dt with a cap is usually enough; the Clear Code video explains both.
Worked example: a bouncing ball
const ball = { x: 20, y: 90, size: 20, vx: 120 };
function update(dt) {
ball.x += ball.vx * dt;
if (ball.x + ball.size > canvas.width) { // hit the right wall
ball.x = canvas.width - ball.size;
ball.vx = -Math.abs(ball.vx);
} else if (ball.x < 0) { // hit the left wall
ball.x = 0;
ball.vx = Math.abs(ball.vx);
}
}
Two details make this robust. First, the ball is pushed back inside the wall, so it can never get "stuck" flipping direction every frame. Second, Math.abs sets the direction explicitly instead of blindly negating, which is safe even if the ball is somehow already moving away.
Keep update and draw separate
update(dt) changes numbers. draw() reads numbers and paints. Do not move things inside draw, and do not draw inside update. This separation means you can:
- run
updatemany times without drawing (to fast-forward or test), - pause by skipping
updatewhile still drawing a pause screen, - test the rules by calling
update(0.5)directly and checking the state.
The last point is exactly how professional teams write automated tests for game logic, and how the Do activity checks your work: it calls your functions with chosen values instead of waiting for real frames.
Common mistakes
- Speeds per frame instead of per second. Anything that moves should be multiplied by
dt. - Forgetting to divide by 1000. Timestamps are milliseconds; speeds per second need seconds.
- No dt cap. A tab switch becomes a teleport.
- Calling
requestAnimationFrame(loop)from several places, which starts several loops and doubles the speed. - Starting with
lastTime = 0. The firstdtis then the whole time since page load.
Quick recap
- The loop is read input, update, draw, repeated with
requestAnimationFrame. - Delta time = seconds since the last frame; multiply velocities by it.
- Cap
dt(for example at 0.1 s) to survive pauses. - Keep
updateanddrawseparate so logic is testable.
Try it
Open the Do tab to write update(dt) and tick(timestamp) for a bouncing ball, including the delta-time cap.