Two columns of state hashes side by side with a highlighted first divergence at a single tick

Proving a game runs identically every time

Labs & Testing11 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

Written up later from the commit history. Dated to when the work landed.

Imagine losing a multiplayer match to a battle your opponent never fought. You watched your army break at the bridge; on their screen it never arrived. No error message appears, nothing reconnects, and both players carry on playing games that stopped being the same game several minutes ago.

A dwarf shield line and a goblin horde charging each other across a mountain pass at sunset, a bomb bursting between them

That is what a desync is — two machines that have stopped agreeing about the state of the world — and it is why this game has a check that runs before anything else.

The reason it can happen at all is the networking model. Multiplayer here is lockstep: rather than a server telling everyone what happened, every machine runs the entire match itself and only the players' button presses cross the network. It is what makes hundreds of units affordable, because the bandwidth depends on how fast you click rather than on how much exists. The price is that the simulation must be deterministic: identical inputs must produce a bit-for-bit identical world on every machine, every time, forever. There is no server to arbitrate and no correction step. If two machines ever compute one different number, the games drift apart and never come back together.

The thing that turns "I think the game is reproducible" into a pass or a fail is one small script.

Almost nothing is written about how to do this in JavaScript specifically, so this post is the method rather than a tour: the check itself, the deliberate bug that proves the check works, and the three language-level hazards that will break your game before any of your own logic does.

Play the same match twice and compare

The obvious approach is to run two matches side by side and compare them tick by tick. That is not possible here, because the shared random number generator and the counter that hands out identities are global to the program, so two live matches would interleave and corrupt each other.

So the check runs the same match twice, one after the other, taking a fingerprint of the world every fifty ticks and asserting the two sets of fingerprints are bit-for-bit identical. Three matches are defined, each one hard computer opponent against another with a fixed starting seed: dwarf against goblin, goblin against dwarf, and dwarf against dwarf. The order is reversed in the second deliberately, because which seat you sit in and which faction you play are separate variables, and a bug that lives in one of them should not be able to hide in the other.

Using computer opponents rather than a scripted list of commands is the decision that makes the coverage cheap. One hard match naturally exercises buildings, units, combat, projectiles, gathering, quarries, trade, income, wild monsters, mercenaries, heroes, items and magic, without a script anywhere that says "now cast a spell". Both sides start rich, at 8,000 gold, 5,000 lumber, 3,000 stone and 2,000 oil, so they genuinely build and train and fight. Instant construction is deliberately left off, because the point is to make real build and production timers accumulate.

A concept sheet of goblin buildings on aged paper: a scrap-plated workshop, a spiked barracks, a mushroom farm and a watchtower

The maps are not saved alongside the check either. They are regenerated from the same seed on both runs, which is itself part of what is being proved: two machines get identical terrain from one number, or nothing downstream of it can agree.

The same check for your own game, in forty lines

Two functions have to come from you. One creates a simulation from a seed and gives it a way to advance one step, and the other produces a fingerprint of its state. Everything else is the runner.

import { createSimulation, hashState } from './your-simulation.mjs';
 
const TICKS = 3000;
const SAMPLE_EVERY = 50;
 
function trace(seed) {
  const sim = createSimulation(seed);
  const samples = [];
  for (let tick = 1; tick <= TICKS; tick++) {
    sim.step();
    if (tick % SAMPLE_EVERY === 0) samples.push({ tick, hash: hashState(sim, tick) });
  }
  return samples;
}
 
function compare(a, b) {
  for (let i = 0; i < Math.min(a.length, b.length); i++) {
    if (a[i].tick !== b[i].tick) return `sample ${i}: tick ${a[i].tick} vs ${b[i].tick}`;
    if (a[i].hash !== b[i].hash) return `first divergence at tick ${a[i].tick}`;
  }
  // Two runs that matched all the way and then stopped at different ticks are
  // still not deterministic. This is the line most implementations forget.
  if (a.length !== b.length) return `traces agree but lengths differ (${a.length} vs ${b.length})`;
  return null;
}
 
