Your game loop probably runs at a different speed on a 144 Hz monitor
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:
| Display | Simulation ticks/sec | Effective game speed |
|---|---|---|
| 60 Hz | 60.0 | 100% — correct by luck |
| 90 Hz | 45.0 | 75% |
| 120 Hz | 60.0 | 100% |
| 144 Hz | 48.0 | 80% |
| 165 Hz | 54.6 | 91% |
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 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:
- Use
try/finally. My draw function has early returns. Leaking an interpolated coordinate back into the next simulation step desynchronises the game from itself, and the symptom appears far from the cause. - Restoration must be exact. I tested 5,000 interpolate/restore round trips for float drift. A restore that loses a fraction of a pixel each frame is a slow corruption of world state that no test will catch later.
- Snap, don't slide. A respawn or a recycled object moves a long way in one step. Interpolating across that draws it gliding through walls. Anything past a threshold gets drawn where it actually is.
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.