A basic Javascript / Typescript game loop

- 5 mins
Hello1
ViewSource code

Intro

The goal of this post is to explain and implement a basic game loop to use as the backbone in future game tutorials.

The game loop we'll be implementing is basic in the sense that it has a few problems with it. We'll explore what a more mature game loop looks like in a future post but for now it will be sufficient for our purposes.

What is a game loop

You can think of the game loop as the heartbeat of a game. It's what transforms a static passive canvas to an interactable game.

At their core, games are nothing more than a simulation that considers the current inputs and state of the game to determine the new state of the game and reflect this new state as an output on the screen.

As the name implies, the game loop is a method that will continue to be called for the duration of the game (or simulation).

The game loop has the responsibility of

  • Checking a stream of inputs
  • Deciding what services should be called and when
  • Determining new gameState based on input, results and current gameState.
  • Drawing new game state to the screen (for now)

Up until now we relied on event listeners and handlers to poll input. The game loop will allow us to control when we choose to update the state of the world instead of updating immediately whenever any input is received.

game loop setup

Enough theory, it's time to implement our own game loop. This will unlock a whole new world of possibilities in your game dev journey.

This tutorial will use the typescript-html5-canvas-starter project as a base. The typescript-html5-canvas-starter is a simple typescript canvas game skeleton project that comprises of code from the previous tutorials, it sets up a typescript html5 canvas project with cold reloading. Tap the typescript-html5-canvas-starter link to download the base zip file. Unzip the content of the zip file and navigate to that directory.

In this example we will be drawing a red square to the canvas and periodically updating it's position with random x and y coordinates.

We are going to create a GameRuntime class that will be responsible for executing our game loop.

To begin lets create a new GameRuntime.ts file in the src directory.

Add the following lines to GameRuntime.ts.

class GameRuntime {

  static loop = () => {
    console.log('loop fired')
  }
}

export default GameRuntime

Here we are defining a new class GameRuntime with a static loop method.

When the loop method is fired we will log loop fired to the console.

The last line is exporting our GameRuntime class which will allow us to import it to be used in other files.

Importing GameRuntime

Update index.ts to be the same as below

import GameRuntime from './GameRuntime.js'

const canvas: HTMLCanvasElement  = document.getElementById("gameCanvas") as HTMLCanvasElement;
const context = canvas.getContext("2d")
canvas.width = 800
canvas.height = 3 * canvas.width / 4

window.onload = function() {
  if (context) {
    setInterval(GameRuntime.loop, 1000)
  }
}

Most of the lines already exist from the template we are using, the new lines are explained below.

The first line is importing the GameRuntime class from the ./GameRuntime.js path. We use .js instead of .ts because once compiled all our files will be .js not .ts as that's what the browser understands.

The setInterval method is a predefined javascript method that takes a function and a timeout as its arguments. The setInterval method will invoke the given method every timeout amount of milliseconds.

In the above code the setInterval line will run the GameRuntime.loop method every second (1000 milliseconds).

Build and run the application. Open up the developer inspector and check the console. The loop fired message should be being printed to the console every second.

Add draw loop

For the effects of the game loop to be evident we also need to convey them to the player in the form of output to the screen which we will achieve by adding a new draw method in GameRuntime.

The draw method will need a reference to the context and the canvas dimensions.

Update GameRuntime's loop method to take the context, canvasWidth and canvasHeight arguments as below.

static loop = (context: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number) => {
  ...
}

Next, update index.ts's setInterval line as shown below to pass these references into our GameRuntime.loop method.

setInterval(GameRuntime.loop, 1000, context, canvas.width, canvas.height)

The setInterval method takes optional arguments that will be passed into the function it is invoking.

In GameRuntime.ts add the below private static draw method implementation inside the GameRuntime class.

private static draw = (context: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number) => {
  console.log('draw fired')

  context.fillStyle = 'red'
  context.clearRect(0, 0, canvasWidth, canvasHeight)

  context.fillRect(0, 0, 50, 50)
}