let failed = 0;
for (const seed of [1337, 24601, 99]) {
  const problem = compare(trace(seed), trace(seed));
  if (problem) { failed++; console.error(`FAIL seed ${seed}: ${problem}`); }
  else console.log(`ok   seed ${seed}: ${TICKS / SAMPLE_EVERY} samples identical`);
}
process.exit(failed > 0 ? 1 : 0);

Two details in that are worth copying rather than reinventing. Running the match twice in sequence, rather than two at once, is not laziness: anything global, an identity counter or a shared random generator, means two live matches interfere. And sampling every fifty ticks rather than every tick keeps the record small, at the price that the reported tick is the first sample after the fault rather than the fault itself.

The fingerprint is yours to write, and its shape matters far more than which hashing algorithm you pick. Build one canonical piece of text and hash that. Three rules are buried in those few lines. Sort before you iterate, because the order things happen to sit in a container is not part of the game state. Do not reach for the standard JSON conversion, because its output depends on the order properties were added, so two objects with identical contents can produce different text. And do not round a number on its way in: trimming to a few decimal places makes the check tolerant of exactly the tiny drift it exists to catch, which is a very comfortable way to own a check that can never fail.

The fingerprint is the one the live game already uses

The comparison calls the same function the live game calls during multiplayer to detect a desync. In your project that is whatever your networking already sends to other players as a checksum.

That is the part that stops the check grading its own homework. A purpose-built comparison could be perfectly deterministic while the shipped one is not, and the check would pass anyway. Using the real one means a green run also proves that the live desync detector is itself reproducible. Stated portably: the offline check and the live detector must be the same function, and if you have no live detector yet, write the fingerprint first and have both call it. A second implementation is free to drift, and the day it drifts is the day your check starts agreeing with itself about a game that is not the one shipping.

What it covers is the tick, the state of the random generator, every player's resources and multipliers and sorted list of upgrades, and then every unit and building with a position and health, sorted by identity, carrying position, health, and movement, gathering, building and combat state where they have them. Alongside the main fingerprint it computes four smaller ones over the same state, for the generator, the players, the units and the buildings. Those are purely diagnostic. When a run diverges you get the first tick that disagreed and which of the four drifted, which is the difference between "something is wrong somewhere in a three thousand tick match" and "the units, at tick 1,450".

The deliberate bug is what makes it a proof rather than a ritual

The documentation next to the check carries a section most test documentation does not, and it is the best thing there. It tells you how to break it: temporarily make a damage value depend on the random number generator, run the check, and confirm it reports a failure with the first tick that disagreed. Then put the code back.

A check that has never been observed to fail is not evidence, it is a script that prints a word. Everything about this one could be quietly inert and the failure would be invisible. The sample interval could be skipping the interesting ticks. The fingerprint could be dominated by fields that never change. The second run could be accidentally reusing the first run's results. Running it once with a deliberate bug in place takes a few minutes and converts the whole thing from a habit into a measurement.

Do that to your own check before you trust it, and gate the bug behind a flag so undoing it is flipping a switch rather than an edit you might forget. Against the runner above it turns three passes into three failures, and every seed reports tick 50, the first sample, rather than tick 1 where the fault actually is. That is the sampling interval being honest about itself: narrow it when you are hunting a real divergence, and leave it wide when you only want to know whether one exists.

Three JavaScript hazards that break this before your own code does

The rest is language-specific and nastier than anything in your own logic, because all three produce a perfectly valid answer.

Some maths functions are allowed to differ between browsers. The standard pins down addition, multiplication, division and square root exactly, and deliberately does not pin down the trigonometric functions, exponentials, logarithms, cube root, the power operator, or the built-in function for the length of a right-angled triangle's hypotenuse. That last one is the trap, because it looks like arithmetic. Chrome's version uses a careful summation to avoid overflow and disagrees with the plain square-root form on 2,028 of the 6,561 small whole-number distances a grid game asks about, which is 30.9%. Write the square root form and you are safe; write the convenient one and two browsers can pick different targets.

