Skip to content

seedRandom() cannot make the simulation reproducible — initWillStuff() reseeds from the wall clock #13

Description

@fdrocha

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 when loadCity() 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 (Intermittent memory access out of bounds in simTick() — unconstructed std::string in SimSprite #11).

  • The --control arm is the diagnostic half. generateMap(seed) seeds the same RNG through the same seedRandom() 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:

// 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions