Last time, I introduced the concepts of my Halloween idler game. Now, let's explore the tech behind the game. In this post, I'll cover the core 'game loop' algorithm for controlling time, handling input, drawing things to the screen and updating the game logic.

What is a Game Loop?

Fundamentally, video games follow a simple algorithm known as the game loop:

  • receive input
  • calculate the new game state
  • draw to screen

I have expressed this as a JavaScript class below. Note that the update and draw functions are constructor arguments, which decouples the implementations of those functions from the logic of the game loop:

export default class GameLoop {
  constructor(update, render) {
    this.updateFn = update;
    this.renderFn = render;
    return this;
  }

  loop() {
    this.updateFn();
    this.renderFn();
    setTimeout(() => {
      this.loop();
    }, 0);
  }

  start() {
    this.loop();
  }
}

As good as this looks, there's a problem. Each execution of the game loop will take a different amount of time. It will vary per CPU and by the CPU load. Any time-based calculations a game needs to make should account for this, such as how far a character should move or how long is left until this item scores a point.

Let's run through a couple of options.

Option 1: Update Once per Loop (however long it is)

I can track how much time has elapsed since the last frame and pass this difference (the 'delta') to the update function as a parameter. This way, the game logic can use this delta to adjust the game state. Both the update and render functions are called once per loop.

Date.now supplies a timestamp; but there's a better method. The requestAnimationFrame API function instructs the browser to invoke a callback before the next repaint with a high-resolution timestamp, replacing the old-school setTimeout-based scheduling.

export default class GameLoop {
  constructor(update, render, panic) {
    this.updateFn = update;
    this.renderFn = render;
    this.lastTime = null;
  }

  loop(currentTime) {
    const delta = currentTime - this.lastTime;
    this.lastTime = currentTime;
  
    this.updateFn(delta);
  
    this.renderFn();
    requestAnimationFrame((t) => { this.loop(t); });
  }

  start() {
    requestAnimationFrame((t) => {
      this.lastTime = t;
      this.loop(t);
    });
  }
}

Game logic that can accommodate delta time is necessary to handle slowdowns. However, alone, it is not enough. Both update and render functions are invoked once per loop. When rendering takes a long time, the observed behaviour becomes increasingly erratic, and the next update will jump forward by a large amount. There's no mechanism for catching up.

Option 2: 'Fix' the Timestep

Firstly, I establish a fixed timestep interval, which specifies the frequency at which the GameLoop invokes the update function. The interval for 60 updates per second is roughly 17ms (1000ms / 60).

When the delta exceeds the timestep, the GameLoop calls update and subtracts that timestep from the delta. It will repeat this if necessary until the delta is less than a timestep, at which point it is time to render.

Updates now occur periodically, only when necessary. Multiple invocations of update provide catch-up behaviour. Rendering occurs as fast as possible.

This behaviour is good enough for my game. It is possible to go much further with this - there's still a possibility of a 'spiral of death' when updates take too long, and it's never possible to catch up. Should my game exhibit this behaviour later in development, I will return to this solution.

export default class GameLoop {  
  static timestep = 1000 / 60; // ~17ms

  constructor(update, render) {
    this.updateFn = update;
    this.renderFn = render;
    this.lastTime = null;
    this.lag = 0;
    return this;
  }

  loop(currentTime) {
    const delta = currentTime - this.lastTime;
    this.lag += delta;
    this.lastTime = currentTime;

    // Invoke update function once for every ~17ms elapsed.
    while (this.lag >= GameLoop.timestep) {
      this.lag -= GameLoop.timestep;
      this.updateFn(GameLoop.timestep);
    }
  
    this.renderFn();
    requestAnimationFrame((t) => { this.loop(t); });
  }

  start() {
    requestAnimationFrame((t) => {
      this.lastTime = t;
      this.loop(t);
    });
  }
}

Hooking It All Up

Those update and render functions supplied to the GameLoop class must come from somewhere. I've also created a GameArea class to implement those functions:

class GameArea {
  constructor() {
    this.item = document.getElementById('game-area');
    this.components = [];
    // Also: event handling
  }
  update(delta) {
    for (const component of this.components) {
      component.update(delta);
    }
  }
  render() {
    for (const component of this.components) {
      component.render();
    }
  }
}

const gameArea = new GameArea();

new GameLoop(
  (d) => gameArea.update(d),
  () => gameArea.render()
).start();

GameArea serves as the top-level orchestrator for items in the game. It references a DOM element (it could be a <canvas>, but I'm starting simply and just using a <div>) alongside an array of game components. When update or render are called by the GameLoop class, each individual component will be updated or drawn, respectively.

Next time, let's draw some stuff onto the screen!