Deterministic browser games: same inputs, same game
A deterministic game gives the same result every time you feed it the same inputs. That one property buys you replays, bug reports that reproduce, fast logic tests, fuzzing and lockstep multiplayer. In JavaScript it breaks quietly, through Math.random(), Date.now(), browser timers and a frame delta that changes with the monitor. The fix is a fixed-step loop and a seeded generator, and you can check for the usual causes before running anything with game-lint.
| What breaks it | What to use instead |
|---|---|
Math.random(), crypto.randomUUID() | a seeded PRNG, with the seed kept next to the recording |
Date.now(), performance.now() in logic | the game's own clock: ticks or simulated seconds |
setTimeout, setInterval, requestAnimationFrame timing in logic | engine timers, or counting steps |
the frame's variable dt | a fixed timestep with an accumulator |
| assets or network responses arriving in a different order | wait for loading to finish before the simulation starts |
| iteration over objects whose keys change shape (rare) | iterate arrays |
| floats on different CPUs and browsers (cross-machine only) | the same build everywhere, or integer/fixed-point maths for lockstep |
What determinism buys you
- Replays. If the simulation is a pure function of seed plus inputs, a replay file is just those two things, a few kilobytes in size.
- Bug reports that reproduce. "I fell through the floor near the second door" becomes a file. Anyone can play it back and land on the same frame.
- Logic tests. A test can press keys, wait a number of steps and assert on state, with no browser and no flaky timing.
- Fuzzing. Random input finds the crashes and soft-locks players hit. With seeded input, every finding comes with a run you can repeat.
- Lockstep multiplayer. Peers send only inputs, and every machine simulates the same game. This one asks the most, because it has to hold across machines, not just across runs.
What breaks it in a JavaScript game
Randomness without a seed
Math.random() can't be seeded, so two runs with identical inputs get different numbers. Every spawn position, crit roll and AI choice that reads it makes a replay drift. Engine helpers often wrap it, so check what yours calls underneath.
Reading the real clock
A cooldown written as Date.now() - lastShot > 300 depends on how fast the machine is and whether the tab was in the background. Play it back faster than real time, or pause on a breakpoint, and the cooldown ends on a different step.
Browser timers in game logic
setTimeout(spawnBoss, 5000) fires on real time, outside your loop. A paused game keeps running it, and a replay that steps faster than real time sees it on the wrong frame. requestAnimationFrame is fine for driving the loop, but logic that counts its callbacks depends on the display's refresh rate.
A variable frame delta
x += speed * dt with the real dt gives a slightly different path at 60 Hz and 144 Hz, and a different one again after a hitch. Collisions are where it shows: a fast object that skips over a thin wall on a long frame and hits it on a short one.
Async load order
If an asset or a fetch finishes in a different order and the game reads it mid-simulation, two runs diverge before the player touches anything. Load first, then start the clock.
Iteration order, rarely
JavaScript object keys keep insertion order (integer-like keys come first, ascending), so this mostly behaves. It bites when the order of insertion itself depends on something nondeterministic, like a Set filled from async callbacks.
Floats across machines
On one machine and one build, the same float operations give the same bits. Across browsers and CPUs, Math.sin, Math.exp and friends aren't required to return identical results, so lockstep games that must agree bit for bit across players often use integer or fixed-point maths for the simulation. Replays and tests on your own machine don't need this.
How to keep it
1. A fixed timestep with an accumulator
Run the simulation in exact steps and let rendering interpolate between them. Real elapsed time only decides how many steps to run:
const STEP = 1 / 60;
const MAX_FRAME = 0.25; // don't spiral after a tab switch
let acc = 0;
let last = performance.now(); // the loop may read real time; the simulation never does
function frame(now: number) {
acc += Math.min((now - last) / 1000, MAX_FRAME);
last = now;
while (acc >= STEP) {
update(STEP); // always the same dt
acc -= STEP;
}
render(acc / STEP); // alpha for interpolation
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Glenn Fiedler's Fix Your Timestep! is the classic write-up of this loop.
2. A seeded generator, kept with the recording
mulberry32 is a few lines and good enough for gameplay:
function mulberry32(seed: number) {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const seed = 1234; // save this with the replay
const random = mulberry32(seed);Seed it once per session and store the seed with the inputs. If you need to rewind, save the generator's state in your snapshots too. Phaser has one built in: Phaser.Math.RND is a seeded RandomDataGenerator, reseeded with Phaser.Math.RND.sow([seed]).
3. The game's clock and the engine's timers
Measure cooldowns and spawns in steps or in simulated seconds that only advance inside update. Use your engine's timers when they run off the game loop, and count steps when they don't.
4. Constant delta in tests
Drive the game in tests by calling update(STEP) yourself, never by waiting on wall-clock time. A test that runs 600 steps then simulates 10 seconds of play, however long the CPU takes.
How to check it
Lint first
game-lint is a plugin for oxlint and ESLint that catches the common causes in the editor, with any engine. Its rules are no-unseeded-random, no-wall-clock, no-real-timers and no-alloc-in-hot-path. The last one isn't about determinism: it flags objects, arrays and closures created for every item in a loop, every frame, which feed the garbage collector and cause hitches. Point the rules at your simulation code. Boot code and networking may read the clock for good reasons.
We ran it with oxlint 1.90.0 and game-lint 0.1.0 over overbuffed, our open-source bullet-heaven game. The three determinism rules found nothing, since the game already uses a seeded generator and a fixed step. no-alloc-in-hot-path reported three warnings, two in the title screen's render() and this one in the main run loop:
src/scenes/run.ts:390:31: warning game(no-alloc-in-hot-path): a closure inside a loop in step()
allocates for every item, every frame, ...for (const e of enemies)
grid.near(e.x, e.y, 16, (o) => { /* push o away from e */ });That's a new closure for every enemy, every step.
A linter only sees the calls it knows. An engine helper that calls Math.random() inside a library won't show up, which is what the next check is for.
Then replay twice and compare
Run the same seed and inputs twice from a fresh start, and compare game state after every step. The first step where they differ tells you where to look. Running the second pass with a different Math.random() seed and a different Date.now() exposes anything that reads them, even through a library.
With debuff
debuff builds this in. @debuffjp/core runs logic on a FixedClock (1/60 s steps by default) and gives every game a seeded game.random, a mulberry32 Random whose state you can save and restore. On top of that:
- Keiko's
test.deterministicplays a script twice on fresh games, with a different seed (forgame.randomandMath.random()) and a different date on the second run, and reports the first tick and field that diverged. Keiko also runs on games from other engines. debuff fuzzplays the game headlessly with seeded random input and saves every crash, stuck state or loop it finds as a scenario file that replays the run. See Testing games with replays and fuzzing.- The dev panel records your session, and its rewind slider replays the inputs to the tick you choose, then pauses.
Each of these works only because the game is deterministic, which is why debuff starts every project that way.
What this doesn't cover
Physics engines with their own internal timing and Web Workers each have their own ways to drift, and cross-machine lockstep needs more than this post (input delay, rollback and desync detection among them). The checks here make a game reproducible on one machine and one build, which is what replays, tests and fuzzing need.
Try it
npm install --save-dev game-lintThen point it at your simulation code with the config in the game-lint README.