ArenaSim is a match simulator: you build a squad, the engine plays the match, a result comes out.
The core requirement: a match played with the same seed must produce the same result anywhere. A match generated on the server, replayed in the browser, must be bit for bit identical. Otherwise there is no “watch from the archive”, no verification, no fairness.
I wrote a verification tool: compare the server’s match with the browser’s and go red on any difference.
It went red.
The first suspects
- Is the seed passed correctly? Yes, the same number on both sides.
- Is the input identical? Yes, same squad, same parameters.
- Is the event order drifting? No — even the first event differed.
The third was an important clue: the divergence was not accumulating, it was there from the start.
The culprit
One place in the code still used the language’s built-in random function, in a tie-break. Small, innocent, overlooked.
I wired it to the seeded generator — and it still did not match.
“The same seed gives the same sequence” is incomplete. Correctly: the same seed gives the same sequence within the same algorithm.
The server language’s built-in generator and the browser’s are different algorithms. With the same seed they produce completely different sequences. Both are working “correctly”.
The engine carries its own mathematics
The fix was to stop trusting what the language provides. The engine now carries its own generator — written line for line identically on both sides, a 32-bit xorshift:
function next(s) {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return s;
}
Three shifts, three XORs. Not cryptographic — it does not need to be. The only requirement is that it is the same everywhere.
The >>> 0 expressions are critical: they pin the result to an unsigned 32-bit value after each step. Without them the overflow behaviour can differ between platforms, and that is exactly where the divergence enters.
Three other things that break determinism
1. Unstable sorting
When two items compare equal, which comes first was not guaranteed. The fix: add a secondary key to every comparison. On a tie it falls back to the identifier, making the order fully determined.
2. Floating-point equality
The difference is of order 10⁻¹⁶. But feed that difference into a threshold comparison and the two sides take different branches, and the match diverges completely from there.
The fix was two-part: the summation order was fixed, and threshold comparisons use a tolerance.
3. Object identity
In one place objects were held in a map and iterated. The order depended on insertion order, which depended on the order network requests returned.
Nothing from the environment may enter the computation: memory addresses, object identity, clocks, insertion order.
Without the verification tool
I would not have found any of these by hand. Both matches looked plausible. Different scores, different events — both entirely believable.
Without the verifier I would have learned about the difference only when a user said “the match I watched was different from the one I played”. And at that point finding the cause would have been impossible.
Determinism is not an intention, it is a constraint. And it only exists if it is checked automatically on every build.
What I learned
If a system says “it must behave identically everywhere”, most of the conveniences a language offers become unusable. Randomness, sorting, floating point, collection order — all environment-dependent, and none of them tells you.
Carrying your own mathematics looks like extra work. The alternative is never being able to learn why a platform behaved differently.
The same constraint appeared in different clothes in the light show engine — and became the shared rule of all my simulations.