The main loop is a core part of any application in which state changes over time. In games, the main loop is often called the game loop, and it is typically responsible for computing physics and AI as well as drawing the result on the screen. Unfortunately, the vast majority of main loops found online - especially those in JavaScript - are written incorrectly due to timing issues. I should know; I've written my fair share of bad ones. This post aims to show you why many main loops need to be fixed, and how to write a main loop correctly.
If you'd rather skip the explanation and just get the code to do it right, you can use my open-source MainLoop.js project.
Table of contents:
- A first attempt
- Timing problems
- Physics problems
- A solution
- Panic! Spiral of death
- Performance considerations
- Starting and stopping
- Node.js/IO.js and IE9 support
- Wrapping up
A first attempt
We're going to write a "game." For simplicity, we're just going to draw an oscillating box:
Let's make it show up:
#box { background-color: red; height: 50px; left: 150px; position: absolute; top: 10px; width: 50px; }
Okay, that wasn't so bad. Now let's scaffold the JavaScript application. First, our box will need some properties to determine its position and velocity. Let's also throw in a function, draw()
, to display the box's new position:
var box = document.getElementById('box'), // the box boxPos = 10, // the box's position boxVelocity = 2, // the box's velocity limit = 300; // how far the box can go before it switches direction function draw() { box.style.left = boxPos + 'px'; }
Now the game's logic. We want the box to move back and forth, so we'll just add the velocity to the position. This is where things start to go wrong, as you'll see in a moment.
function update() { boxPos += boxVelocity; // Switch directions if we go too far if (boxPos >= limit || boxPos <= 0) boxVelocity = -boxVelocity; }
And now let's make it run. To do that, we need a loop that just keeps executing the update()
function to make the box move, and then the draw()
function to update its visualization on-screen. How should we do that?
If you've ever written games in another language, you might think of a while
loop:
while (true) { update(); draw(); }
However, JavaScript is single-threaded, which means that this approach would prevent the browser from doing almost anything else on that page. As a result, the browser will lock up, and after a few seconds it will show the user an error asking if they want to terminate your script. You'll want your game to run for more than a few seconds, so that's no good! We need a way to yield control from the game loop back to the browser for awhile, until the browser is ready for us to do some work again.
If you're familiar with JavaScript, you might think of setTimeout()
or setInterval()
- functions that allow executing code after a given amount of time. This is a pretty reasonable idea, but in browsers there's a better way: requestAnimationFrame()
. It's a fairly new function with strong browser support (at the time of writing, all browsers except IE9 and below support it). You pass a callback to this function, and it runs that callback the next time the browser is ready to change how the page looks. On a 60 Hz monitor, that means an optimally tuned application can draw updates (called painting frames) 60 times per second. So, your main loop has 1 / 60 = 16.667 milliseconds to do its thing every time it runs if you want to hit that 60 frames-per-second ideal. We'll cover a polyfill for node.js/io.js and lesser browsers later on.
Alright, let's use requestAnimationFrame() to write that main loop!
function mainLoop() { update(); draw(); requestAnimationFrame(mainLoop); } // Start things off requestAnimationFrame(mainLoop);
It works!
It is important that draw() is called after update() because we want the screen to reflect a state of the application that is as up-to-date as possible. (Note that canvas-based applications may need to take care to draw the very first frame in the application's initial state, before any updates have occurred. One approach to doing this is discussed later in this article.) Some posts across the internet speculate that rendering earlier in the requestAnimationFrame callback can get the screen painted faster; this is mostly not true, and even when it is, it's usually just a trade-off between rendering the current frame sooner and rendering the next frame later. It doesn't really matter when requestAnimationFrame is called again because only one frame can run at once, but I find that it makes the most logical sense to request another frame once the current one is completed.
Timing problems
So far, our update() function suffers from the issue that it is dependent on the frame rate. In other words, if your game is running slowly (that is, fewer frames per second), your object will also appear to move slowly, whereas if your application is running quickly (that is, more frames per second), your object will appear to move quickly. Having such unpredictable gameplay experiences is undesirable, especially in multiplayer games. No one wants their character to be a slowpoke just because their computer isn't as powerful. Even in single-player games, speed significantly affects difficulty: games that require fast reactions will be easier at slow speeds and unplayably difficult at fast ones.
To see this for ourselves, let's add the ability to throttle the FPS. We'll take advantage of the fact that requestAnimationFrame() passes a timestamp to the callback. Every time the loop runs, we'll check if a minimum amount of time has passed; if it has, we'll render the frame, and if it hasn't, we'll just wait for the next frame.
var lastFrameTimeMs = 0, // The last time the loop was run maxFPS = 10; // The maximum FPS we want to allow function mainLoop(timestamp) { // Throttle the frame rate. if (timestamp < lastFrameTimeMs + (1000 / maxFPS)) { requestAnimationFrame(mainLoop); return; } lastFrameTimeMs = timestamp; // ... }
You can see how much painfully slower it is:
We need to do better. The problem is that our application isn't tied to real time... how do we fix that?
As a first pass, let's try multiplying the velocity by the amount of time that has passed between rendering frames, which we'll call delta
. So instead of running boxPos += boxVelocity
we'll instead run boxPos += boxVelocity * delta
. We'll need to adjust our update function to receive delta
as a parameter from the main loop:
// Re-adjust the velocity now that it's not dependent on FPS var boxVelocity = 0.08, delta = 0; function update(delta) { // new delta parameter boxPos += boxVelocity * delta; // velocity is now time-sensitive // ... } function mainLoop(timestamp) { // ... delta = timestamp - lastFrameTimeMs; // get the delta time since last frame lastFrameTimeMs = timestamp; update(delta); // pass delta to update // ... }
Pretty good results! Our box now appears to move a constant distance over time, regardless of the frame rate.
If it's not behaving the way you expect... well, read on.
Physics problems
Try cranking up the velocity to a value like 0.8
. Within only a few seconds, you'll notice that the box will start to behave erratically, possibly going outside of the visible window. That's not supposed to happen! What went wrong?
What's happening is that, whereas before the box moved a constant distance every frame, now the box is moving varying distances every frame - and some of those distances are pretty large. In games, this phenomenon can allow players to run through walls, or prevent them from jumping over obstacles.
A second problem is that, because the box is moving a varying distance every frame, small rounding errors from multiplying the velocity with the frame delta will result in the box's position "drifting" over time. No one will be able to play exactly the same game because their frame rates will be different and that will cause different rounding errors. This may sound inconsequential but in practice it can be visible after only a few seconds even at normal frame rates That's not just bad for players - it's also bad for testing, because we'd like our program to produce exactly the same output if we give it the same input. That is, we want our program to be deterministic.
A solution
The key insight to fixing our physics problems is that we want the best of both worlds: we want to simulate a constant amount of in-game time every time we run our updates, and yet we need to simulate the actual elapsed time every frame. It turns out we can do both, by just running our updates multiple times with a fixed-size delta until we've simulated all the time we need. Let's tweak our main loop:
// We want to simulate 1000 ms / 60 FPS = 16.667 ms per frame every time we run update() var timestep = 1000 / 60; function mainLoop(timestamp) { // ... // Track the accumulated time that hasn't been simulated yet delta += timestamp - lastFrameTimeMs; // note += here lastFrameTimeMs = timestamp; // Simulate the total elapsed time in fixed-size chunks while (delta >= timestep) { update(timestep); delta -= timestep; } draw(); requestAnimationFrame(mainLoop); }
All we did is we separated the amount of time simulated in each update() from the amount of time between frames. Our update() function doesn't need to change; we just need to change the delta we pass to it so that each update() simulates a fixed amount of time. The update() function will now be run multiple times per frame if needed to simulate the total amount of time passed since the last frame. (If the time that has passed since the last frame is less than the fixed simulation time, we just won't run an update() until the the next frame. If there is unsimulated time left over that is less than our timestep, we'll just leave it to be simulated during the next frame. That's why we need to add to delta
with +=
instead of assigning to it - because we need to keep the leftover time from the last frame.) This approach avoids inconsistent rounding errors and ensures that there are no giant leaps through walls between frames.
Let's see it in action:
If you tweak the boxVelocity
you'll see that our box now correctly stays where it's supposed to be. Nice!
One note: the choice of value for our timestep wasn't arbitrary. The denominator effectively caps the frames per second that users will be able to perceive (unless drawing is interpolated, as discussed below). Decreasing the timestep increases the maximum perceived FPS at the cost of running update() more times per frame when the actual FPS is lower than the maximum. Since running update() more times takes more time to process, this can actually slow down the frame rate. If it slows down too much, it could lead to a spiral of death.
Panic! Spiral of death
Sadly, yet again we've introduced a new problem. In our first attempt, if frames took a long time to update and render, the frame rate just slowed down until each frame lasted long enough for the updates to occur. However, our updates are dependent on time now. If one frame takes a long time to simulate, the next one will need to simulate an even longer amount of time. That means we'll need to run update() more times, which means that our new frame will take even longer to simulate, and so on... until the application will freezes and crashes. This is called a spiral of death. Not good.
Generally, we're fine as long as our timestep
is set to a value that is high enough that the update calls usually take less time than the amount of time they're simulating. However, this will be different for different hardware and workloads. Plus, this is JavaScript we're talking about: we have very little control over the execution environment. If the user switches to another tab, the browser will stop painting frames, and we'll end up with a lot of time to simulate when the user comes back. If it's too much time, the browser will hang.
Sanity check
We need an escape route. Let's add a sanity check to our update loop:
var numUpdateSteps = 0; while (delta >= timestep) { update(timestep); delta -= timestep; // Sanity check if (++numUpdateSteps >= 240) { panic(); // fix things break; // bail out } }
What should we do in our panic function? Well, that depends.
Turn-based multiplayer games use a networking technique called "lockstep" which ensures that all players are experiencing the game at the same pace. This means that all players experience the game at the pace of the slowest one. If one player falls behind for too long, that player gets dropped so that the other players don't get dragged down too. So, in lockstep games, the player will just get dropped.
In multiplayer games that don't use lockstep, like first person shooters, there is typically a server that has an "authoritative state" of the game. That is, the server receives all players' inputs and calculates what the world should look like in order to prevent players from cheating. If a player drifts too far away from the authoritative state, as would be the case in a panic, then the player needs to be snapped back to the authoritative state. (In practice, this is disorienting, so the player may fall back on a temporary alternative update function that will ease the client world back towards the server world more smoothly.) Once we snap the user to their new state, we don't want them to still have a bunch of time left over to simulate because we effectively fast-forwarded through it. So, we can get rid of it:
function panic() { delta = 0; // discard the unsimulated time // ... snap the player to the authoritative state }
If the server does this, it would introduce non-deterministic behavior, but only the server needs the game to proceed deterministically so it's fine on the client in a networked game.
In single player games, it may be acceptable to allow the application to keep running for awhile to see if it will catch up. However, this could also cause the application to look like it is running very quickly for a few frames as it transitions through the intermediate states. An alternative that may be acceptable is to simply ignore the unsimulated elapsed time as we did above in the networked non-lockstep case. Again, this introduces non-deterministic behavior, but you may decide that a panic is an extraordinary circumstance and all bets are off.
In all cases, if the application panics frequently and it's not because of being in a background tab, this is probably an indication that the main loop is running too slowly. You may want to increase your timestep.
FPS control
A complementary way to prevent spirals of death (and low frame rates in general) is to monitor the frame rate and adjust what activities are occurring in the main loop if the frame rate gets too low. Often a drop in frame rate will probably be noticeable before a panic occurs, so we can prevent panicking by being prepared.
There are many ways to track the frame rate. One way is to keep track of how many frames were rendered in each second for some period of time (say, the last 10 seconds) and average them. However, that's a little more performance-intensive than we'd like, and it would be nice to weight the average towards more recent seconds. An easier approach that does this weighting is to use an exponential moving average FPS in our main loop:
var fps = 60, framesThisSecond = 0, lastFpsUpdate = 0; function mainLoop(timestamp) { // ... if (timestamp > lastFpsUpdate + 1000) { // update every second fps = 0.25 * framesThisSecond + (1 - 0.25) * fps; // compute the new FPS lastFpsUpdate = timestamp; framesThisSecond = 0; } framesThisSecond++; // ... }
The 0.25
is a "decay" parameter - it is essentially how heavily more recent seconds are weighted.
The fps
variable now holds our estimated frames per second. Great... now what should we do with it? Well, the obvious thing to do is display it:
// Assumes we've added <div id="fpsDisplay"></div> to the HTML var fpsDisplay = document.getElementById('fpsDisplay'); function draw() { box.style.left = boxPos + 'px'; fpsDisplay.textContent = Math.round(fps) + ' FPS'; // display the FPS }
Success:
We can use the FPS in more useful ways though. If it's too low, we can exit the game, lower the visual quality, stop or reduce activities outside of the main loop like event handlers or audio playback, perform non-critical updates less frequently, or increase the timestep. (Note that this last option results in more time being simulated per update() call, which causes the application to behave non-deterministically, so it should be used sparingly if at all.) If the FPS goes back up, we can reverse these actions.
Beginnings and endings
It may be useful to have functions that your main loop calls at the beginning and end of the loop (let's call them begin()
and end()
, respectively) to do setup and cleanup. Generally, begin() is useful for processing input before the updates run (for example, spawning projectiles when the player presses the "fire weapon" key). If there are long-running actions that need to be taken in response to user input, processing them in chunks in the main loop instead of all at once in the event handlers can be useful to avoid delaying frames. The end() function is also useful for incrementally performing long-running updates that aren't time-sensitive, as well as for adjusting to changes in FPS.
Choosing a timestep
Generally, 1000 / 60 is a good choice for most cases because most monitors run at 60 Hz. If you're finding that your application is very performance-intensive you may want to set it to, for example, 1000 / 30. This effectively caps your perceivable frame rate at 30 FPS (unless drawing is interpolated as discussed below). Note that frames can be throttled depending on your monitor and graphics driver, so the maximum you set may not be the maximum you get in practice. If your game runs quickly and you want more accurate simulations, you could consider maximum FPS values of high-end gaming screens: 75, 90, 120, and 144. Much beyond that and you'll end up slowing yourself down.
Performance considerations
If your performance isn't as good as you'd like it to be, interpolating drawing and using web workers are two structural changes you can make that can have a solid impact.
Interpolate drawing
After our updates finish running, there will usually be some time left over in delta
that is less than a full timestep. Passing the percent of a timestep that hasn't been simulated yet to the draw() function allows it to interpolate between frames. This visual smoothing helps reduce stuttering even at high frame rates.
Stuttering appears because the time simulated by update() and the time between draw() calls is usually different. To illustrate, if update() advances the simulation at each vertical bar in the first row below, and draw() calls happen at each vertical bar in the second row below, then some frames will have time left over that is not yet simulated by update() when rendering occurs in draw():
update() timesteps: | | | | | | | | | draw() calls: | | | | | | |
In order for draw() to interpolate motion for rendering purposes, objects' state after the last update() must be retained and used to calculate an intermediate state. Note that this means renders will be up to one update() behind. This is still better than extrapolating (projecting objects' state after a future update()) which can produce bizarre results. Storing multiple states can be difficult to set up, and keep in mind that running this process takes time that could push the frame rate down, so it may not be worthwhile unless stuttering is visible.
Here's how we can implement interpolation for our box:
var boxLastPos = 10; function update(delta) { boxLastPos = boxPos; // save the position from the last update boxPos += boxVelocity * delta; // ... } function draw(interp) { box.style.left = (boxLastPos + (boxPos - boxLastPos) * interp) + 'px'; // interpolate // ... } function mainLoop(timestamp) { // ... draw(delta / timestep); // pass the interpolation percentage
All together:
Use Web Workers for updates
As with everything in the main loop, the running time of update() directly affects the frame rate. If update() takes long enough that the frame rate drops below the target ("budgeted") frame rate, parts of the update() function that do not need to execute between every frame can be moved into Web Workers. (Various sources on the internet sometimes suggest other scheduling patterns using setTimeout() or setInterval(). These approaches sometimes offer modest improvements with minimal changes to existing code, but because JavaScript is single-threaded, the updates will still block rendering and drag down the frame rate. Using Web Workers is more work, but they execute in separate threads, so they free up more time in the main loop.)
A non-exhaustive list of things to think about when you're considering using Web Workers:
- Profile your code before doing the work to move it into Web Workers. It could be the rendering that is the bottleneck, in which case the first solution should be to decrease the visual complexity of the scene.
- It doesn't make sense to move the entire contents of update() into workers unless your draw() function can interpolate between frames as discussed above. The lowest-hanging fruit to move out of update() is background updates (like calculating citizens' happiness in a city-building game), physics that doesn't affect the scene (like flags waving in the wind), and anything that is occluded or happening far off screen.
- If draw() needs to interpolate physics based on activity that occurs in a worker, the worker needs to pass the interpolation value back to the main thread so that is is available to draw().
- Web Workers can't access the state of the main thread, so they can't directly modify objects in your scene. Moving data to and from Web Workers is a pain. The fastest way to do it is with Transferable Objects: basically, you can pass an ArrayBuffer to a worker, destroying the original reference in the process.
You can read more about Web Workers and Transferable Objects at HTML5 Rocks.
Starting and stopping
So far, once our game has started the main loop, we haven't had a way to stop it. Let's introduce start()
and stop()
functions to manage whether the application is running. The first thing we need to do is figure out how to stop requesting frames. One way would be to keep a running
Boolean variable that controls whether the main loop calls requestAnimationFrame() again. That's normally fine, but if the game is started and stopped rapidly, a frame will get requested with no way to cancel it. So instead what we need is a way to cancel frames. Luckily there's a function for that: cancelAnimationFrame()
. Requesting a frame actually returns a frame ID that we can pass to the cancelling function:
frameID = requestAnimationFrame(mainLoop);
Be sure to do this in the FPS throttling condition as well if you've kept it around.
Now let's implement stop():
var running = false, started = false; function stop() { running = false; started = false; cancelAnimationFrame(frameID); }
In addition to this implementation, event handling and other background tasks (such as those occurring via setInterval() or in web workers) should also be paused when the main loop is paused. This usually isn't a big deal because they can just check the running
variable to see if they should execute or not. Also note that pausing in multiplayer games will cause the player's client to become out of sync, so generally the game should exit or snap the player to their updated position when the main loop is started again (after confirming with the player that they really want to pause).
Starting the loop is trickier. We need to pay attention to four things. First, we shouldn't allow starting the main loop if it's already started, since that would request multiple frames at once and slow us down. Second, we need to make sure that toggling between starting and stopping quickly doesn't cause problems. Third, we may need to render the initial state of the game before any updates occur, since our main loop draws after updating. Finally, we need to reset some variables in order to avoid simulating time that passed while the game was paused. Additionally, event handlers and background tasks should resume once the game starts again. Here we go:
function start() { if (!started) { // don't request multiple frames started = true; // Dummy frame to get our timestamps and initial drawing right. // Track the frame ID so we can cancel it if we stop quickly. frameID = requestAnimationFrame(function(timestamp) { draw(1); // initial draw running = true; // reset some time tracking variables lastFrameTimeMs = timestamp; lastFpsUpdate = timestamp; framesThisSecond = 0; // actually start the main loop frameID = requestAnimationFrame(mainLoop); }); } }
In practice:
Node.js/IO.js and IE9 support
The main issue with our code right now that is holding back support for node.js/IO.js, as well as IE9 and earlier if you care about those browsers, is the lack of requestAnimationFrame() and cancelAnimationFrame(). We can make up for it with polyfills based on timers:
// This polyfill is adapted from the MIT-licensed // https://github.com/underscorediscovery/realtime-multiplayer-in-html5 var requestAnimationFrame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (function() { var lastTimestamp = Date.now(), now, timeout; return function(callback) { now = Date.now(); timeout = Math.max(0, timestep - (now - lastTimestamp)); lastTimestamp = now + timeout; return setTimeout(function() { callback(now + timeout); }, timeout); }; })(), cancelAnimationFrame = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout;
We've reduced the compatibility issue to support for Date.now(), which isn't available in IE8. If you really need IE8 support, you can use +new Date()
instead, but you'll be generating a lot of new objects and that will create a lot of garbage collection which will make your game stutter. And IE8 is slow enough to start with that it hardly makes sense to do anything JavaScript-intensive on it.
Wrapping up
That was a lot to think about. If you want your main loop the easy way, you can just use my MainLoop.js open-source project. Then you won't have to worry about all these issues.
If you're rolling your own, there is some general cleanup that could be done. For example, the box should be wrapped in its own class. The whole script should be wrapped in an IIFE with an exported interface to prevent polluting the global namespace in browsers, or packaged as a CommonJS or AMD module. MainLoop.js does this (and more) for you, but on the whole, we've done pretty well.
Finally, I'd like to thank Glenn Fiedler for writing the classic Fix your timestep! which was the starting point for a lot of the work here. Thanks to Ian Langworth as well for reviewing a shorter version of this article that I included in my book about making 3D browser games and for some tips about web workers. If, after all this, you're still looking for more resources on the topic, you might also consider checking out the Game Programming Patterns book's take or the less thorough take at MDN.