Designing an advanced game loop
In this course we will look at different techniques to implement a more advanced game loop that can help to deal with some of the common pitfalls when trying to update games via a game loop.
Up until now we have implemented a very basic game loop that has some inherent flaws that may lead to erratic gameplay and definitely make it unsuitable for multiplayer online games. Our current game loop is using Javascript's setInterval
method which is known to have issues for small intervals, drift out of sync over time and pause when the browser tab is not in focus, so not ideal relying solely on it for something as crucial as a game clock! It also has issues overtime running out of sync and can perform differently on different browsers. The problem is that our current game loop is coupled to setInterval
. Each time setInterval
calls our loop method relates to another frame occurring in our game. However as mentioned before what if the interval is slightly off? It would be great if we could decouple setInterval
from when we update our game based on some decision we
make.
In this tutorial I will simulate two different players experiences of a game when there setInterval
are operating at different speeds to show first hand the inconsistent undesirable behaviour this causes. This should hopefully whet your appetite for a more advanced game loop which we will cover in the following tutorials in the course.
Setting up a basic game loop at two different frame rates
In this example we are going to have two oscillating squares representing the two players. The squares will move back and forth across the screen at a constant speed each time the frame is updated.
To keep track of where the squares are we will introduce some new state fields to our GameState
type.
For this course we will use the typescript game dev input starter code
which is linked below.
Add the following new top level squares
field to types/types.ts
inside the GameState
type.
squares: {
square1: {
pos: Vector2DType,
velocity: Vector2DType
},
square2: {
pos: Vector2DType,
velocity: Vector2DType
}
}
Also update the GameConstants.ts
file's InitialGameState
type to include default values for the two squares.
squares: {
square1: {
pos: {
x: 0, y: 200
},
velocity: {
x: 1,
y: 0
}
},
square2: {
pos: {
x: 0, y: 300
},
velocity: {
x: 1,
y: 0
}
}
}
Next lets create a new method updateGameState
in GameRuntime
which will update the gamestate and return the new gamestate.
static updateGameState = (gameState: GameState): GameState => {
const squareSize = 30
const canvasXBound = GameRuntime.canvas.width - squareSize
const square1Velocity = (gameState.squares.square1.pos.x > canvasXBound || gameState.squares.square1.pos.x < 0) ? -1 * gameState.squares.square1.velocity.x : gameState.squares.square1.velocity.x
const square1X = gameState.squares.square1.pos.x + square1Velocity
gameState.squares.square1.pos.x = square1X
gameState.squares.square1.velocity.x = square1Velocity
return gameState
}
Create a new file called GameLoopEngine.ts
and add a static updateFrame method with the code below.
import GameRuntime from "./GameRuntime.js"
import { GameState } from "./types/types.js"
export class GameLoopEngine {
static updateFrame = (): GameState => {
const newGameState = GameRuntime.updateGameState(GameRuntime.gameState)
return newGameState
}
}
Next add a loop method to the GameLoopEngine
class with the following code.
static loop = (): GameState => {
const newGameState = GameLoopEngine.updateFrame()
return newGameState
}
Inside the loop
method in GameRuntime
remove all of the body and replace it with the code below.
static loop = () => {
GameRuntime.gameState = GameLoopEngine.loop()
GameRuntime.draw(GameRuntime.context, GameRuntime.canvas.width, GameRuntime.canvas.height, GameRuntime.gameState)
}
Okay so we've added a few methods and you might think some of them seem kind of redundant. Rest assured the extraction of these different methods will serve a purpose as our game loop becomes more complex. The responsibilities of each method are described below.
GameRuntime
- loop: This is the loop method that our
setInterval
time calls it will retrive the new gameState and draw the new state. - updateGameState: This is responsible for updating our
game
's specific state that is unique to our game. This is not generic code and so needs to be separated from the generic game loop library code.
GameLoopEngine
- loop: This is a the loop method relating to our game
engine
loop framework. It will take care of all the important game loop concepts that our regular game code should not need to be aware of. The goal of this extraction will be to separate the generic game framework code into a library that we can use for whatever game we create as we have done in previous tutorials with our input code. - updateFrame: This code will be responsible for deciding whether to update the gamestate and incrementing the games current frame.
Updating the game setup
Next we are going to move some setup code from index.ts
into our GameRuntime
.
Add the following two static variables to GameRuntime
for the canvas and context.
static canvas: HTMLCanvasElement
static context: CanvasRenderingContext2D
Update GameRuntime
's setup method to look like below.
static setup = (canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) => {
new KeyboardManager()
GameRuntime.canvas = canvas
GameRuntime.context = context
const game1UpdateLoopsPerSecond = 1000 / 60
setInterval(GameRuntime.loop, game1UpdateLoopsPerSecond)
}
Inside index.ts
change the window.onload
method to look as below.
window.onload = () => {
if (context) {
GameRuntime.setup(canvas, context)
}
}
This just moves the responsiblity for setting up our game from the index
file into the GameRuntime
which I prefer.
Drawing the square
Update the Gameruntime
's draw
method with the following code to draw the square.
private static draw = (context: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number, gameState: GameState) => {
Draw.resetCanvas(context, canvasWidth, canvasHeight)
const square1 = gameState.squares.square1
context.fillRect(square1.pos.x, square1.pos.y, 30, 30)
}
Run the game npm run serve
and head to localhost:8080
and you should see the square moving forward and back accross the screen.
Updating the frame rate
The setInterval
interval currently simulates our game's frame rate. Try updating the interval to be shorter or larger as below and rerun the game.
const frameRate = 120 // Set to what you want the frame rate to be.
const game1UpdateLoopsPerSecond = 1000 / frameRate
setInterval(GameRuntime.loop1, game1UpdateLoopsPerSecond)
What do you notice, if we decrease the interval the square moves faster and slower if we increase the interval. This makes sense as currently we are moving the square by a consistent amount per frame. The faster the frames tick over the faster the square will move.
Adding a second square
Lets set the first square's frame rate back to 60
and now add a second square that will help us simulate this difference in speed.
We're going to add a simple loop2
method in GameRuntime
, add the code below.
static loop2 = () => {
const square2Velocity = (GameRuntime.gameState.squares.square2.pos.x > GameRuntime.canvasXBound || GameRuntime.gameState.squares.square2.pos.x < 0) ? -1 * GameRuntime.gameState.squares.square2.velocity.x : GameRuntime.gameState.squares.square2.velocity.x
const square2X = GameRuntime.gameState.squares.square2.pos.x + square2Velocity
GameRuntime.gameState.squares.square2.pos.x = square2X
GameRuntime.gameState.squares.square2.velocity.x = square2Velocity
}
Note we won't add all the extracted methods we did with the first loop as this code is temporary and purely for demonstration purposes.
In the setup
method in GameRuntime
add the following code.
const game2UpdateLoopsPerSecond = 1000 / 30
setInterval(GameRuntime.loop2, game2UpdateLoopsPerSecond)
Next update the Draw
classes draw
method to draw the second square as below.
const square2 = GameRuntime.gameState.squares.square2
context.fillRect(square2.pos.x, square2.pos.y, 30, 30)
Run the app again and notice how the first square is moving twice as fast as the second square. Wouldn't it be great if despite how quickly the setInterval method is calling the loop
method and whether this is different between the two squares the squares still moved at the same speed. After all it's a bit of an advantage in a racing game if the other player is able to move twice as fast as you just because they have more performant hardware!
Tune into the next tutorial to see if square 2 can get its comeuppance as the next tutorials in this course will cover exactly this and other factors that should be considered when designing and advanced game loop.