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.
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.
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 rate | Time for that fall | Speed |
|---|---|---|
| 60 Hz | 0.42 s | 1× (correct) |
| 120 Hz | 0.21 s | 2× |
| 144 Hz | 0.17 s | 2.4× |
| 165 Hz | 0.15 s | 2.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.
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 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.
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.
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 VengeanceFree, runs in the browser, no sign-up. Keyboard on desktop or touch on a phone.