Table of Contents
Help! My game runs too fast!
Limit your game's speed
Determine your current frame rate
Get the current time
Get time difference between frames
Prevent future rendering
Subtract excess time
Test your new frame rate
Conclusion
Christopher Lis
Christopher Lis
Last updated

Senior Frontend Engineer

Standardize your JavaScript games' framerate for different monitors

Help! My game runs too fast!

Ever develop an HTML / JavaScript game and realize that your game plays quicker on one monitor compared to another?

Ever wonder: Why tf is that?

Well. Let me tell ya.

When used in an animation context, requestAnimationFrame (the method responsible for creating an animation loop in JavaScript) will attempt to establish a framerate, usually 60 frames per second (fps). However, according to Mozilla's web docs, the fps rate produced by requestAnimationFrame "will generally match the display refresh rate in most web browsers as per W3C recommendation."

This means if you're using a monitor that refreshes its screen at a rate of 120hz, like the M1 MacBook pro monitor that I'm using to write this post, your web games will run at double the speed.

For instance, if one of your game's characters is meant to move at the pace of one-pixel for every frame of animation, they'll move at a total rate of 120 pixels per second compared to the standard 60. This means your character is traveling double the distance within the same amount of time, making it feel as though your game is playing at twice the speed of that on a 60hz monitor.

As a result, when using requestAnimationFrame for your game loop, it's common to limit your frame rate so that it always attempts to run at 60fps rather than something like 120fps.

Let's learn how to do exactly that.

Limit your game's speed

To restrict requestAnimationFrame from running at a potential 120fps, we're going to need to track the amount of time that passes inbetween each frame. But first, if you don't have something already, here's some quick boilerplate code that runs an animation loop from an HTML file:

index.html
<canvas></canvas>
<script src="./index.js"></script>
index.js
const canvas = document.querySelector('canvas')
const c = canvas.getContext('2d')

function animate() {
  window.requestAnimationFrame(animate)
}

animate()

Determine your current frame rate

Depending on what monitor you're using, running the code in index.js will start an animation loop that typically runs at a standard 60fps or a very fast 120fps. To determine which we're using, we can either check the hz value associated with our monitors (my MacBook refreshes at a rate of 120hz), or we can add some code that logs how many frames have passed for every second:

index.js
let frames = 0
function animate() {
  window.requestAnimationFrame(animate)
  frames++
}

setInterval(() => {
  console.log(frames)
}, 1000)

This says for every frame of our animation loop, add on a value of 1 onto the let of frames (this stores our total elapsed frames). setInterval will fire once every second (the second argument of 1000 refers to time in milliseconds [ms], so 1000ms is equal to 1 second) and log the total amount of frames that have passed.

Running this in a browser, the first console log should produce the fps associated with your monitor. Mine is 121 as you can see here:

My screen's current refresh rate of 121 fps

To prevent my game from running at 121fps, I'll have to limit my fps with JavaScript, but even if your value comes out to around 60, you should still follow along and do the same if you want others to play your game at the pace you intend it to be played at.

Get the current time

To limit our game's framerate we need to track the amount of time that has passed between each of our frames. To do this, we'll start by getting a value that represents total elapsed time in JavaScript:

index.js
const canvas = document.querySelector('canvas')
const c = canvas.getContext('2d')

// gets elapsed time since page loaded in ms
let msNow = window.performance.now()
function animate() {
  window.requestAnimationFrame(animate)
}

animate()

window.performance.now() retrieves the amount of time (in milliseconds) that has passed since our page finished loading.

If we were to log this out, you should see a value from 5 to 100 in the console on refresh. This means that 5 to 100 milliseconds have passed before our JavaScript console.log() statement is called.

If we were to throw this in our animation loop, you'd see that the value increases:

Milliseconds increase as time passes

As time goes on, the value window.performance.now() will get larger and larger, but rather than get the total amount of time that has passed, we want the time difference between frames. This means we have to store the amount of time that has passed for one frame, then carry it over into the next frame of animation before setting it once more.

Get time difference between frames

To get the elapsed time between our animation loop's frames, we'll add the following code:

index.js
const canvas = document.querySelector('canvas')
const c = canvas.getContext('2d')

let msPrev = window.performance.now()
function animate() {
  window.requestAnimationFrame(animate)

  const msNow = window.performance.now()
  const msPassed = msNow - msPrev

  msPrev = msNow
}

animate()

Initially, msPrev (millisecondsPrevious) is set to a value of window.performance.now() since no time will have passed inbetween frames when we first load our file.

You'll see we run the following calculation:

const msPassed = msNow - msPrev

For the very first frame of our animation, subtracting two separate versions of window.performance.now() will give us a result that's close to 0. You may think, "Well, that's kind of useless if the amount of time between frames is 0," and yes, that is true, but you must remember, this is the amount of time between msNow and msPrev on initial load. Only when we get to frame two of our animation will we start to see meaningful values assigned to msPassed.

So while const msPassed = msNow - msPrev produces a value near 0 for frame one, it should consistently produce a larger value that hovers around 8, representing 8 milliseconds of time that passes between each frame render:

Frame rate console logged out

This is a great start, but we're not done just yet. Using this msPassed value, we're going to determine if enough time has passed to call code that should render a frame.

