← Robostrat

Two engines, one set of rules

Keeping a TypeScript game and its GPU training copy identical, down to the rounding, and why the AI in the browser became a third copy with the same discipline.

Robostrat log · September 2026

Why there are two

The game you play is written in TypeScript. The browser needs units and tiles as objects, instant hover previews and animations, so the rules live there, and that engine is the authority on what the rules are.

Training needs something else entirely: thousands of games at once, as batches of tensors on a GPU, with no per-unit objects and no Python loops. So there is a second engine, a vectorised PyTorch mirror of the same rules. Running everything through one Python engine was considered and rejected. A round trip to a server per hover ruins the interface, Python compiled to WebAssembly is a heavy download, and an object-style Python engine would be too slow to train on, so there would still be two engines to keep in step.

Two engines means one question that never goes away: do they agree?

The box that was ticked

Early on, every item in the parity plan was marked done. Then the comparison test was run at real volume for the first time, and agreement was 97.6%: attacks agreed 60 to 87% of the time and ending a turn about 80%. The two most important categories were nowhere near passing. The rule that came out of it is short: a parity claim is a test result at volume, or it is not a claim.

Differential fuzzing

The test generates random reachable positions in the TypeScript engine, picks a random legal action, applies it in both engines and compares the resulting state, all 26,148 numbers of it. The bar is 100.00% on every action category, with no tolerated residual of any kind.

python train/test_sync.py 20000              # every category at 100.00%
python train/test_sync.py --type Attack 20000  # after any combat change

The second line exists because uniform sampling is misleading. Most legal actions in a typical position are purchases and moves, so a clean run of 20,000 cases tests an attack only about 30 times. Rare families get runs of their own.

What it taught

A tolerated residual is a hiding place

For months, fuzzing topped out at about 99.9%, and the gap was accepted as an artefact of how the two engines ordered captures. The TypeScript engine checked for encircled tiles after every capture, and only for the capturing player. The PyTorch engine checked once per step, for both sides. When a rules change moved encirclement to the end of the turn in both engines, the gap closed to exactly 100.00%. The residual had been a real difference all along.

The GPU rounds differently

Damage used to be a base value scaled by health out of 100 and rounded, which lands exactly on a .5 for every odd health value. On the GPU, dividing a tensor by a Python float compiles to a multiplication by the reciprocal, which rounds twice. At 57 health the training engine dealt 29 damage where the game dealt 28, and for weeks that too was filed as a tolerated rounding residual. What removed the whole class in the end was a rules change made for readability: health became a scale of 8, and damage became exact integer arithmetic,

damage = floor((health + bonus_halves + 1) / 2)

with every stored value a power-of-two fraction. There is no float division left anywhere in the rules, so there is nothing left to round.

Both engines can agree and both be wrong

When the price of repairing a unit became a flat fee, the code that charged for a repair was updated, and the code that decided whether a repair was allowed was not, in either engine. A damaged unit with enough money for the real price was refused, and a nearly healthy unit was offered a repair that silently did nothing. The fuzzer passed the whole time, because the two engines made the identical omission. Parity proves agreement, not correctness, so the rules that matter also have scenario tests that assert the rule itself on hand-built positions.

A test can pass for the wrong reason

Spawning a unit in a test also captures the tile it stands on, so a test asserting that a unit captured its tile passes whether or not capture works. The house rule now: set the tile to neutral first, and break the fix once on purpose to see the test fail.

The third copy

Putting the AI in the browser created another consumer of the rules: the TypeScript engine encodes a position, an ONNX export of the network runs in WebAssembly, and TypeScript decodes the chosen action. That is exactly the kind of path that drifts silently, so it got the same treatment before the page was allowed to say it plays the champion:

The general point

Whenever the same logic lives in two places for good reasons, agreement is a property you measure, not one you assume, and "close enough" is where the bugs live. Measure at volume, test the rare cases on purpose, assert the rule and not just the agreement, and make every test prove it can fail.