Skip to main content
null 💻 notes

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!