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 oninput
,results
and currentgameState
. - 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 fromdraw
- Performance relating to drawing.