Every video game you have ever played has - at its core - a loop cycle that listens for user input, processes it updating the current state, and then draws the result on the screen accordingly.
while(true) {
input()
update()
draw()
}
If we measure the time taken for every single game loop cycle in terms of real time passed, we get frames per second (aka frame rate, aka fps).
let fps = 0
let elapsed = 0
let current = performance.now()
let last = performance.now()
setInterval(() => {
current = performance.now()
elapsed = current - last
last = current
fps = 1000 / elapsed
input()
update()
draw()
})
The problem with this approach is that the duration of a single cycle depends on how much fast the CPU is and how much operations are performed at any given time before processing the next state.
Javascript is single-threaded language, so during the execution of the function called by setInterval
no other functions will be executed and the next cycle has to wait for the previous one to be done.
Understandably, the real challenge in game development is to make the game run on a consistent speed on every machine.
Fixed speed
The simplest approach to solve this problem is to run the game at a fixed and consistent speed, so that every frame takes the same amount of time.
In the following scenario we will run our code at the frame rate set by the browser via window.requestAnimationFrame
.
The number of callbacks is usually 60 frames per second, but will generally match the display refresh rate in most web browsers.
The desktop monitor standard is a 60Hz refresh rate.
let fps = 0
let elapsed = 0
let current = performance.now()
let last = performance.now()
function tick() {
window.requestAnimationFrame(() => {
current = performance.now()
elapsed = current - last
last = current
fps = 1000 / elapsed
input()
update()
draw()
tick()
})
}
tick()
The limit with this approach is that we need to ensure that the logic inside every cycle will not take more time than the frame duration, as the game will slow down.
Real time
To overcome the limitation with the method above we can calculate how much time is passed on every cycle.
Passing the time elapsed between two frames to the update()
function allows us to compute the next state based on a real difference in time between two frames.
If a frame takes longer to be computed, our function will be able to calculate the next state taking this delay into account.
let fps = 0
let current = performance.now()
let last = performance.now()
let elapsed = 0
function tick() {
window.requestAnimationFrame(() => {
current = performance.now()
elapsed = current - last
last = current
fps = 1000 / elapsed
input()
update(elapsed)
draw()
tick()
})
}
tick()
The limitation with this approach is better understable with a real-case scenario. Let's figure out two objects that are going to collide.
If the frame before the collision takes too much time to be computed, on the next frame the objects will be drawn in a new position based solely on the time passed between the frames. The frame in which the collision should have been detected has been skipped completely.
Decoupling
What we can do to fix the issue above is to decouple the update()
logic from the draw()
logic.
In this way the screen will be re-painted only when all the frames that are lagging behind - if any - have been computed and the game state is the closest to the expected "real time" conditions.
On a fast machine this process will be unnoticeable, on a slow one we will have a choppy rendering, but we will be sure that what is on the screen will reflect the latest state that could have been possibly fully computed.
let lag = 0
let elapsed = 0
let current = performance.now()
let last = performance.now()
let update_rate = 1000 / 60
function tick() {
window.requestAnimationFrame(() => {
current = performance.now()
elapsed = current - last
last = current
lag += elapsed
fps = 1000 / elapsed
while (lag >= update_rate) {
update()
lag -= update_rate
}
draw(lag / update_rate)
tick()
})
}
tick()
Given that lag
can be greather than zero
at draw()
time - we can pass lag / update_rate
to it, in order to take into account small delays between frames, providing a smoother experience.
This small delays will be always under the update_rate
duration - so we can ignore the fact that an object could be drawn in a position different from the expected (e.g. due to collisions or changes in speed).
At this point in the logic we can assume to be at the most recent game state update and this remaining delta is so small that any discrepancies will be unnoticeable and immediately fixed at next loop cycle.