Prevent future rendering

We need to prevent a render cycle if too little time has passed between frames.

Let's look at the following code:

index.js
const canvas = document.querySelector('canvas')
const c = canvas.getContext('2d')

let msPrev = window.performance.now()
const fps = 60
const msPerFrame = 1000 / fps

function animate() {
  window.requestAnimationFrame(animate)

  const msNow = window.performance.now()
  const msPassed = msNow - msPrev

  if (msPassed < msPerFrame) return

  msPrev = msNow
}

animate()

First we're setting fps and msPerFrame.

fps is the frames per second I want the game to run at, while msPerFrame is the amount of time in milliseconds required for one frame to pass if we were to runs things at a rate of 60fps. Calculating this, msPerFrame will be equal to a value of 16.67, meaning one frame will take 16.67 milliseconds to complete in a 60 fps loop.

As you saw earlier, when calculating msPassed, we were receiving values around 8, meaning 8ms have passed between each frame. What I want to do is prevent our loop from setting msPrev for every frame, and only set this if 16.67 milliseconds pass between consecutive frames.

We can do that by calling:

if (msPassed < msPerFrame) return

This says, if the msPassed between two frames is less than 16.67, don't set msPrev, go to the next frame in the animation loop.

In the next frame (frame three), msPrev will have the value from frame one because we never set it in frame two. With this frame one value, we can continue to check if the time between frames one and three is greater than 16.67.

Model exhibiting frame rates and time between frames
We must travel through two frames in total before 17 milliseconds have passed

If we were to go off the above model, you'll see that it's not until frame three in which our total time difference reaches a value greater than 16.67. Therefore, only when we hit frame three would we continue downwards inside our animate function and then call msprev = msNow:

index.js
// msPassed not greater than 16.67? don't call any code below if statement
if (msPassed < msPerFrame) return

// only call if msPassed is greater than 16.67ms
msPrev = msNow

Adding this to index.js will get requestAnimationFrame running at around 60 fps, however, looking closely, you might notice an issue...

If msPassed is a value greater than 16.67, say 20, then we have about 4 ms left over that's not taken into account for the next frame of our loop and we might not be running at a true 60fps. Small amounts of ms values can add up and really throw off the rate at which we call render code, so we need to make sure that left over value (in this case, 4) is taken into account.

Subtract excess time

To get the amount of excess time that may have accumulated between consecutive frames, we're going to use the following equation:

index.js
const excessTime = msPassed % msPerFrame

This divides the total time difference between frames by the amount of time required for one frame to complete at 60fps (16.67ms), then returns the remainder.

So let's say msPassed is 20 while msPerFrame remains 16.67. Running the equation above, we'd get a value of 3.33, meaning we have 3.33 excess seconds that have passed between frames.

We want to make sure we take these excess seconds into account when calculating msPassed on the next iteration of the loop, so we'll set msPrev equal to this frame's current time with msNow, but subtract the excess time as well:

index.js
const canvas = document.querySelector('canvas')
const c = canvas.getContext('2d')

let msPrev = window.performance.now()
const fps = 60
const msPerFrame = 1000 / fps

function animate() {
  window.requestAnimationFrame(animate)

  const msNow = window.performance.now()
  const msPassed = msNow - msPrev

  if (msPassed < msPerFrame) return

  const excessTime = msPassed % msPerFrame
  msPrev = msNow - excessTime
}

animate()

And now, any code run beneath:

if (msPassed < msPerFrame) return

Should run at a solid 60fps.

Test your new frame rate

To test that this works, we can re-add the frames variable and setInterval function that we used previously, just make sure frames++ is placed beneath your if statement within the animation loop:

index.js
const canvas = document.querySelector('canvas')
const c = canvas.getContext('2d')

let msPrev = window.performance.now()
const fps = 60
const msPerFrame = 1000 / fps
let frames = 0

function animate() {
  window.requestAnimationFrame(animate)

  const msNow = window.performance.now()
  const msPassed = msNow - msPrev

  if (msPassed < msPerFrame) return

  const excessTime = msPassed % msPerFrame
  msPrev = msNow - excessTime

  frames++
}

setInterval(() => {
  console.log(frames)
}, 1000)

animate()

Running this in the browser, you should see frames increase by 60 for every second, with the occasional 59 or 58 thrown in there for when your loop misses some frames:

Frame rate console logged out

Missing a frame is common due to computational power, limitations, and time syncing. Really you should only start worrying once your frame rate starts dipping past a point where your game feels choppy, typically in my experience this is around 30fps, but you always want to make sure your frame rate is hitting 60fps as consistently as possible.

Conclusion

In all, this is one way you can limit your fps on screens that have a higher refresh rate. If you know of any improvements, let me know in the comments down below, I'd love improve and learn from you as well.

If you'd like to take this to the next level, you can view my video tutorial on this topic and take my Space Invaders Course where you'll learn how to build a shooter style game from the ground up.

Thanks for reading and I'll see yaaa next time.

Comments

Want to participate?

Create a free Chris Courses account to begin

No comments yet, be the first to add one

Providing the lift to launch your development career

© 2024 Chris Courses. All rights reserved.