⚔ Dev Notes

My game ran at double speed on most modern phones

7 October 2026

I shipped a browser action platformer with a bug that made it unplayable for a large share of the people who tried it, and I had no idea for weeks. Posting it because the failure mode is invisible if you only test on one device, and because the thing that finally exposed it was a rejection letter that never mentioned it.

The bug

My game loop looked like this, which is the shape almost every browser game starts with:

function loop(){
  draw();
  update();
  requestAnimationFrame(loop);
}

update() runs once per animation frame. Inside it, every constant was a per-frame value — gravity 0.57, jump velocity -13.5, friction 0.81, every cooldown, every invincibility window, every timer counted down one per call.

On a 60Hz display that is all perfectly correct, and 60Hz was the only kind of display I ever tested on.

requestAnimationFrame fires at the display's refresh rate. Most phones shipping today are 120Hz — iPhone Pro models, most Android flagships. Gaming monitors are 144Hz or 165Hz. On those, update() is called twice as often, or more, and every one of those constants is applied twice as often.

The game runs at double speed. Not "feels a bit fast" — double. Jump arcs are wrong, so gaps you tuned become unclearable. Enemy attack timing is wrong. The parry window is half as long in real time as you designed it.

Measuring it

After the fact I measured how many update() calls it took for the player to fall 200 pixels. The answer is a constant — 25 — because the physics has no idea time exists. That constant means something completely different depending on the display:

Refresh rateTime for that fallSpeed
60 Hz0.42 s1× (correct)
120 Hz0.21 s2×
144 Hz0.17 s2.4×
165 Hz0.15 s2.75×

You can check your own game in about a minute. Freeze the player, run update() in a loop a fixed number of times, and see how far they moved. If the distance only depends on the number of calls and not on elapsed time, your physics is tied to the refresh rate.

A quicker smoke test: open your game on a 120Hz phone next to a 60Hz one. If it looks faster on the newer phone, you have this.

How I found it

I didn't. A game portal rejected the submission with one line:

The overall quality of the game does not yet meet the expectations of our platform.

No specifics. The natural reading of that is "your game isn't good enough," which is not actionable and sent me straight toward blaming the art.

Instead I went through their published requirements line by line looking for something concrete I had broken. One of them read: "Game mechanics perform identically on 60Hz, 144Hz and 165Hz monitors." That was the entire clue.

Their QA reviewer was almost certainly on a gaming monitor, and launched a game running at two and a half times speed. From their side that is a quality problem. It just wasn't the one I would ever have guessed.

The fix

The usual advice is to multiply everything by delta time. That works, but it means touching every tuned constant in your game and re-balancing all of them, which for me would have meant re-tuning combat I was happy with.

A fixed timestep accumulator avoids that entirely. You keep update() meaning exactly 1/60th of a second, and call it the correct number of times per frame instead:

const STEP = 1000 / 60;
let acc = 0, prev = null;

function loop(ts){
  draw();

  if (prev === null) prev = ts;
  let dt = ts - prev;
  prev = ts;

  if (dt > 250) dt = 250;   // tab was backgrounded
  acc += dt;

  let steps = 0;
  while (acc >= STEP && steps < 5){
    update();
    acc -= STEP;
    steps++;
  }
  if (steps >= 5) acc = 0;  // can't keep up — drop the backlog

  requestAnimationFrame(loop);
}

Not one tuned constant had to change. Gravity is still 0.57, the jump is still -13.5, every cooldown still counts in frames — because a frame still means 1/60s. Rendering continues to run once per animation frame, so a 144Hz display still renders at 144Hz. Only the simulation is pinned.

It also fixes the opposite problem. On a slow device managing 40fps, the accumulator runs update() twice on some frames and the game runs at the correct speed instead of in slow motion.

Three things that will bite you

Clamp the delta. A backgrounded tab returns a delta of many seconds. Without the clamp, the loop tries to simulate all of it at once and locks the page.

Cap the catch-up steps. If a device genuinely cannot keep up, an uncapped while loop accrues a debt it will never repay and spirals. Five steps then drop the backlog.

Check prev against null, not falsiness. A timestamp of 0 is legitimate on the first frame, and if (!prev) quietly mishandles it.

What I'd do differently

Test on a high-refresh device before shipping anything. I develop on one phone and one laptop, both 60Hz, and that blind spot shipped to everyone.

And when a vague rejection arrives, don't assume it means the thing you're already insecure about. I was ready to rebuild my art. It was a one-line bug in the game loop.

Play Samurai Vengeance

Free, runs in the browser, no sign-up. Keyboard on desktop or touch on a phone.