Advanced

The Game Loop

A game is thousands of pictures, one after another, 60 times a second. Learn the loop, update then draw, and the delta-time `dt` that makes speed independent of frame rate.

What you will see

budget @60fps: 16.67 ms budget @30fps: 33 ms cap 1/30 = 0.033 s, blocks a 0.5 s teleport

About 5 min of reading

Quick check

Before this new lesson, one small question about the lesson you just finished. Recall it, do not look it up.

Try It

The loop lesson shows code turning into game mechanics. I run small snippets and watch the exact output, then copy the same pattern the finished game ships.

1

A game is thousands of pictures, one after another. Each cycle does two jobs in order, update, then draw, then asks the browser to run again. I sketch that two-step order and print why update-before-draw matters: drawing old positions after updating makes everything lag one frame.

function loop(dt) { update(dt) // 1. move player, spawn, collide draw() // 2. paint the current state requestAnimationFrame(loop) // 3. call loop again soon } console.log('order: update -> draw -> repeat') console.log('if you draw old positions after updating, everything lags one frame')

Before you run it, say out loud what it will print. Then open the answer.

What it prints
order: update -> draw -> repeat if you draw old positions after updating, everything lags one frame
The order is non-negotiable: update first so positions are fresh, then draw. If you drew after updating but from the old snapshot, the whole world appears one frame late. requestAnimationFrame(loop) makes the cycle self-perpetuating.
2

dt is delta time, the fraction of a second each frame took. Multiplying speed (px/s) by dt makes movement independent of frame rate. I compare 60 fps and 30 fps machines moving at 340 px/s: without dt they diverge, with dt they both travel exactly 340 px in one second. That is the whole point of dt.

function move(dt, vx) { return Math.round(vx * dt) } console.log('per frame @60fps without dt:', 340) console.log('per frame @30fps without dt:', 340) let p60 = 0; for (let i = 0; i < 60; i++) p60 += move(1/60, 340) let p30 = 0; for (let i = 0; i < 30; i++) p30 += move(1/30, 340) console.log('with dt @60fps total:', Math.round(p60)) console.log('with dt @30fps total:', Math.round(p30))

Before you run it, say out loud what it will print. Then open the answer.

What it prints
per frame @60fps without dt: 340 per frame @30fps without dt: 340 with dt @60fps total: 340 with dt @30fps total: 340
Without dt, both machines move 340 px PER FRAME, so 60 fps travels twice as far per second as 30 fps. With dt (speed * seconds-per-frame), both travel exactly 340 px per second regardless of how many frames occurred. dt turns a per-frame amount into a per-second rate.
3

The game measures dt with a Timer that stores when the previous frame started. tick() returns the seconds since last frame, clamped at 1/30. That cap is critical: tab-switching can deliver a huge dt and teleport the player. I trace a 5 ms frame, a 5000 ms tab-switch frame, and the cap so the guard is visible.

function tick(last, now, capped) { let dt = (now - last) / 1000 // seconds since last frame if (dt < 0) dt = 0 dt = Math.min(dt, capped) // cap for tab-switch return Math.round(dt * 1000) / 1000 } console.log(tick(100, 105, 1/30)) // normal 5 ms frame console.log(tick(100, 5000, 1/30)) // 5000 ms tab switch -> capped

Before you run it, say out loud what it will print. Then open the answer.

What it prints
0.005 0.033
A real 5 ms frame returns 0.005 s. A 5000 ms gap from tab-switch would be 4.9 s, but Math.min(4.9, 1/30) clamps it to 0.033 s, a bounded jump instead of a teleport across the screen. Without the cap the player would fly halfway across on return.
4

The full loop is five lines: get dt, update, consume input edges, draw, schedule next. I print the loop in order so its shape is unmistakable. It never stops on its own, requestAnimationFrame keeps rescheduling it, so the game runs until the tab closes.

function loop() { const dt = timer.tick() // 1. seconds since last frame sm.update(dt, input) // 2. advance every system input.consume() // 3. clear pressed-key edges sm.draw(ctx) // 4. paint the state requestAnimationFrame(loop) // 5. call loop again } console.log('five lines: tick, update, consume, draw, schedule')

Before you run it, say out loud what it will print. Then open the answer.

What it prints
five lines: tick, update, consume, draw, schedule
Each frame the loop measures time, advances logic, clears the jump-key edge so a held jump fires once, paints, then reschedules itself. The loop is the heartbeat, physics, spawning, and scoring all run inside update and draw.
5

The frame budget is the time allowed per frame. At 60 fps that is ~16.67 ms; at 30 fps it doubles. If update takes longer than the budget, frames drop and the game stutters. I compute the budget for both rates and show the cap's purpose: one slow frame must never deliver a 0.5 s dt.

function budget(fps) { return Math.round((1000 / fps) * 100) / 100 } console.log('budget @60fps:', budget(60), 'ms') console.log('budget @30fps:', budget(30), 'ms') console.log('cap 1/30 =', Math.round(1/30 * 1000) / 1000, 's, blocks a 0.5 s teleport')

Before you run it, say out loud what it will print. Then open the answer.

What it prints
budget @60fps: 16.67 ms budget @30fps: 33 ms cap 1/30 = 0.033 s, blocks a 0.5 s teleport
At 60 fps each frame gets ~16.67 ms; at 30 fps it doubles to ~33 ms. The dt cap of 1/30 s is a fairness rule: even a catastrophic slow frame cannot accelerate the player by more than one capped step.

Put it in order

Before you write anything, put these lines back in the right order. Click a line to move it up one step. This support step is easier than writing code from scratch, and it is here to make the last example stick.

Data Flow

Each frame the timer measures elapsed seconds, the state machine updates using that time, then the state draws, and the browser schedules the next frame.
1 timer.tick() measures seconds since the last frame
↓
2 sm.update(dt, input) advances every system by dt
↓
3 input.consume() forgets this frame's pressed keys
↓
4 sm.draw(ctx) paints the current state
↓
5 requestAnimationFrame schedules the next call to loop
Why this works
The frame budget

At 60 fps you have about 16 ms (1/60 s) per frame for all your update logic. At 30 fps that doubles to ~33 ms. If your update takes longer than the budget, frames drop and the game stutters.

That's exactly why the Timer caps dt at 1/30. A single slow frame (say, because the browser paused) can't deliver a dt of 0.5 s and then accelerate the player halfway across the screen in one update. The cap turns a catastrophic teleport into a small, bounded jump.

dt = Math.min(dt, 1 / 30);

This one line is what makes a game playable after a tab switch or a moment of lag. It's a fairness rule for the player, just like the difficulty caps we met earlier.

Where the loop lives in the game

The whole game, menu, play, pause, game over, runs inside this one loop. sm.update and sm.draw forward to the current state (the State Machine lesson). So every frame:

get time → update → draw → schedule next frame → repeat

input.consume() clears the pressed-key edges after each frame so a held jump key fires only once (we cover input in the next lesson). The loop never stops on its own, requestAnimationFrame keeps scheduling it, so the game runs until the tab closes.

flowchart TD
  A[timer.tick()] --> B[sm.update(dt, input)]
  B --> C[input.consume()]
  C --> D[sm.draw(ctx)]
  D --> E[requestAnimationFrame(loop)]
  E --> A

The loop is the heartbeat. Everything else, physics, spawning, scoring, juice, is just the code that runs inside update and draw.

Examples

The self-repeating cycle

A game isn't one picture, it's thousands of pictures, each reacting to input. That stream is the game loop:

function loop() {
  update(dt);   // 1. compute where everything IS now
  draw(ctx);    // 2. paint it
  requestAnimationFrame(loop);   // ask the browser to call loop again soon
}
loop();                        // start it

The last line, requestAnimationFrame(loop), tells the browser: "call my loop again as soon as you're ready to show a new frame." So loop calls itself forever. Each cycle does two jobs:

  • **update(dt): move the player, spawn obstacles, check collisions, count score. The logic.*
  • **draw(ctx): paint the current state. What you see.*

The order matters: update first, then draw. If you drew the old positions after updating, everything would lag one frame behind.

Why dt (delta time)?

Look at the real game's movement from app1/js/entities/Player.js:

this.x += this.vx * dt;
this.y += this.vy * dt;

dt is **delta time: how many seconds* passed since the last frame. Here's the problem it solves. A player moving at vx = 340 px/s:

  • Without dt: each frame moves 340 px. On a 60 fps machine that's 20,400 px/s; on a 30 fps machine 10,200 px/s, half as fast. The game runs at different speeds on different machines.
  • With dt: the player moves 340 dt px per frame. dt is the fraction of a second each frame took, so the player moves exactly* 340 px/s no matter the frame rate.

dt makes speed a rate (px/s), not a per-frame amount. Same game, any machine. That's why every movement and every physics line multiplies by dt.

The real Timer class

The game measures dt with a Timer class from app1/js/core/Timer.js:

export class Timer {
  constructor(capped = 1 / 30) {
    this.capped = capped;
    this.last = performance.now();
    this.elapsed = 0;
  }

  tick() {
    const now = performance.now();
    let dt = (now - this.last) / 1000;   // seconds since last frame
    this.last = now;
    if (dt < 0) dt = 0;
    dt = Math.min(dt, this.capped);      // cap so tab-switch jumps don't teleport
    this.elapsed += dt;
    return dt;
  }
}

  1. performance.now() returns milliseconds since the page loaded.
  2. (now - this.last) / 1000 converts to seconds.
  3. Math.min(dt, this.capped) is a guard: switching away from the tab and back can pass a huge amount of time. Without the cap, the player would teleport across the screen in one frame. Capping at 1/30 keeps jumps small.

So in the loop, const dt = timer.tick(); hands you the per-frame time. The real loop from app1/js/main.js:

function loop() {
  const dt = timer.tick();
  sm.update(dt, input);   // update the current state
  input.consume();        // clear pressed-key edge signals
  sm.draw(ctx);           // draw the current state
  requestAnimationFrame(loop);
}
loop();

That's a game engine in five lines.

Words this lesson uses

Each one is defined here on this site. Open the entry for the full definition and the other words it points at.

← Drawing with Canvas
Previous Lesson
Drawing with Canvas
Next Lesson
Classes and OOP
Classes and OOP →