Your game loop probably runs at a different speed on a 144 Hz monitor

By Brandon Levy · Neon Drift devlog · August 2026

My game ran at 100% speed on a 60 Hz display, 75% on a 90 Hz one, and 80% on a 144 Hz one. Nobody reported it, because a slower game doesn't feel broken — it feels easier.

I found this while chasing something else entirely, which is the only reason I found it at all. It had been sitting in my notes for two weeks, mis-scoped as a battery-saver issue, while it was quietly making my leaderboards unfair.

The loop

Here is what I shipped. If you have written a browser game, you have probably written something close to it:

const TARGET_FPS = 60;
const FRAME_MS   = 1000 / TARGET_FPS;   // 16.667
const PHYSICS_MS = FRAME_MS - 1;        // 15.667, "slightly loose so 60Hz never misses"
let lastPhysicsTime = 0;

function loop() {
  requestAnimationFrame(loop);
  const now = Date.now();
  const delta = now - lastPhysicsTime;

  if (delta >= PHYSICS_MS) {
    lastPhysicsTime = now;     // <- the bug
    update();
  }
  draw();
}

The intent is "run the simulation about sixty times a second". The reasoning is that if enough time has passed, take a step. It is short, it is readable, and on the machine I wrote it on it is perfectly correct.

Two mistakes that only interact

The first mistake is lastPhysicsTime = now. That line discards whatever time was left over. If 20 ms have elapsed and a step is 16.667 ms, you take one step and throw away 3.3 ms. Do that repeatedly and the simulation drifts behind wall-clock time, permanently. A correct loop subtracts the step from an accumulator and carries the remainder.

The second mistake is Date.now(). It returns integer milliseconds. It is also wall-clock time, which means it can jump backwards when the system clock is adjusted. performance.now() is monotonic and sub-millisecond, and it is what this code wanted.

Either mistake alone is survivable. Together they produce something worse than either: the number of steps per second depends on how the display's frame interval divides into a threshold that has been quantised to whole milliseconds.

The measurement

You can work this out without a browser. Simulate integer Date.now() against the frame interval of each refresh rate and count how many times the branch fires:

const PHYSICS_MS = 15.667;
function simulate(hz, seconds) {
  const frame = 1000 / hz;
  let last = 0, ticks = 0;
  for (let f = 1; f <= seconds * hz; f++) {
    const now = Math.floor(f * frame);   // Date.now() is integer ms
    if (now - last >= PHYSICS_MS) { last = now; ticks++; }
  }
  return ticks / seconds;
}

The target is 60. What it actually does:

DisplaySimulation ticks/secEffective game speed
60 Hz60.0100% — correct by luck
90 Hz45.075%
120 Hz60.0100%
144 Hz48.080%
165 Hz54.691%

60 and 120 are fine. Everything else is not. And notice there is no pattern a player could learn — 165 Hz is better than 144 Hz.

Why nobody reports this

This is the part I think is genuinely worth internalising, because it is why the bug survived a sixteen-day closed test with real players.

In my game, both the obstacle speed and gravity are applied per simulation step. So when the simulation runs slower, everything scales together: obstacles approach more slowly, the player falls more slowly, the gaps stay exactly where they were. The geometry is untouched.

What is not untouched is the player. Reaction time is measured in real seconds. A player at 45 ticks per second gets a third more wall-clock time to react to every gap, against identical geometry.

So the bug does not make the game feel bad. It makes it easier. Nobody files a report saying "I think I'm doing suspiciously well." I only found it because I was measuring frame timing for an unrelated stutter and a diagnostic number came back at 54.6 when it should have been 60.

The competitive consequence. My game has a daily challenge where every player gets the same seeded course, plus head-to-head modes. A player on a 90 Hz phone was playing a materially easier version of the identical challenge. Not by cheating — by owning a different screen.

The fix, part one: a real accumulator

