A setInterval() alternative for browser-based games
Don’t use setInterval()
for your game loops. In JavaScript,
timers operate in a non-guaranteed delay manner due to the
single-threaded nature of the language in browsers.
Asynchronous events such as timers and mouse clicks can
only be executed when there is a gap in the execution.
Consider the following example where we want to calculate the number of seconds elapsed since a certain date:
let count = 0;
let startTime = new Date().getTime();
setInterval(function() {
let currentTime = new Date().getTime();
let elapsedSeconds = Math.floor((currentTime - startTime) / 1000);
count++;
console.log(`Elapsed time: ${elapsedSeconds}s | Count: ${count}`);
}, 1000);
It would seem like this function increments count
every second,
but setInterval()
only guarantees that the it will wait
at least one second—not that it will wait exactly one second.
If you have a game that assumes the time is the same for all players regardless of browser or device, you’ll want a more robust solution.
So let’s create just that, and call it setInterval2()
.
Precision interval-based function invocation instructions
Start by creating a const function with the same arguments
we would pass to setInterval()
: the function it should
invoke after each interval, and the length (in milliseconds)
of each interval between executions:
const setInterval2 = (fn,time) => {
// A place to store the timeout Id (later)
let timeout = null;
// calculate the time of the next execution
let nextAt = Date.now() + time;
Next, let’s create a way to execute the provided function as well as schedule the next execution of that function after a specific time:
const wrapper = () => {
// calculate the time of the next execution:
nextAt += time;
// set a timeout for the next execution time:
timeout = setTimeout(wrapper, nextAt - Date.now());
// execute the function:
return fn();
};
The wrapper
function dynamically adjusts the timeout for
each iteration beyond the first based on the actual time
elapsed. This helps compensate for inconsistencies caused by
the single-threaded nature of JavaScript and the imprecision
of timers.
We still need to handle the first iteration, and then
wrapper
will be on its recursive way:
// Set the first timeout, kicking off the recursion:
timeout = setTimeout(wrapper, nextAt - Date.now());
Finally, let’s provide a way to halt any additional
executions. We can do this by wrapping a clearTimeout()
in an anonymous function and returning it:
// A way to stop all future executions:
const cancel = () => clearTimeout(timeout);
// Return an object with a way to halt executions:
return { cancel };
};
Putting it all together, you should end up with something like this:
const setInterval2 = (fn,time) => {
// A place to store the timeout Id (later)
let timeout = null;
// calculate the time of the next execution
let nextAt = Date.now() + time;
const wrapper = () => {
// calculate the time of the next execution:
nextAt += time;
// set a timeout for the next execution time:
timeout = setTimeout(wrapper, nextAt - Date.now());
// execute the function:
return fn();
};
// Set the first timeout, kicking off the recursion:
timeout = setTimeout(wrapper, nextAt - Date.now());
// A way to stop all future executions:
const cancel = () => clearTimeout(timeout);
// Return an object with a way to halt executions:
return { cancel };
};
If you’d like to try it out, just open up your browser’s developer tools—where you’ll see it’s been running this whole time.
Here’s how I did it:
setInterval2(() => {
console.log("Every three seconds, I say hello. Hello.")
}, 3000);
Now go forth and make some great games.