Skip to content
JavaScript js canvas 6 min read

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:

  1. 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.
  2. Update — advance the simulation: move objects, run physics, check collisions, change score. This is pure logic; it never draws.
  3. 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 next dt can be several seconds, teleporting objects through walls. A line like const 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);
}
ApproachUpdate dtBest forTrade-off
Variable timestepReal elapsed timeArcade/casual gamesPhysics may differ per frame rate
Fixed timestepConstant STEPDeterministic physics, networkingMore 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 state object so saving, resetting, and debugging stay trivial.
  • Never mutate game state inside render, and never draw inside update — the separation pays off as the game grows.
  • Always multiply movement by delta time, and always clamp dt to 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 cancelAnimationFrame and visibilitychange to pause cleanly and reset last on 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.
Last updated June 1, 2026
Was this helpful?