A lobby slot list with eight seats filled, faction crests and team groupings visible

Going from two players to eight

Engine & Performance9 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

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

The ask was simple to state and nothing else about it was: eight players in a match, humans and computer opponents mixed, everybody for themselves or in teams, because a two player skirmish is not what this genre is for.

Skeletons and dire wolves pushing out of a dead wood into a sunlit grove where wood elf archers and a treeman meet them

The game was built for two, and the way it was built for two is the good part. The neutral monsters that guard the interesting corners of a map had to belong to a player, and they belonged to player number two. Player number two is also the first seat a third player would sit in.

So the first job was moving the monsters out of the way. Their owner is now the maximum player count, eight, so seats zero through seven are free for real players. Both numbers live in one place, and the second is defined in terms of the first, so the two can never drift apart. That is the general form of the fix: when one number has to sit outside the range of another, derive it rather than picking a value that happens to be outside today.

619 monsters that would have joined a real player's army

Changing that number fixes new maps and breaks every map already on disk, because a saved map records an owner for each thing on it, and those files all say "owner two". Loading one after the change would have handed every monster on it to the player sitting in that seat.

Saved maps carry a format version for exactly this reason, so old ones are upgraded as they are read. Without that upgrade, 93 of the shipped maps would put 619 monster-owned things onto a real player. That number was not a guess about what might break. It was counted before the change was made, which is why the upgrade exists at all, rather than being discovered by a player wondering why they started the match owning a dragon.

A mercenary camp at dusk with a stone idol and a glowing pool beside it, a bear, wolves and an ogre waiting in the treeline

That failure would not have been subtle. It is not intermittent, and it would have been obvious inside the first minute of the first affected match. It is also exactly the class of change that gets made confidently and shipped, because the thing it touches looks like a fixed constant rather than like data other files depend on.

"Not me" stops meaning "enemy"

With more than two players, everyone who is not you is no longer an enemy. Allied, hostile and "can I give this thing orders" became three separate questions with one place that answers them, and the rule the change set is precise: who owns a thing stays an exact comparison, and only hostility and command authority go through the alliance rules. Get that boundary wrong in the other direction and a teammate can spend your gold.

The rest of the multiplayer rules follow from having a real notion of a team. Winning became last team standing, so knocking a player out leaves their allies playing rather than ending the match. Friendly fire came out of melee, arrows, siege, spells and auras. Allies share vision. Trade routes may run to a teammate's marketplace or dock for double gold, and which building counts is checked by the simulation rather than by the interface, so a modified game cannot route trade into an enemy's building. When a player leaves, their forces pass to an allied player who is still in the match, but ownership itself never moves: their colour, their bank pays, their production queues.

The type that changed most quietly says which seat this machine is driving. It used to be "zero or one". Everything downstream of that had quietly assumed the player count.

The half that never gets asked for: arithmetic is allowed to be right twice

Every machine in a multiplayer match runs the entire game itself, and only button presses travel over the network. The map is not sent, it is regenerated from a shared random seed. The computer opponents are not sent either, they are simulated identically everywhere. For that to work, every machine must compute bit-for-bit identical results from the first tick, forever. There is no reconciliation step and no authoritative server to fall back on.

Computers store fractional numbers as floating point, which holds a fixed number of significant digits rather than an exact value — so a number is generally the nearest representable one to what you meant, and the last digit is where disagreements live. The standard governing that, IEEE 754, pins down addition, subtraction, multiplication, division and square root exactly: every conforming implementation must return the correctly rounded result, so those five give bit-identical answers in every browser on every processor.

It stops there. The JavaScript standard does not extend the guarantee to sine, cosine, tangent, the inverse trigonometric functions, exponentials, logarithms, the power operator, cube root, or Math.hypot, all of which each browser is explicitly free to approximate its own way. Chrome, Firefox and Safari use different algorithms and can disagree in the very last digit. One such disagreement flips a comparison, which changes a decision, which splits the match in two.

Roughly 220 of those calls were replaced with arithmetic the standard pins down, and the result was checked over 22.3 million samples with no differing decisions at all. The offender is the one that looks completely harmless: the standard library function for the length of a two-sided triangle. It uses a careful summation to avoid overflow on enormous inputs, which is exactly right for a general purpose numeric library and exactly wrong for a grid, because it comes out with a different last bit from the obvious square-root form.

Check your own browser in ten lines

This is the part worth taking away, because it is easy to miss and takes seconds to check. Paste this into your browser's console, on whichever browser you actually ship to.

let differ = 0, total = 0, worst = 0;
for (let dx = -40; dx <= 40; dx++) {
  for (let dy = -40; dy <= 40; dy++) {
    const a = Math.hypot(dx, dy);
    const b = Math.sqrt(dx * dx + dy * dy);   // mathematically identical
    total++;
    if (a !== b) { differ++; worst = Math.max(worst, Math.abs(a - b)); }
  }
}
console.log(`${differ} of ${total} differ (${(differ / total * 100).toFixed(1)}%), worst ${worst}`);

In Chrome that prints 2028 of 6561 differ (30.9%), worst 7.105427357601002e-15, and this game's own test reports the same figures against the real code. Nearly a third of the distances a unit works out about another unit come out differently depending on which of two mathematically identical expressions I happened to write. The size of the gap is irrelevant. What matters is that it is not zero, because a comparison downstream turns a gap of seven quadrillionths into a different decision, and a different decision is a different game.

Then find every one of them, which is a single search across your source for the function names above plus the power operator. Everything that search finds is left to the browser by the standard. Everything it does not find, meaning the four arithmetic operations, square root, absolute value, minimum, maximum, floor, ceiling, round and truncate, is exactly specified and safe to keep. The power operator is noisy to search for, because it also matches every documentation comment, so read those hits rather than counting them. If your game runs the same simulation on several machines, or verifies replays, or compares a hash of its own state, that search is the whole audit.

The trigonometry could not simply be deleted, because the computer opponents genuinely need angles to place buildings in rings and sweep a perimeter. Those were ported from the same well-known reference implementations Chrome itself uses, so on a different browser they keep producing Chrome's answer rather than that browser's. A test checks it directly, asserting that a sixteen-way placement ring at radii from 6 to 28, and a sixty degree perimeter sweep, pick identical tiles, with a worst error across the range in use of about one ten-quadrillionth.

Know what each check actually proves

The verification list for this work is specific: types clean, the replay check passing on three scenarios at 3,000 ticks each, movement 31 of 31, alliances 30 of 30, map loading 82 of 82, and the arithmetic 16 of 16, since grown to 23.

The interesting part is written at the top of the arithmetic itself, in a note that is unusually direct about the limits of the project's own flagship test: replaying a match twice cannot catch a cross-browser difference, because both replays run inside the same browser and therefore share the same maths library. Passing proves the game reproduces itself on one browser and says nothing at all about two people on different browsers playing together.

That is correct. Agreement across browsers rests on how the code is built rather than on a test: exactly specified arithmetic everywhere it can be, and one browser's own algorithms ported where it cannot. Knowing which of those two the guarantee comes from is the difference between a test that fails when it should and an argument that merely sounds right.

What eight seats cost besides seats

Eight players cost memory, and the same work had to pay for it, because eight seats means up to eight factions' worth of art loaded at once. Decoded music was holding about a gigabyte across a long match and now gets released. Model textures and animation data got the same treatment. Sprite sheets shrink according to how many distinct factions are actually in the match rather than by a fixed rule, so four factions cost less than eight. And a smaller texture size cut the graphics memory each model needs by 62%.

Those are as load-bearing for eight players as the seat count itself. A lobby that seats eight and then runs out of memory at the third faction has not shipped eight players, it has shipped a longer list, and that distinction is the same one the sprite memory work had to draw: the size of the art library is not the constraint, the amount held at once is.

What the seat count changed about everything else

Eight seats is why several other parts of this project look the way they do. The stress maps that found the computer opponents owning 90% of the work exist because eight opponents is where the cost stops growing in any obvious way. The map list caps its seats to the number of starting positions each map really has, so a small map seats six and everything larger seats eight, which you can read straight off the map list before you pick one. And the bit-for-bit agreement this all rests on is why a pathfinding rewrite had to be checked against the old one directly rather than by running the game twice and looking at it.

Questions

How many players can play at once?

Up to eight, humans and computer opponents freely mixed, either everybody for themselves or in teams. The map limits the count to the number of starting positions it genuinely has, so a small map seats six and a medium, large or huge map seats a full eight.

What is a desync and why does it end a match?

Every player's machine runs the whole match itself, and only the button presses travel over the network. If two machines ever compute a different answer from the same inputs, their games drift apart and never come back together, so one player watches a battle the other never had. It is a correctness failure rather than a slow frame, which is why it is treated as fatal.

Why avoid Math.hypot in a game simulation?

Because the JavaScript standard allows each browser to approximate it differently. Addition, multiplication, division and square root are pinned down exactly and give the same answer everywhere, but hypot, the trigonometric functions and the power operator are not. Chrome's hypot disagrees with the square root form on about 31 percent of small whole-number distances.

Does adding six more players cost six times the memory?

Close to it, unless the art is bounded. Eight seats means up to eight factions loaded at once, so the same work added eviction for decoded music, model textures and animation data, and cut the graphics memory each model needs by 62 percent with a smaller texture size.

← All posts