Lower precision invents ties that did not exist. Narrowing an array of route costs is free memory and a broken game. Five diagonal steps and seven-and-a-bit straight steps come to genuinely different numbers at full precision and to exactly the same number at half of it. Two distinct routes become one, and the queue then picks whichever was added first, on every machine, for reasons that have nothing to do with the map. It runs the other way too: adding a diagonal step eight times equals eight times one diagonal step exactly at full precision and misses by one digit at lower precision. Either direction is a different route.

Sets and maps iterate in insertion order, and objects have no natural order at all. This is the one that hides longest, because iteration order is perfectly deterministic within one machine. Two players whose games joined the same three units to a group in a different sequence will walk that group in a different sequence forever. Neither has a bug. Pick a target by taking the first one you come to and they choose different units, permanently. There is nothing to sort by unless you put it there, which is the fix: never walk a set or a map to make a game decision. Copy it into an array, sort by a number or a name, then walk it. That is also why the fingerprint sorts before hashing.

Know exactly which property your green run proves

This check runs both matches inside one browser, so both sides share one maths library. Passing proves the game reproduces itself on one browser, which is the property that covers unseeded random generators, reading the clock into game state, unstable sorts and content-dependent iteration.

Agreement between different browsers is a separate property with a separate guarantee behind it: a set of exactly-specified replacements that nothing on the simulation path is allowed to bypass. Roughly 220 of those calls moved onto it, verified over 22.3 million samples with no differing decisions, and the trigonometry is ported from the same well-known reference implementations Chrome itself uses, so another browser keeps producing Chrome's answer rather than its own. That work landed inside the change that took the game from a hardcoded two players to eight.

The precision hazard has a resident example too. The pathfinder's cost arrays stay at full precision, and the code says why: they accumulate diagonal steps and speed-adjusted fractions, so narrowing them would halve the memory and split the match. If you have a pathfinder with a narrowed cost array in it, that is the first thing to look at. The rest of what happened to that code is in the pathfinding post.

Two more boundaries, both stated openly. The check does not simulate one machine receiving a different or extra command from another, which is guarded structurally instead, by every lookup returning its results in a sorted order and by the live detector during a match. And it runs only the ordinary computer opponents. The language-model commander is excluded on purpose, because it calls a network service and a model's reply is not reproducible, which is also why it is barred from multiplayer entirely.

The runner underneath it

Both this check and the performance benchmarks sit on a second way of starting the game with no window at all. It mirrors the real startup and runs every part of the game at the same priorities, except four that genuinely cannot work without a browser or would measure nothing at all: drawing, selection, commands and voice lines.

The important thing is that the exclusion list is data rather than a comment. It is an array of entries each carrying a kind and a written reason, and a test checks it. Prose in a comment cannot be checked. An array can, and the reason it had to be is that this list had once fallen roughly twenty parts behind with nothing reporting it.

The same pattern runs through all three pieces. Every guarantee here is paired with the thing that proves the guarantee is live: the deliberate bug for the check, the exclusion array for the runner, the shared fingerprint for the detector.

Questions

What does deterministic mean in a multiplayer strategy game?

Every player's machine runs the whole match itself and only button presses cross the network, so the same starting seed and the same commands must produce identical results everywhere. If two machines compute one different number, their games quietly become different games. Determinism is that guarantee, and it has to hold from the very first tick.

Why is Math.hypot a problem for a deterministic simulation?

The JavaScript standard allows each browser to approximate it its own way, so different browsers can return a different last digit. Chrome's version disagrees with the plain square root form on roughly 31 percent of small whole-number distances. One digit that flips a comparison changes a decision, and from there the whole match splits in two.

How do you know a determinism check actually works?

Make it fail on purpose. Put a call to the random number generator into something on the simulation path, such as combat damage, run the check, and confirm it reports a failure with the first tick that disagreed. A check that has never been seen to fail is not evidence that anything is correct.

Can you store path costs at lower precision to save memory?

Not in a game where machines have to agree. Lower precision rounds two genuinely different route costs onto the same number, and the queue then picks between them by the order they were added rather than by cost, so two machines that added them in a different sequence choose different routes across the same map. The memory saving is real and so is the split.

← All posts