Spiral of death in a game loop
In the previous tutorial we showed how we can use the concept of game lag
to catchup on missed frames allowing games running on different quality hardware to exhibit the same game play dynamics.
One question you might be wondering is how much lag is too much lag? If a game is only lagging a bit and the game loop can catch up within a reasonable amount of time then the impact to the player is negligible. What should happen though when a game lags often or the lag increases indefinitely.
To demonstrate this scenario we are going to introduce an artificial delay
into our updateGameState
method and observe what happens.
Update the updateGameState
method in GameRuntime
to include the following artificial delay at the top of the method.
let now = Date.now(),
end = now + 20;
while (now < end) { now = Date.now(); }
console.log(GameRuntime.gameState.engine1.lag)
This will delay the updateGameState
method by 20
milliseconds. Given the updateGameState will be called approximately every 1000/60 = 16.6 ms
this delay will cause a problem where the game will constantly by lagging.
We are logging the lag
to the console which should demonstrate this problem. Below is my console output which gives a good idea of what is happening. There is some initial lag, it decreases as we catchup
and then finally when we output the lag to the console we are further behind then the last cycle and the cycle repeats. Here we can see the game is not catching and the lag will increase to infinity. This is called the spiral of death, this will lead to the game freezing and potentially even the browser (happened to me many times in the making of this tutorial). In fact the only way I could get the browser to register code changes after it froze was to open a new browser window. We can also see some warnings in the console telling us that setInterval
handler is taking too long to be called again indicating that our game code and artificial loop have taken over the game loop cycle. If we let the application continue running we can see that the square slows down until it stops updating.
Now if we alter the delay from 20 ms
to 4 ms
we allow the game to catchup, you can test this out just make sure to use a new browser window if your browser tab is frozen.
You can comment out the artificial delay and let's fix this issue!
Handling the spiral of death game loop
Once the spiral of death occurs it can lock up the ability to perform other computations as shown when the browser slows down and becomes unresponsive. We must make a decision about when to interject and alter the game in some way to avoid the dreaded spiral of death
.
The good news is there are a few parameters we can track to decide when the game is moving into spiral of death
territory, ie the current lag
and whether the lag
is increasing or decreasing and for how long.
There are a few decisions to make once a potential spiral of death
has been detected. We can stop the game completely, in a multiplayer online game this might look like disconnecting / booting the player from the server. Or we can alter how the game state is generated, perhaps we have some resource intensive animation logic that we can skip over for a few frames to allow the game to catch up. The point here is that we have some control over what to do so long as we can detect the potential spiral of death
.
Implementing spiral of death detection
To detect the spiral of death we will create the following new engine state parameters.
- lagSpiral: boolean // Indicating if a potential spiral of death is about to occur
- increasingLagCount: number // Number of consecutive loops that lag has been increasing
Update Gamestate.engine1
to include the following two new parameters as below.
increasingLagCount: number,
lagSpiral: boolean
In GameConstants
update InitialGameState
's engine1
to set default values for the new fields.
increasingLagCount: 0,
lagSpiral: false
Now let's set these new parameters and check for a spiral of death
. In GameLoopEngine
update the loop
method as below.
const maxLag = 2000
GameRuntime.gameState.engine1.increasingLagCount = newLag > GameRuntime.gameState.engine1.lag ? GameRuntime.gameState.engine1.increasingLagCount + 1 : 0;
GameRuntime.gameState.engine1.lagSpiral = newLag > 200 ? true : GameRuntime.gameState.engine1.lagSpiral;
if (GameRuntime.gameState.engine1.lagSpiral) {
console.log('Potential spiral of death detected')
return GameRuntime.gameState
} else {
const newGameState = GameLoopEngine.updateFrame(GameRuntime.gameState)
return newGameState
}
First we set the increasingLagCount by checking if the new lag is greater than the previous lag and if so we increment otherwise we reset to 0. Then we detect if a lagSpiral is occurring by seeing if the current lag is over 200 ms
.
Here we have moved the updateFrame
method into the else
branch which occurs if the game has not detected a spiral of death
otherwise we just return the game state as is and log the Potential spiral of death detected
.
Run the app and check the result. Looks like everything is working right? Try changing the browser tab, pausing and switching back, oh no what happened to the square, it's stopped. Open the browser console and confirm that the spiral of death
log is there. As shown in a previous tutorial when we switched tabs our game became inactive and stopped updating, when we switched back the game was too far behind, lag was greater than 200 ms
and the spiral of death
was activated.
It would be nice to give the game a chance to catchup and only trigger the spiral of death
if the lag is over some threshold and has been increasing for some amount of loops.
That is what our parameter increasingLagCount
will be used for.
Update the lagSpiral
detection code line as below.
GameRuntime.gameState.engine1.lagSpiral = newLag > maxLag || (newLag > 200 && GameRuntime.gameState.engine1.increasingLagCount > 60) ? true : GameRuntime.gameState.engine1.lagSpiral;
Now we will trigger a spiral of death
if the game lag is greater than some max lag 2000 ms
. This change will also take into account not just whether the current lag is greater than 200 ms
but whether the lag is increasing and for how long.
Run the app again and switch tabs, pause then switch back. Notice how this time the game keeps running, check the console for the lag logs. This is because although the lag has exceeded 200 ms
when we switch the tab back the lag decreases until we catch up so the increasingLagCount
threshold of 60
is never reached and hence the spiral of death
never occurs.
Now this is just a demonstration, it wouldn't be ideal if the game could stop for 2 seconds and then when a player resumed everything sped up until the game reduced the lag to a reasonable value. This can be solved by handling game pauses
correctly. Indeed the browser tab switch is something specific to browser based games, most games would just continue to function if we walk
away from them physically.
We are only logging when a spiral of death
occurs and then preventing the game from updating further but we could easily trigger a restart
of the game or pausing of the game and tweaking certain game state to avoid the spiral of death
on resume, there are many ways we can handle this.
Lets finish by reintroducing our artificial delay by uncommenting it in GameRuntime
's updateGameState
, run the app and confirm that the browser doesn't hang anymore but rather the spiral of death
is triggered when the lag is over 2000 ms
.
Congratulations on getting to the end of implementing an advanced game loop
. The game engine and game loop can be a hard concept to grasp, I spent a lot of time playing with game loops for this course and it took a while for things to fall into place so I hope you have enjoyed the course and have a better understanding of how the heartbeat of a game functions.