A browser game draws a frame whenever the screen refreshes. On an office monitor that is 60 times a second, on a gaming laptop 144 or 165, on a new phone 120, and on a laptop running on battery it can drop to 30. If the game moves things by "speed times the time since the last frame", it quietly becomes a different game on every screen: jumps reach other heights, enemies arrive at other moments and collisions are missed when a frame takes too long. This guide shows the loop we use in our 3D browser games, why each line is there, and the automated test that proves the rules play the same at 60, 144 and 165 Hz.
#Why a variable time step breaks a game
The browser calls the function you pass to requestAnimationFrame once per screen refresh and hands it a timestamp. The obvious loop measures the time since the last call and moves everything by that amount. It looks smooth, and on a fast machine it even looks better. The trouble is in the rules, not in the picture.
- Integration error depends on the step. With the usual "add velocity, then add gravity" update, a jump computed in sixty steps a second ends at a slightly different height than the same jump in 144 steps. Small differences add up over a round, and a level that was fair on your monitor becomes impossible or trivial on someone else's.
- Long frames skip collisions. When a frame takes a tenth of a second, a fast object moves ten times further in one go and can pass through a thin wall or step past the edge check that should have stopped it.
- Replays, bots and tests stop working. If the same inputs do not give the same result, you cannot replay a run, compare two versions of the balance or let a bot play a thousand rounds and trust the numbers.
The fix is old and well known in game programming: run the rules in fixed steps and let the drawing happen as often as the screen wants.
#The loop with an accumulator
In our arena game Faultwarden the rules live in a pure simulation that knows nothing about the screen. It always advances by exactly one sixtieth of a second. The frame loop collects real time in an accumulator and spends it in whole steps:
export const DT = 1 / 60;
let last = performance.now();
let acc = 0;
function frame(now) {
requestAnimationFrame(frame);
// never more than 0.1 s per frame: a slow frame or a stalled tab
// must not turn into hundreds of catch-up steps
const real = Math.min(0.1, (now - last) / 1000);
last = now;
acc += real * settings.speed;
const input = gatherInput();
let first = true;
while (acc >= DT) {
// one-shot presses (strike, leap) count only on the first step
sim.step(first ? input : { ...input, strike: false, leap: false });
first = false;
acc -= DT;
}
const alpha = acc / DT; // how far we are between two steps
render(sim, alpha);
}
requestAnimationFrame(frame);
On a 60 Hz screen the loop usually runs one step per frame. On a 144 Hz screen most frames run no step at all and some run one; on a 30 Hz screen every frame runs two. The rules see the same sequence of sixtieth-of-a-second steps everywhere.
Two details matter more than they look. The first is the clamp on the frame time. Without it, a tab that was hidden for a minute comes back with sixty seconds in the accumulator, runs 3,600 steps in one frame and freezes, or worse, plays a minute of the game without the player. The second is the treatment of one-shot inputs. A key press is an event, not a state: if a frame runs two steps and both see "strike pressed", the player strikes twice. Held inputs such as movement go to every step; presses go to the first one only.
#Smooth pictures between the steps
With fixed steps, a 144 Hz screen would show the same position on two or three frames in a row, and motion looks like it stutters. The remainder in the accumulator says how far the real time has moved past the last step, as a fraction from 0 to 1. The renderer keeps the previous and the current position of every moving object and draws a blend of them:
function lerp(a, b, t) {
return a + (b - a) * t;
}
// in render(sim, alpha), for every actor
mesh.position.set(
lerp(actor.prevX, actor.x, alpha),
lerp(actor.prevY, actor.y, alpha),
lerp(actor.prevZ, actor.z, alpha),
);
The picture is at most one step behind the rules, about 17 milliseconds, which nobody feels. Cameras, particles and other things that do not affect the rules can still move with the real frame time; only the simulation needs fixed steps.
#Pause when the player leaves
Browsers slow down or stop requestAnimationFrame in a hidden tab, and a game embedded in a page loses focus whenever the player clicks outside it. Both should pause the game rather than let it run unattended or jump ahead on return:
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (mode === 'play') pause();
audio.ctx?.suspend();
} else if (audio.ctx) {
audio.ctx.resume();
}
});
addEventListener('blur', () => {
if (mode === 'play') pause();
});
When the game is paused, reset the accumulator to zero as well. Otherwise a little leftover time from before the pause turns into a step the moment play resumes.
#Keep the rules deterministic
Fixed steps are half of the work. The other half is making sure that nothing inside the rules depends on anything other than the inputs and the starting state.
- Use a seeded random generator.
Math.random()cannot be seeded. Our rules use a small mulberry32 generator with a seed per round, so the same seed builds the same island and spawns the same enemies. The renderer may useMath.random()freely for sparks and dust, because nothing in the rules reads it. - Never read the clock inside a step. Timers in the rules count steps or add
DT; they never callperformance.now()orDate.now(). - Keep the order stable. Iterate over arrays, not over objects whose key order you did not control, and do not sort with a comparison that can return the same value for different items when the order matters.
#Prove it with a test
A claim like "the physics does not depend on the refresh rate" is easy to break with one innocent change, so we test it. The test drives the simulation the way a real frame loop at 60, 144 and 165 Hz would, with the same steering input, and compares the results:
it('physics does not depend on the refresh rate', () => {
const run = (hz) => {
const sim = makeSim({ isle: 0, seed: 3 });
let acc = 0;
for (let f = 0; f < 20 * hz; f++) {
acc += 1 / hz;
while (acc >= DT - 1e-9) {
const t = f / hz;
sim.step({ ...NO_INPUT, mx: t % 3.3 < 1.65 ? 0.6 : -0.6 });
acc -= DT;
}
}
return sim;
};
const a = run(60), b = run(144), c = run(165);
for (const s of [b, c]) {
expect(Math.abs(s.time - a.time)).toBeLessThan(DT * 1.01);
expect(s.enemies.length).toBe(a.enemies.length);
expect(Math.abs(s.heartHp - a.heartHp)).toBeLessThanOrEqual(1);
}
});
The small tolerance in the loop condition is there because adding 1/144 in floating point does not land exactly on multiples of 1/60. A second test lets our playtest bots play full rounds at 60 and 144 Hz and checks that they clear the same number of waves, give or take one, because the bots themselves react on a timer and may press a button one step later. Both tests run with the rest of the suite on every change, so a timer that counts frames instead of steps cannot slip in unnoticed.
#A speed setting for free
Once the rules run in fixed steps, a slower game speed is one multiplication: the loop adds real * settings.speed to the accumulator. At 0.7 the rules run 70 percent as fast, every timing window grows, and nothing else changes. Players who need more time to react get it without a separate easy mode, and it belongs in the accessibility settings next to remappable controls. Our guide to keyboard and gamepad controls in browser games covers that part.
#When you do not need it
A turn-based puzzle, a card game or a menu-driven idle game has no physics to drift, and a plain loop is fine. You need fixed steps as soon as anything moves under rules: platformers, arena games, racing, anything with projectiles, and anything you want to replay, test with bots or share as a daily challenge with the same seed for everyone. For the bigger picture of building with Three.js, see browser games with Three.js and WebGL, and for what makes the moment-to-moment play satisfying, making a browser game feel good. You can find our published and upcoming browser games in the PN Scripts games catalogue.
#Frequently asked questions
Should the fixed step be 1/60 or 1/120 of a second?
Sixty steps a second is enough for most browser games and keeps the cost low on weak laptops and phones. Use 120 only when fast objects need finer collision checks and you have measured that the simulation stays well under a few milliseconds per step.
Why clamp the frame time to 0.1 seconds?
After a hidden tab, a debugger pause or a long garbage collection, the measured time can be seconds or minutes. The clamp limits catch-up to six steps per frame, so the game slows down for a moment instead of freezing or skipping ahead.
Does interpolation add input lag?
It shows the world up to one step behind the rules, about 17 milliseconds at 60 steps a second. Input is still read every frame and applied on the next step, so the delay is too small to feel.
Can I use Math.random() in the simulation?
Not if you want replays, daily seeds or reliable bot tests. Use a small seeded generator for everything the rules decide and keep Math.random() for visual effects only.
Comments
Comments
Be the first to leave a comment.
Leave a comment