const STEP_MS      = 1000 / 60;   // exact, no fudge factor
const MAX_CATCHUP  = 5;           // ~83ms of backlog per frame
const MAX_FRAME_MS = 250;
let acc = 0, lastFrame = 0, renderAlpha = 0;

function loop() {
  requestAnimationFrame(loop);
  const now = performance.now();
  if (!lastFrame) { lastFrame = now; draw(0); return; }

  let elapsed = now - lastFrame;
  lastFrame = now;
  if (elapsed > MAX_FRAME_MS) elapsed = MAX_FRAME_MS;
  acc += elapsed;

  let steps = 0;
  while (acc >= STEP_MS && steps < MAX_CATCHUP) {
    acc -= STEP_MS;               // carry the remainder
    steps++;
    update();
  }
  if (steps >= MAX_CATCHUP) acc = 0;
  renderAlpha = acc / STEP_MS;
  draw(renderAlpha);
}

Three details that are not decoration:

MAX_FRAME_MS. A backgrounded tab stops firing requestAnimationFrame. Come back after forty seconds and, without a clamp, you bank forty seconds of debt and run thousands of steps in one frame. The player returns to a corpse. Clamp the elapsed time before it reaches the accumulator.

Dropping the backlog at the cap, not carrying it. When a device cannot keep up, carrying the deficit means the next frame has even more work, which makes it later still. That is the classic spiral of death. Setting acc = 0 at the cap means a struggling device runs in slow motion instead of locking up — which is the failure you want.

An exact step. The old FRAME_MS - 1 fudge was there to stop a 60 Hz display missing a step. With a real accumulator it is unnecessary, and it was half of what made the arithmetic go wrong.

Verified across 60, 90, 120, 144, 165 and 240 Hz, with frame jitter, and throttled to 30 and 20 Hz: a true 59.9–60.0 ticks per second everywhere.

The fix, part two: interpolation

A fixed 60 Hz simulation on a 165 Hz display leaves roughly three frames to fill between each simulated position. Without interpolation the panel shows the same position three times and then jumps — the object moves in visible stairs whenever it moves fast.

The answer is not to speed up the simulation. It is to keep the simulation fixed and draw between steps. Store each object's previous position, then render a fraction of the way toward the current one:

// at the top of each simulation step, before anything moves
player.prevY = player.y;

// at render time
const SNAP = 40;   // px
function withInterpolation(alpha, drawFn) {
  const trueY = player.y;
  if (Math.abs(player.y - player.prevY) < SNAP)
    player.y = player.prevY + (player.y - player.prevY) * alpha;
  try { drawFn(); } finally { player.y = trueY; }
}

The important property: nothing here feeds back into the simulation. Collision, scoring and the random seed all still run on the integer step. Interpolation cannot change an outcome, which is what makes it safe in a game with leaderboards.

Three things I got wrong before getting it right:

How to check yours in two minutes

Add a counter to your loop, play for thirty seconds, and print the number of simulation steps divided by elapsed seconds. If your target is 60 and you see 45, 48 or 54, you have this bug. Then borrow someone's laptop with a different screen and do it again — that comparison is the whole test.

The tell in the source is any loop that assigns now to the last-step timestamp instead of subtracting a fixed step from an accumulator. If you also see Date.now(), you have both halves.

The part I got wrong

This was in my notes for two weeks as "throttled devices run slow, confirmed on iOS Low Power Mode". That framing is what kept it un-fixed: it made it sound like an edge case affecting a handful of players who had opted into a degraded mode.

It was not an edge case. It was every player on a high-refresh display, permanently, including a large share of modern phones. I had the correct observation and drew the boundary around it far too tightly — and because the symptom was "easier", nothing pushed back on that.

If you have a note like that, the question worth asking is not "how do I fix this" but "what else produces this symptom that I have not checked".

Neon Drift is free to play in a browser and on Google Play. More build notes in the devlog.