Idler Game Loop
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!