Building a Game Loop
A game is just a program that redraws itself many times per second while responding to input. The heart of any game is the game loop: a cycle that reads input, updates the world, and renders the result, repeated as fast as the browser allows. Getting this structure right — separating state, update logic, and rendering, and accounting for variable frame timing with a delta — is what keeps movement smooth and your code maintainable. This page builds a small but complete game architecture you can extend.
The anatomy of a game loop
Every frame your game does three things in a fixed order:
- Input — capture what the player is doing (keys, pointer, touch). Usually you record input in event handlers and read it during update, rather than mutating game state directly inside the handler.
- Update — advance the simulation: move objects, run physics, check collisions, change score. This is pure logic; it never draws.
- Render — clear the canvas and draw the current state. This never changes state.
Keeping update and render separate is the single most important habit. It makes the code testable, lets you render less often than you update (or vice versa), and prevents subtle bugs where drawing accidentally mutates the world.
The loop itself is driven by requestAnimationFrame (rAF), which calls your callback right before the next repaint — typically 60 times per second, but it varies by display and load.
State and delta time
All mutable game data lives in one place: a state object. Centralizing state makes saving, resetting, and reasoning about the game trivial.
const state = {
player: { x: 180, y: 380, width: 60, speed: 320 },
block: { x: 120, y: 0, size: 24, speed: 180 },
score: 0,
running: true,
};
Because frames are not perfectly evenly spaced, you must scale movement by delta time (dt) — the seconds elapsed since the last frame. Multiply every velocity by dt so a “320 pixels per second” object moves the same real-world distance whether the machine runs at 30 or 144 fps.
let last = performance.now();
function frame(now) {
const dt = (now - last) / 1000; // seconds since last frame
last = now;
update(dt);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Always clamp
dt. If the tab is backgrounded, rAF pauses and the nextdtcan be several seconds, teleporting objects through walls. A line likeconst step = Math.min(dt, 0.05)caps each update to a sane maximum.
Handling input
Record key state in a set or map on keydown/keyup, then read it during update. This decouples the polling rate of events from your update rate and naturally supports holding multiple keys.
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.key));
addEventListener("keyup", (e) => keys.delete(e.key));
function readMovement() {
let dir = 0;
if (keys.has("ArrowLeft") || keys.has("a")) dir -= 1;
if (keys.has("ArrowRight") || keys.has("d")) dir += 1;
return dir;
}
Putting it together: catch the block
Here is a complete, self-contained game. Move the paddle with the arrow keys (or A/D) to catch falling blocks. Each catch scores a point; a miss ends the run. Notice how update only touches state and render only reads it.
<canvas id="game" width="360" height="420" style="background:#0f172a;border-radius:12px"></canvas>
<p id="hud" style="font:16px system-ui;color:#e2e8f0"></p>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const hud = document.getElementById("hud");
const state = {
player: { x: 150, y: 390, width: 60, height: 16, speed: 320 },
block: { x: 120, y: 0, size: 24, speed: 160 },
score: 0,
best: 0,
alive: true,
};
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.key));
addEventListener("keyup", (e) => keys.delete(e.key));
function resetBlock() {
state.block.x = Math.random() * (canvas.width - state.block.size);
state.block.y = -state.block.size;
state.block.speed += 8; // ramp up difficulty
}
function update(dt) {
if (!state.alive) {
if (keys.has(" ")) { Object.assign(state, freshState()); }
return;
}
const { player, block } = state;
// input -> movement
let dir = 0;
if (keys.has("ArrowLeft") || keys.has("a")) dir -= 1;
if (keys.has("ArrowRight") || keys.has("d")) dir += 1;
player.x += dir * player.speed * dt;
player.x = Math.max(0, Math.min(canvas.width - player.width, player.x));
// physics
block.y += block.speed * dt;
// collision: did the paddle catch it?
const caught =
block.y + block.size >= player.y &&
block.x + block.size >= player.x &&
block.x <= player.x + player.width;
if (caught) { state.score++; resetBlock(); }
else if (block.y > canvas.height) {
state.alive = false;
state.best = Math.max(state.best, state.score);
}
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { player, block } = state;
ctx.fillStyle = "#38bdf8";
ctx.fillRect(player.x, player.y, player.width, player.height);
ctx.fillStyle = "#f472b6";
ctx.fillRect(block.x, block.y, block.size, block.size);
hud.textContent = state.alive
? `Score: ${state.score}`
: `Game over — score ${state.score} (best ${state.best}). Press Space.`;
}
function freshState() {
return {
player: { x: 150, y: 390, width: 60, height: 16, speed: 320 },
block: { x: 120, y: 0, size: 24, speed: 160 },
score: 0, best: state.best, alive: true,
};
}
let last = performance.now();
function loop(now) {
const dt = Math.min((now - last) / 1000, 0.05); // clamp delta
last = now;
update(dt);
render();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
Fixed vs. variable timestep
Multiplying by dt gives a variable timestep — simple and smooth, perfect for arcade games. But physics that depends on previous physics (stacking, fast collisions) can behave differently at different frame rates. A fixed timestep solves this by accumulating elapsed time and running update in constant slices.
let accumulator = 0;
const STEP = 1 / 60; // run physics at a steady 60 Hz
function loop(now) {
accumulator += Math.min((now - last) / 1000, 0.25);
last = now;
while (accumulator >= STEP) {
update(STEP); // always the same dt — deterministic
accumulator -= STEP;
}
render();
requestAnimationFrame(loop);
}
| Approach | Update dt | Best for | Trade-off |
|---|---|---|---|
| Variable timestep | Real elapsed time | Arcade/casual games | Physics may differ per frame rate |
| Fixed timestep | Constant STEP | Deterministic physics, networking | More code; needs interpolation for ultra-smooth render |
Output: with a fixed step the simulation is identical on every machine:
update(0.0166...) x N per second, regardless of display refresh
Pausing and the loop lifecycle
Pause by skipping update while still rendering (or stop rendering entirely). To fully stop the loop, save the id from requestAnimationFrame and pass it to cancelAnimationFrame. Pausing automatically when the tab hides is good citizenship and avoids the giant-dt problem:
document.addEventListener("visibilitychange", () => {
if (document.hidden) state.running = false;
else { state.running = true; last = performance.now(); } // avoid a huge dt
});
Best practices
- Keep all mutable data in a single
stateobject so saving, resetting, and debugging stay trivial. - Never mutate game state inside
render, and never draw insideupdate— the separation pays off as the game grows. - Always multiply movement by delta time, and always clamp
dtto avoid teleporting after the tab is backgrounded. - Buffer input in a set/map from event handlers and read it during
update; don’t act on raw events mid-frame. - Use a fixed timestep when you need deterministic or networked physics; a variable timestep is fine for casual games.
- Use
cancelAnimationFrameandvisibilitychangeto pause cleanly and resetlaston resume. - Profile with the browser’s Performance panel before optimizing; most jank comes from doing too much work per frame, not from the loop itself.