Hello, last weekend I got a bug report that consisted of a string, a number, and one sentence: “the spore guy hits his own side on tick 140-something.” I pasted the string and the number into a test, ran it, and watched the exact fight the reporter watched, down to the same crit on the same tick. The fix took ten minutes. The reason the fix took ten minutes is the subject of this post: every random thing in Mutarch, the world map, loot rolls, hatch outcomes, portrait scars, whole battles, flows from one seeded generator, and any moment of it can be reproduced from a seed plus a stream position.
Five lines of randomness
The generator lives in src/game/rng/index.ts and it is mulberry32, which is the whole
function:
next(): number {
this.state = (this.state + 0x6d2b79f5) >>> 0;
let t = Math.imul(this.state ^ (this.state >>> 15), this.state | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
Add a magic constant, stir with two rounds of imul mixing, divide by 2^32. The entire state
is one 32-bit integer. That last property is the one I actually chose it for: serializing the
generator means writing down one number, and restoring it means assigning one number. You can
eyeball it in a debugger. You can paste it into a bug report.
The alternative I rejected first was Math.random. To be fair to it: it is right there, it
costs nothing, and V8’s implementation has better statistical properties than mine. But it
cannot be seeded and its state cannot be read or written, so the question “a tester sends you
a broken fight, what do you do?” has no answer. Everything this post describes is impossible
on top of it. The other direction, a bigger PRNG like PCG or Mersenne Twister, buys
distribution quality I do not need at the cost of state I cannot eyeball (Mersenne Twister
carries about 2.5 KB of it). Mulberry32 is not good for cryptography, anything adversarial,
or serious statistical work, and its period of roughly 4 billion draws would be a problem in
a simulation that ran for months. A fight in Mutarch lasts a few hundred ticks. It will do.

100,000 draws from the function above, 20 bins. Flattest bin 4,823, tallest 5,166. A flat histogram proves almost nothing about PRNG quality, but it was fun to make.
Streams, not a stream
One generator is not enough, though, because consumption order couples everything to
everything. If worldgen and loot shared a stream, picking up one extra item would generate a
different world next time. So GameRng has a fork(label) method: a child stream is seeded
from a hash of the parent’s label, the child’s label, and one draw from the parent. Each
subsystem gets its own independent stream, and the labels form a readable path like
root/combat.
These are real labels from the code: worldgen forks terrain, climate, rain, and sites
off the world seed (that cascade is devlog #2, where the whole 448 by 288 map regenerates
from the seed every launch). Combat forks encounter. Route generation forks loot and
elites. The splice bench forks spliced. The payoff is isolation in both directions:
rolling more loot does not move the world, and regenerating the world does not move the loot.

The stream hierarchy. Everything on the sim side is a fork() of the world seed; the
portrait twin on the right deliberately is not, for reasons covered below. The wobbly lines
are jittered with mulberry32, which felt appropriate.
The second payoff is the bug report from the opening. Because a stream’s state is one
integer, a combat snapshot is {seed, rngState} via exportState, and restoreCombatRng
rebuilds the generator mid-fight from exactly that pair. The string and the number the tester
sent me were literally those two fields. How fights get recorded and played back is its own
post (next week, in fact), but the foundation is just this: two values, bit-identical replay.
The draw I can never insert
Now the part where I nearly burned myself, and the rule that came out of it.
Every champion in Mutarch shares its species’ portrait, so rivals get an accent recolor
seeded from their id to stay tellable apart. The hue family is picked from six authored
shifts, [-60, -32, 0, 22, 48, 95], and the pick is the first draw of the rival’s portrait
stream. Players learn their rivals by that color. “The orange one killed my matriarch twice”
is exactly the kind of memory the system exists to create.
Months in, I wanted a second identity axis: a tone ramp (vivid, dusky, deep, plus neutral), so six families times four ramps gives 24 spawn-visible looks instead of 6. The obvious implementation is one line: draw the ramp from the stream that is already right there in the function. I had the line written before I asked the question that mattered: drawn where in the order? Before the hue pick, and the first draw is no longer the hue pick, so every rival in every save silently changes color on update. After it, and the scar decals, which draw from the same stream next, all re-roll instead, so every scarred veteran gets a new face. The code would be correct, the tests of the day would pass, and the players’ mental map of their enemies would be quietly wiped either way.
The insight, which I should have had earlier: a stream’s draw order is a hidden API. It froze
the moment the first player learned a rival by its color, and nobody told the type system.
The actual fix is that the tone ramp gets its own stream, seeded from the same id with a
|tone suffix, so neither the hue nor the scars ever see an extra draw. And the rule is now
enforced the only way order can be: a test pins the hue family to the first draw of the
legacy stream, with a comment explaining to future me why it must stay there.
One related confession: the portrait code in src/ui/portraits/portraitCompose.ts does not
import GameRng at all. It carries its own FNV-1a hash (the classic 0x811c9dc5 and
0x01000193 constants) and its own copy of mulberry32. The duplication is deliberate. The
sim must not depend on UI code, the UI must not be able to advance a sim stream by rendering
something, and 15 duplicated lines are cheaper than either coupling.
What determinism buys
The running total of what this architecture pays for:
- Golden tests that pin an entire fight. The combat golden test runs a full demo battle and snapshots the outcome: ally win, 208 ticks, 904 total damage. Any engine change that moves any of it fails CI with a diff of exactly which number moved.
- Saves that store a seed instead of a map. The region map is never persisted; it is
rebuilt from
roster.seedon load, which is why a Mutarch save file is mostly a number (see #2). - Replay debugging. The ten-minute fix above. The reproduction arrives inside the report.
- Idempotent ids. Entity ids come from the stream (
idSegment) or fromcreateSequentialIdFactory, and run ids are derived asrun:{seed}:{clanId}:{expeditionId}, so re-deriving state never mints new identities.
Side note: you might ask whether this is overkill for a single-player game with no multiplayer and no leaderboards. My answer is that it is the opposite of overkill, it is the cheap option. Every golden test, every “paste the seed into a test” repro, and every regenerate-instead-of-persist decision rides on determinism. The alternative is writing serialization and bug-hunting machinery separately for each system, forever.
As always, let me know what you think.