Handmade Web Games

Chapter 3: Game loops

Let's make it move.

Unlike a program that runs once to completion, a game has to keep happening. Every frame, it updates the state of the world and draws it to the canvas. This game loop continues as long as the game runs.

while (gameIsRunning) {
  update();
  draw();
}

But in JavaScript, a long-running while loop like that will just crash your tab because it never yields control back to the event loop [YouTube]. The game loops we'll build up to in this chapter do two things the while loop can't (in addition to not crashing):

  1. They sync to monitor refresh rates for smooth animation.
  2. They handle updates at precise fixed intervals so that physics calculations are consistent across devices.

Basic loops

Starter code

Since we're continuing from Chapter 2, assume all code in this chapter follows from this setup.

setInterval

For the simplest possible game loop in JavaScript, setInterval(fn, ms) runs a function every ms milliseconds. It is simple, but a poor choice for games.

  1. The interval is not exact. JavaScript can't guarantee your function fires every 16ms precisely, which makes it unsuitable for physics simulations that require a stable time step.
  2. It can't sync to the monitor's refresh rate. On a 120hz display, a 60hz interval only paints half the frames the screen is capable of. Timing mismatches between the display's refresh rate and how often the game renders will result in dropped frames. And dropped frames mean choppy animation.
  3. setInterval is bad for battery life. The interval keeps running even when the tab is hidden, draining battery for no reason.

For demonstration purposes only!

setInterval gives you the worst of both worlds--it's not precise enough for stable updates, but also can't sync with the monitor. Don't use it as your game loop!

requestAnimationFrame

requestAnimationFrame(fn) calls its callback fn just before the next browser paint, in sync with the monitor's refresh rate. To make a loop, the callback calls requestAnimationFrame itself to schedule its own next tick.

If the work within the callback takes longer than one frame to execute, you will drop frames.

A dropped frame is still better than the "spiral of death" that crashes your tab when code in a setInterval takes longer than the interval to run.

Variable frame rates

Because requestAnimationFrame is synchronized to the device's refresh rate, a square that moves one pixel per frame--as we saw above--will move faster on a 120hz display than on a 30hz one. So on its own, requestAnimationFrame does not work well as a game loop because it ties game speed to the player's hardware. But it is the right foundation to build onto.

src/main.ts
const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d")!;
const bounds = canvas.getBoundingClientRect();

deltaTime

deltaTime

To fix the varied speeds between devices with different refresh rates, the trick is to think in terms of pixels per second rather than pixels per frame. To do that, you need a way to normalize time that you can incorporate into any state that changes over time. The answer--deltaTime, or dt for short--is simply the amount of time that elapsed since the last frame.

deltaTime cheatsheet