Here we are logging a console message that the draw method has been fired. Then we are clearing the canvas and drawing a red square with position (0,0) and size 50.

Add the following line to The GameRuntime.loop method to call the draw method each time the loop is invoked.

GameRuntime.draw(context, canvasWidth, canvasHeight)

Build and run the application. Open up the developer inspector and check the console. The loop fired and draw fired messages should be printed to the console every second.

A red square should be placed in the top left corner of the canvas.

Set desired FPS

At the moment the GameRuntime.loop method is running once a second. To achieve the illusion of smooth animation games run at a number of frames per second fps, typically 60.

Update index.ts's setInterval method as below.

window.onload = function() {
  if (context) {

    const loopTimeout = 1000 / 60
    setInterval(GameRuntime.loop, loopTimeout, context, canvas.width, canvas.height)
  }
}

We have updated the setInterval method to use a timeout equal to a loopTimeout which is set to 1000/60. This will achieve our desired 60fps.

Build and run the application. Open up the developer inspector and check the console. Confirm the loop fired and draw fired messages are being updated more frequently.

Introducing Gamestate

At the moment the square we are drawing is static, it doesn't change.

Add a GameState type to top of GameRuntime.ts.

type GameState = {
  squareSize: number
  color: string
  posX: number
  posY: number
}

Here we have introduced a GameState type that will hold all the information relating to the square.

Next update GameRuntime's draw method to take gameState as an argument and use the gameState properties when drawing the square.

private static draw = (context: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number, gameState: GameState) => {
  console.log('draw fired')

  context.fillStyle = gameState.color
  context.clearRect(0, 0, canvasWidth, canvasHeight)
  context.fillRect(gameState.posX, gameState.posY, gameState.squareSize, gameState.squareSize)
}

This achieves a subtle but important goal, which is to separate responsibility of computing the gameState (properties relating to the square) from the draw method. Now the draw method is naively drawing whatever gameState we pass into it.

Add a static gameState constant to GameRuntime to hold our current gameState.

static gameState: GameState = {
  squareSize: 50,
  color: 'red',
  posX: 0,
  posY: 0
}

Update the draw method invocation within the GameRuntime's loop method as below.

GameRuntime.draw(context, canvasWidth, canvasHeight, GameRuntime.gameState)

This will pass in the GameRuntime.gameState as an argument to the draw method.

Build and run the application. You should still be able to see the red square in the top left of the canvas. In fact there should be no visible change to functionality.

Updating the GameState

Add the following code to the top of GameRuntime's loop method.

const randomNumber = Math.floor(Math.random() * 100)

if (randomNumber === 0) {
  GameRuntime.gameState = {
    ...GameRuntime.gameState,
    posX: Math.random() * (canvasWidth - GameRuntime.gameState.squareSize),
    posY: Math.random() * (canvasHeight - GameRuntime.gameState.squareSize)
  }
}

Here we are computing a random number between 0 and 99. We use an if condition to check the randomNumber. Inside the if block we are updating our gameState.

The ... spread operator is used to take the current gameState and use it in computing the new updated gameState. Some of the gameState properties such as colour and squareSize remain constant so we don't need to update these.

The posX and posY properties follow the spread operator which overwrite the current gameState posX and posY positions with the new computed ones.

The new posX and posY values are computed using a random number within the canvas bounds.

Build and run the application. Now you should be able to see that the red square's position gets updated.

Summary

With just this simple game loop and the knowledge from the previous tutorials, there are now countless (albeit basic) possibilities of games you can create.

The game loop implementation we have added is by no means perfect. There are a few modifications we'll look to address in future posts which will help with the following.

  • Fixed time-step game loop
  • Preventing game loop freezing when the application is out of focus.
  • Decoupling the game loop from draw
  • Performance relating to drawing.