Handmade Web Games

Chapter 3

Game loops

requestAnimationFrame, deltaTime, and fixed timesteps

Unlike a program that runs once to completion, a game has to keep happening. For each frame while the game runs, the world state is updated and drawn 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. 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.

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

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!

// state
let x = 0;

function update() {
  x += 1;
  if (x > bounds.width) x = 0;
}

function draw() {
  // start with a clear canvas
  const { width, height } = bounds;
  ctx.clearRect(0, 0, width, height);

  ctx.fillStyle = "#3d7ea6";
  ctx.fillRect(x, 80, 20, 20);
}

// "game loop" updates every 16ms (~60fps)
setInterval(() => {
  update();
  draw();
}, 16);

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. Remember to profile your game from time to time.

// state
let x = 0;

function update() {
  x += 1;
  if (x > bounds.width) x = 0;
}

function draw() {
  // start with a clear canvas
  const { width, height } = bounds;
  ctx.clearRect(0, 0, width, height);

  ctx.fillStyle = "#3d7ea6";
  ctx.fillRect(x, 80, 20, 20);
}

function loop() {
  update();
  draw();
  requestAnimationFrame(loop); // again!
}

loop();

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.

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) % 360;
  • 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:

    frameTime += dt;
    if (frameTime >= frameDuration) {
      frame = (frame + 1) % totalFrames;
      frameTime -= frameDuration;
    }

    More on this in Chapter 4 with Sprite Animation

  • 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 a while can create 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 = "#3d7ea6";
  ctx.fillRect(x, 80, 20, 20);

  requestAnimationFrame(loop);
}

requestAnimationFrame(loop);

When a variable deltaTime is not enough

For code that needs consistent simulations, our game loop still has a problem. With variable dt, the same physics code produces slightly different results depending on the frame rate. A fast device will perform many small steps per calculation while a slow device will perform fewer/larger steps. This can cause problems ranging from the game feeling slightly "off" on some devices, to completely breaking collision detection and causing players to tunnel through walls. The solution is to prevent dt from varying at all using what's called a fixed timestep.

Separate simulation from render

Before introducing the fixed timestep, we need to separate two concerns that have been mixed together in our code: updating state and drawing it. This separation will make a fixed timestep possible.

Before we go any further, I'd like to note that the game loop we have right now is perfectly suitable for a lot of games! If you're not relying on physics simulations, stick with this simple game loop. Types of games that don't usually need fixed timesteps include:

  • Card games
  • Chess and most board games
  • Incremental games
  • Roguelikes with grid-step movement
  • Sokoban-style puzzles
  • 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.

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 = "#3d7ea6";
  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);

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:

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.

Glenn Fiedler

Every frame, we accumulate real elapsed time in a variable (accumulator in the code to the right). The loop of fixedUpdate calls drains it in fixed amounts. These new delta times are guaranteed to be the same every single time and on every device. And 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. With modern devices supporting 120–240fps as a baseline frame rate, interpolation is rarely worth the effort these days.

const state = { x: 0, speed: 60 };

// dt will now always be 0.008s (8ms)!
function fixedUpdate(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 = "#3d7ea6";
  ctx.fillRect(state.x, 80, 20, 20);
}

const fixedDt = 8 / 1000; // 8ms in seconds
let lastFrame = 0;
let accumulator = 0;

function loop(now: number) {
  if (!lastFrame) lastFrame = now;
  const elapsed = now - lastFrame;
  const dt = Math.min(elapsed / 1000, 0.1);
  accumulator += dt;
  lastFrame = now;
  while (accumulator >= fixedDt) {
    fixedUpdate(fixedDt);
    accumulator -= fixedDt;
  }
  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 function, 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, // replace this with your game's state
  : {
    : .,
    : .,
  },
};

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

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

function (: CanvasRenderingContext2D) {
  const { ,  } = .;
  .(0, 0, , );
  . = "#3d7ea6";
  .(., 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`;
}

See also

On this page