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:
<canvas></canvas>
<script src="./index.js"></script>
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:
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:
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:
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:
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:
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:
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:
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.
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
:
// 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:
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:
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:
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:
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.