A setInterval() alternative for browser-based games
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);
Have fun!