You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Loading the same city with the same explicit seed produces a different world on every run. seedRandom() is exposed to embedders (and bound in emscripten.cpp), but there is no call ordering from the outside that makes it stick: initWillStuff() unconditionally reseeds the RNG from gettimeofday(), and the map scans in doSimInit() consume that clock-seeded randomness — writing it into map tiles — before the caller regains control.
This also silently defeats generateSomeCity(int seed), whose whole point is to take a seed.
Environment
main @ 2894fe4
committed WASM artifacts in apps/micropolis/src/lib/ (no rebuild needed)
Node v22.23.2, macOS 14.2.1 arm64
Reproduction
I've attached repro.mjs.txt — self-contained, uses only the committed engine artifacts, nothing to build and no dependencies. Rename it to repro.mjs (GitHub won't accept a bare .mjs attachment, and the extension matters — it's what makes Node treat the script's top-level await as an ES module), drop it in the repo root, and run:
for i in 1 2 3 4; do node repro.mjs; done # digests DIFFER
for i in 1 2 3 4; do node repro.mjs --control; done # digests MATCH
It seeds with seedRandom(42), loads a city, and prints a SHA-256 digest of the whole tile map. Measured on main:
Command
Result
node repro.mjs ×6
6 distinct digests
node repro.mjs deadwood ×3
3 distinct digests
node repro.mjs --control ×4
4 × ddf9ecd4952ca093 (identical)
Two things about the script worth calling out, because they make the result hard to argue with:
The --control arm is the diagnostic half.generateMap(seed) seeds the same RNG through the sameseedRandom() and generates terrain without routing through initWillStuff() — and it is bit-identical across processes. So the PRNG itself is perfectly reproducible under wasm; the reseed is what breaks it.
The script builds one Micropolis per process deliberately (a second instance in the same module trips the uninitialized-callback bug, also #11), so the comparison is across processes.
Root cause
initWillStuff() — whose doc comment reads "Reset many game state variables" — opens by throwing away any seed the caller set:
All four callers of initWillStuff() follow it with doSimInit() in the same function, with no return to the caller in between:
Caller
micropolis.cpp:741
simInit() (reached from init())
fileio.cpp:386
loadFile() (reached from loadCity())
fileio.cpp:553
loadScenario()
generate.cpp:112
generateSomeCity()
doSimInit() (simulate.cpp:288) then runs mapScan(0, WORLD_W), pollutionTerrainLandValueScan(), crimeScan() and friends, which draw from the RNG and write the results straight into map tiles:
// zone.cpp:503
map[xx][yy] = HOUSE + BLBNCNBIT + getRandom(2) + value * 3;
// zone.cpp:677
map[x][y] = LHTHR + value + getRandom(2) + BLBNCNBIT;
So by the time loadCity() returns, tile contents already encode the wall-clock seed. Calling seedRandom() afterwards can't undo it — the divergence has already been baked into the map. That's why the repro diverges with zero ticks.
generateSomeCity(int seed) is affected in a more pointed way: generateMap(seed) correctly does seedRandom(seed) (generate.cpp:133), and then initWillStuff() on the very next line of the caller discards it before doSimInit() runs. The terrain honors the seed; everything derived from it in the same call does not.
Expected vs. actual
Expected: seedRandom(N) followed by loadCity(...) yields the same world every run, and generateSomeCity(N) is fully determined by N.
Actual: both diverge run to run. There is no ordering of the public API that produces a reproducible simulation.
Why it matters
Beyond reproducibility as a feature, this makes the engine hard to test: any test that ticks the simulation is inherently flaky, so a latent memory bug shows up on some runs and not others. That's exactly what happened with #11 — it presented as an intermittent crash, and pinning the seed was a prerequisite for making it debuggable at all. Deterministic seeding is what lets that class of bug be caught by a test instead of by luck.
Suggested fix
initWillStuff() reseeding the RNG isn't "resetting state," it's randomizing it, and each of the four callers already knows whether it wants fresh randomness. I'd move the randomlySeedRandom() call out to the call sites — keeping it on the fresh-init() path so a newly constructed Micropolis still behaves randomly by default, and dropping it from the loadCity/loadScenario/generateSomeCity paths, where the caller has either supplied a seed or is loading a fixed world.
That's about five lines across four files, changes no signatures, and fixes generateSomeCity() as a side effect. Embedders then get determinism from the obvious call sequence (init() → seedRandom(n) → loadCity(...)), and anyone who wants the old behavior can call the already-exposed randomlySeedRandom() explicitly.
Loading the same city with the same explicit seed produces a different world on every run.
seedRandom()is exposed to embedders (and bound inemscripten.cpp), but there is no call ordering from the outside that makes it stick:initWillStuff()unconditionally reseeds the RNG fromgettimeofday(), and the map scans indoSimInit()consume that clock-seeded randomness — writing it into map tiles — before the caller regains control.This also silently defeats
generateSomeCity(int seed), whose whole point is to take a seed.Environment
main@2894fe4apps/micropolis/src/lib/(no rebuild needed)Reproduction
I've attached repro.mjs.txt — self-contained, uses only the committed engine artifacts, nothing to build and no dependencies. Rename it to
repro.mjs(GitHub won't accept a bare.mjsattachment, and the extension matters — it's what makes Node treat the script's top-levelawaitas an ES module), drop it in the repo root, and run:It seeds with
seedRandom(42), loads a city, and prints a SHA-256 digest of the whole tile map. Measured onmain:node repro.mjs×6node repro.mjs deadwood×3node repro.mjs --control×4ddf9ecd4952ca093(identical)Two things about the script worth calling out, because they make the result hard to argue with:
It runs zero
simTick()calls. The divergence is already complete whenloadCity()returns, so this isn't about accumulated simulation drift — it's in the load path. It also means the script can't be confused with the sprite crash in the tick path (Intermittentmemory access out of boundsinsimTick()— unconstructedstd::stringinSimSprite#11).The
--controlarm is the diagnostic half.generateMap(seed)seeds the same RNG through the sameseedRandom()and generates terrain without routing throughinitWillStuff()— and it is bit-identical across processes. So the PRNG itself is perfectly reproducible under wasm; the reseed is what breaks it.The script builds one
Micropolisper process deliberately (a second instance in the same module trips the uninitialized-callbackbug, also #11), so the comparison is across processes.Root cause
initWillStuff()— whose doc comment reads "Reset many game state variables" — opens by throwing away any seed the caller set:All four callers of
initWillStuff()follow it withdoSimInit()in the same function, with no return to the caller in between:micropolis.cpp:741simInit()(reached frominit())fileio.cpp:386loadFile()(reached fromloadCity())fileio.cpp:553loadScenario()generate.cpp:112generateSomeCity()doSimInit()(simulate.cpp:288) then runsmapScan(0, WORLD_W),pollutionTerrainLandValueScan(),crimeScan()and friends, which draw from the RNG and write the results straight into map tiles:So by the time
loadCity()returns, tile contents already encode the wall-clock seed. CallingseedRandom()afterwards can't undo it — the divergence has already been baked into the map. That's why the repro diverges with zero ticks.generateSomeCity(int seed)is affected in a more pointed way:generateMap(seed)correctly doesseedRandom(seed)(generate.cpp:133), and theninitWillStuff()on the very next line of the caller discards it beforedoSimInit()runs. The terrain honors the seed; everything derived from it in the same call does not.Expected vs. actual
Expected:
seedRandom(N)followed byloadCity(...)yields the same world every run, andgenerateSomeCity(N)is fully determined byN.Actual: both diverge run to run. There is no ordering of the public API that produces a reproducible simulation.
Why it matters
Beyond reproducibility as a feature, this makes the engine hard to test: any test that ticks the simulation is inherently flaky, so a latent memory bug shows up on some runs and not others. That's exactly what happened with #11 — it presented as an intermittent crash, and pinning the seed was a prerequisite for making it debuggable at all. Deterministic seeding is what lets that class of bug be caught by a test instead of by luck.
Suggested fix
initWillStuff()reseeding the RNG isn't "resetting state," it's randomizing it, and each of the four callers already knows whether it wants fresh randomness. I'd move therandomlySeedRandom()call out to the call sites — keeping it on the fresh-init()path so a newly constructedMicropolisstill behaves randomly by default, and dropping it from theloadCity/loadScenario/generateSomeCitypaths, where the caller has either supplied a seed or is loading a fixed world.That's about five lines across four files, changes no signatures, and fixes
generateSomeCity()as a side effect. Embedders then get determinism from the obvious call sequence (init()→seedRandom(n)→loadCity(...)), and anyone who wants the old behavior can call the already-exposedrandomlySeedRandom()explicitly.I have this working locally and will open a PR.