Common uses for deltaTime in games include:

  • Multiplying velocity by deltaTime in changes to positions or angles:

    player.x += player.velocity * dt;
    bullet.x += bulletSpeed * dt;
    angle += angularSpeed * dt;
  • Applying acceleration to velocity:

    player.velocity += gravity * dt;
  • Adding or subtracting deltaTime each tick for timers:

    elapsedSeconds += dt;
    reloadTimer = Math.max(0, reloadTimer - dt);
    if (reloadTimer === 0) {
      canShoot = true;
    }
  • Damping / friction:

    velocity *= Math.exp(-friction * dt);
  • Stepping through frames:

    More on this in Chapter 4 with Sprite Animation

    frameTime += dt;
    if (frameTime >= frameDuration) {
      frame = (frame + 1) % totalFrames;
      frameTime -= frameDuration;
    }
  • Time-based random events:

    Checking Math.random() against a fixed threshold every frame ties the event rate to the frame rate.

    Don't do this!

    This fires more often on high refresh rate displays:

    update.ts
    // called within `update()`
    if (Math.random() < 0.5 * dt) {
      doRandomEvent(); // 50% chance per second -- or is it?
    }

    Better:

    Use an accumulator that checks once per second regardless of frame rate:

    state.ts
    const state = {
      eventAccumulator: 0,
      eventRate: 1, // seconds between checks
    };
    update.ts
    // called within `update()`
    state.eventAccumulator += dt;
    if (state.eventAccumulator >= state.eventRate) {
      state.eventAccumulator -= state.eventRate;
      if (Math.random() < 0.5) {
        // true 50% chance, once per second
        doRandomEvent();
      }
    }

    (Spoiler--this same accumulator pattern is how we'll add fixed timestep updates at the end of the chapter.)

But not everything needs dt.

  • Instantaneous changes do not incorporate deltaTime.

    function jump(player) {
      player.velocityY = 100; // no dt
    }
    // collisions
    if (hitWall) {
      ball.velocityX *= -1; // no dt
    }
    
    if (player.y > groundY) {
      player.y = groundY;
      player.velocityY = 0; // no dt
    }

Game loop with deltaTime

Because requestAnimationFrame(fn) automatically passes a high-resolution timestamp to its callback function, we can compute deltaTime by comparing the current frame's timestamp with the previous lastFrame timestamp.

That makes dt = (now - lastFrame) / 1000.

Why / 1000?

It's usually easier to think in terms of seconds rather than milliseconds--but that's optional. Standard physics constants and equations tend to be expressed in terms of seconds, not ms, so you can do things like this:

const gravity = 980; // px/s²  (not 0.98 px/ms²)
const speed = 200; // px/s (not 0.2 px/ms)
player.velocity += gravity * dt; // dt in seconds

Clamp your maximum delta time

requestAnimationFrame pauses execution when the tab is hidden, which means that returning to a tab after it has been inactive for some time can cause a massive spike in dt. This can crash your game as it tries to catch up with potentially millions of state updates.

To prevent that in your code, pick a reasonable maximum dt and clamp any dt values that exceed it.

Thus our dt calculation becomes:
dt = min((now - lastFrame) / 1000, maxDt)

const speed = 60; // pixels per second
let x = 0;
let lastFrame = 0;
function loop(now: number) {
// special case for the very first frame
if (!lastFrame) lastFrame = now;
// clamp dt to 0.1s since it becomes
// huge when switching tabs
const elapsed = now - lastFrame;
const dt = Math.min(elapsed / 1000, 0.1);
lastFrame = now;
// incorporate dt when updating position
x += speed * dt;
if (x > bounds.width) x = 0;
const { width, height } = bounds;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "deepskyblue";
ctx.fillRect(x, 80, 20, 20);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

When a variable deltaTime is not enough

There's still a problem lurking in our game loop. With variable dt, the same physics code produces slightly different results depending on the frame rate. On a fast device, there are many small steps for each calculation. On a slow one, there are fewer larger steps. Those differences can cause small problems, like the game feeling slightly "off" on some devices. Or in games with fast-moving objects they can completely break collision detection. The fix is to prevent dt from varying at all.

Separate simulation from render

Before introducing the fixed timestep, we need to separate two concerns that have been mixed together: updating state and drawing it. This separation is what makes the fixed timestep possible. In the next step, the physics callback will become fixedUpdate() and run on a fixed interval, while draw() continues to run at the display's refresh rate.

Something great about this game loop right now is how simple it is. For many types of games--those that don't need fixed time steps for physics--this is all you need! Examples of games that would not typically benefit from the more complex timestep introduced below include:

  • Chess
  • Roguelikes with grid-step movement
  • Card games
  • Sokoban-style puzzles
  • Incremental games
  • Visual novels

Put another way, if the state in your game is discrete rather than a continuous simulation of time, the game loop on the right is a great choice.

Fixed timestep updates

To achieve the precise updates needed in physics-based simulations and games, we'll introduce a fixed timestep. The idea is to decouple the draw cycle from the physics update cycle. This means you can process physics updates on an exact interval--far more precise than the approach with setInterval--while also only painting to the screen in time with its current refresh rate.

How? To reference the classic article, Fix Your Timestep! by Glenn Fiedler:

Instead of thinking that you have a certain amount of frame time you must simulate before rendering, flip your viewpoint upside down and think of it like this: the renderer produces time and the simulation consumes it in discrete dt sized steps.

Every frame, we accumulate real elapsed time in a variable. The physics fixedUpdate loop then drains it in fixed dt-sized amounts. But now, dt is guaranteed to be the same every single time and on every device. Your renderer still runs at whatever rate the display supports.

Taking the draw loop one step further with interpolation

You'll usually have a tiny bit of accumulated time leftover before you draw. This means your draw might occasionally be slightly behind where it should be--especially at lower frame rates.

To solve this, Fix Your Timestep suggests interpolating your draws between the current and next physics states based on how much time is left in the accumulator. The idea is to figure out what percent of the next physics step you are currently into, and incorporate that into your draw code.

The downside is that rather than saving just one state value for each physics property, you now need to save two--the current and previous values. Any logic that previously could simply read state.x will now need to calculate interpolate(state.prevX, state.x, alpha), which is a tradeoff in complexity you will need to weigh for your specific game.

const state = { x: 0, speed: 60 };
function update(dt: number) {
state.x += state.speed * dt;
if (state.x > bounds.width) {
state.x = 0;
}
}
function draw(ctx: CanvasRenderingContext2D) {
const { width, height } = bounds;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "deepskyblue";
ctx.fillRect(state.x, 80, 20, 20);
}
let lastFrame = 0;
function loop(now: number) {
if (!lastFrame) lastFrame = now;
const elapsed = now - lastFrame;
const dt = Math.min(elapsed / 1000, 0.1);
lastFrame = now;
update(dt);
draw(ctx);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Complete fixed timestep example

The complete example below combines everything from this chapter with the DPI scaling boilerplate from Chapter 2. It also includes a ResizeObserver that re-runs the DPI scaling whenever the canvas changes size. The index.html is the same full-window canvas setup from Chapter 1. In addition to the fixed timestep fixedUpdate, we'll also include a variable-rate update function that's useful for visual effects like particles or sprite animation that normally don't require stable physics and can run with a variable dt.

index.html
main.ts
const  = .("canvas");
..();
const  = .("2d")!;
const  = .();

const  = {
  : 0,
  : 60,
  : {
    : .,
    : .,
  },
};

// update with a stable `dt`
function (: number) {
  const { ,  } = ;
  . +=  * ;
  if (. > .) {
    . = 0;
  }
}

// update with a variable `dt`
function (: number) {}

function (: CanvasRenderingContext2D) {
  const { ,  } = .;
  .(0, 0, , );
  . = "deepskyblue";
  .(., 80, 20, 20);
}

const  = 8 / 1000;
let  = 0;
let  = 0;

function (: number) {
  if (!)  = ;
  const  =  - ;
  const  = .( / 1000, 0.1);
   += ;
   = ;
  while ( >= ) {
    ();
     -= ;
  }
  ();
  ();
  ();
}

();
();

new ().();

function () {
  const { ,  } = .();
  const  = .;

  // set how many pixels exist within the canvas
  . =  * ;
  . =  * ;

  // update logical bounds, in css pixels
  .. = ;
  .. = ;

  // ensure consistent scaling
  .(, 0, 0, , 0, 0);

  // set css layout size
  .. = `${}px`;
  .. = `${}px`;
}

Up next

With a game loop in place and the drawing techniques you learned in Chapter 2, we now have all the tools needed to start animating. Sprites, easing curves, squash/stretch techniques, animation state machines, and ways to blend between states follow in Chapter 4.

See also

On this page