`seedRandom()` cannot make the simulation reproducible — `initWillStuff()` reseeds from the wall clock
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 42/100
Research direction
Read initWillStuff() in initialize.cpp and its callers in micropolis.cpp, fileio.cpp, and generate.cpp, then inspect doSimInit() in simulate.cpp to confirm when RNG state is consumed. Use the supplied repro.mjs after renaming it and run the repeated Node commands. Done means seeded loadCity() and generateSomeCity() produce identical tile-map digests across runs while fresh init() retains random behavior.
Written by the indexing model from the issue text.
Description
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:
-
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 (#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 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:
// initialize.cpp:85
void Micropolis::initWillStuff()
{
randomlySeedRandom(); // <-- clobbers any caller-supplied seed
initGraphMax();
destroyAllSprites();
...
// random.cpp:165
void Micropolis::randomlySeedRandom()
{
struct timeval time;
gettimeofday(&time, NULL);
seedRandom(time.tv_usec ^ time.tv_sec);
}
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.
I have this working locally and will open a PR.
- Dominant language
- TypeScript
- Stars
- 194
- Forks
- 27
- PR merge metrics
- No merged PRs in 30d
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from SimHacker/MicropolisCore
-
Difficulty 3/5 1-2 days Newbie friendliness 35/100
SimHacker/MicropolisCore#11 ·
-
Rendering sprites Open
Difficulty 4/5 3-5 days Newbie friendliness 45/100
SimHacker/MicropolisCore#4 · 11 comments ·
All issues in SimHacker/MicropolisCore
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
area:tools bug good first issue help wanted priority:P2
Difficulty 2/5 1-3 hours Newbie friendliness 90/100
TaewoooPark/Motifcode#14 ·
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
newrelic-experimental/preflight#793 · 1 comment ·
-
bug 🐞
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
[Bounty proposal] fix(web): memory insights count an evening memory on the next day ($25 proposed) Open
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
BasedHardware/omi#15320 ·