1,655 words · 3 diagrams
The question: how is the universe identical every time, generated in any order, on any machine, with any number of workers? The answer: content derives from a seed path, never from a shared stream; the PRNG uses only exactly-specified integer operations; and golden vectors make an accidental change fail loudly.
Decision record: ADR-0005 · Code:
packages/procedural/
The failure this design exists to prevent
Here is the tempting version:
const rng = new Rng(systemSeed)
for (const planet of planets) {
planet.mass = rng.next()
planet.radius = rng.next()
}It is deterministic in the narrow sense — same seed, same output. It is also unusable, for two reasons:
flowchart TB
subgraph STREAM["shared stream"]
direction TB
S1["planet 0 draws values 1-6"]
S2["planet 1 draws values 7-12"]
S3["planet 2 draws values 13-18"]
S1 --> S2 --> S3
end
INSERT["insert a moon in planet 0<br/>→ it draws 1-9<br/>→ <b>every later planet changes</b>"]
ORDER["generate planet 2 first<br/>→ it draws 1-6<br/>→ <b>a different planet</b>"]
STREAM --> INSERT
STREAM --> ORDER
style INSERT fill:#7f1d1d,stroke:#450a0a,color:#fff
style ORDER fill:#7f1d1d,stroke:#450a0a,color:#fffThe second one is fatal for a streaming world: patches are generated in whatever order workers pick them up, so the planet would depend on which way the player flew around it.
Path derivation
Content derives from a seed path — the address, which is already the stable identity of the thing being generated.
flowchart LR
R["rootSeed('inertialref')<br/><code>0df87e57…</code>"]
G["+ 'g:milky-way'"]
S["+ 's:SOL'"]
B["+ 'b:2'"]
M["+ 'b:0'"]
SUR["+ 'surface'"]
R --> G --> S --> B --> M
B --> SUR
R -.- N1["128 bits, four uint32 lanes"]
M -.- N2["a moon's seed descends from its planet's,<br/>so one moon can be generated<br/>without its siblings"]
classDef note fill:none,stroke:none,color:#64748b,font-style:italic
class N1,N2 note
style R fill:#0369a1,stroke:#0c4a6e,color:#fffA body's properties therefore depend only on its own address. Not on traversal order, not on worker count, not on how much of the universe happens to be loaded, not on what a neighboring client has visited.
The tests assert exactly that, rather than asserting a value:
- Generate the whole star catalog sequentially, then again in shuffled order, and compare.
- Generate a galactic cell's contents; generate its nine neighbors; regenerate the original and compare.
- Sample noise at 200 points in order, then in shuffled order, and compare.
The PRNG, and why this one
xoshiro128** — 32-bit state, 2^128 period, and expressible entirely in
Math.imul, shifts and XOR.
That last property is the reason. ECMAScript specifies those operations exactly, so Chrome, a Web Worker, Node and a future server produce identical streams bit for bit. Floating-point-derived randomness makes no such promise, and a universe that differs subtly between a client and a server is a class of bug that only appears in production.
| Rejected | Why |
|---|---|
Math.random with a seeded shim |
Not reproducible across engines; the global stream is the order-dependence trap by construction |
| PCG64 / splitmix64 via BigInt | Better statistics, ~10× slower — not viable in the terrain inner loop |
| One RNG per system, drawn sequentially | Order-independent between systems but not within one; adding a planet still rewrites its siblings |
Seed derivation has to avalanche
Sibling addresses differ by one character — b:3 and b:4. If their seeds
correlated, adjacent planets would come out suspiciously similar, which reads to
a player as "the generator is fake".
Every lane absorbs every code unit of the label. Measured over 5,000 sibling derivations: 64.0 of 128 bits differ on average, with no collisions. The test asserts the mean lands between 56 and 72.
Stateless noise
Terrain is a pure function of (surface parameters, direction on the sphere) —
no stream, no cursor, no accumulated state. The two derivations between the seed
and the field, the grammar and the sketch, are memoized rather than stored: a
cache, never a save (ADR-0019).
flowchart LR
IN["body facts + surface seed<br/><i>mass, radius, air, tide</i>"] --> G["SurfaceGrammar<br/><i>which bands, how loud</i>"]
G --> SK["terrainSketch<br/><i>plate nuclei, hotspots,<br/>the crater ladder</i>"]
G --> B["six bands<br/><i>hypsometry, belts, volcanism,<br/>relief, ice, craters</i>"]
SK --> B
D["direction on the sphere"] --> B
B --> OUT["groundElevation, meters<br/><i>and, where a patch asks for it,<br/>eight bytes of surface cover</i>"]
B -.- NOTE["each band is a share of one relief budget<br/>and the shares sum to one, so no band<br/>can grow past its allowance"]
classDef note fill:none,stroke:none,color:#64748b,font-style:italic
class NOTE noteStateful erosion is what this rules out, and it is the one thing that would buy the look for free. Hydraulic erosion is order- and resolution-dependent: two patches of the same ground at different levels, or the same patch generated after a different neighbor, come out different, and ground whose value depends on who asked first is not a function of an address. The look is bought analytically instead — each octave damped by how steep the sum already is.
Because it is stateless, the same patch generated on the main thread and in a worker are byte-identical — which capability check 10 asserts by comparing the whole response: all 4,761 elevations of a bordered 65×65 patch and all 33,800 bytes of surface cover beside them. The cover matters to the claim rather than riding along with it, because it is the half that decides what the ground is made of: a worker whose sketch had diverged would draw a different planet through an identical heightfield.
Golden vectors
procedural.test.ts pins exact outputs: the root seed, two derivations, the
first four uint32s of a stream, and a noise and an fBm sample.
These are not testing that the numbers are right — any stream would do. They are testing that the numbers never change. A silent tweak to the mixing function would regenerate every player's universe out from under their save file, and this is the tripwire.
Changing a golden vector is a deliberate act, and it comes with an algorithm version bump in the same commit.
Algorithm versioning
Generators declare an AlgorithmVersion, and saves record the manifest of the
implementation that produced them. These numbers identify drift; they are not
folded into seed paths and do not select archived generators. The active
manifest is:
generation: { galaxy: 5, 'galaxy-field': 5, system: 4, terrain: 4, photometry: 1 }system went to 3 when generated systems gained a belt — six to eighteen small
bodies each, issued after every planet. Nothing a save could already point at
moved, because issue ordinals are
exactly what stops that; but a system now contains things it did not, and a
manifest that did not say so would let two builds of the game disagree about the
contents of one address space with nothing to notice.
terrain counts the three times the ground itself moved: 2 for the band stack,
3 for the liquid — valleys, a coast and a sea read against the ground
temperature — and 4 for the crater ladder reaching fourteen halvings. Each time,
the number a save's landed ship is sitting on is a different number.
A draw that keeps its place in the stream can still be a version. system
deliberately did not move with the band stack, and that case is sound:
makeSurface draws the same three values in the same order from the same
stream, so the ground under a landed ship changed while every other property of
every body in the galaxy stayed exactly where it was.
The opposite case is the trap, and system@4 is what it costs. The axial-tilt
draw consumes exactly one gaussian and the spin floor beside it consumes none,
so nothing downstream of either shifts — and the angle that comes back is
different on 142 of 6,496 bodies, the worst by 41°. Order is what protects a
body's neighbors; it says nothing about the body. The state hash cannot cover
the gap either, because a landed entity's numbers are body-frame-relative and
identical on both sides of a pole that moved. One bump covers everything that
moved with it — the spin floor's lengthened period, and polarRadius on 1,515
bodies, which is presentation and rides along rather than earning its own
number. ADR-0027 records the argument and
system.ts carries it at the constant.
So "this save was made with terrain v4" is a statement the loader can act on, rather than a mystery about why the coastline moved. Deciding what to do about it — regenerate, pin the old version, migrate content — is future work, but the information is captured now, which is the part that cannot be added retroactively.
Determinism in the simulation, not just generation
Generation determinism is only half. The simulation half is enforced by a single comparison value:
world.stateHash() // 'f38e988a'A hash over the tick, the seed, and every entity's frame, position, velocity, orientation, angular velocity, control input, flight assist, landed state and rails epoch — the instant a coasting entity is propagated from (ADR-0025). The epoch is in there for the same reason as the rest: two worlds that agree on a state and not on its epoch agree about the present and part in the low bits on the next tick, each propagating a slightly different rounding of the same conic.
Control input, flight assist and landedness were not always in it, and the
docstring said "everything canonical" anyway — so two worlds that differed only in the fields killRotation
and flight assist write hashed identically at the instant they diverged, and only
drifted apart hundreds of ticks later once the difference had leaked into
position. That is where a shipped bug had already lived: a save taken mid-burn
resumed coasting, and the persistence test caught it only by stepping 300 further
ticks. If you add a field to canonical state, add it here too.
Every determinism test in the suite is an assertion about it:
| Test | Asserts |
|---|---|
| 60 Hz vs 144 Hz | same hash at the same tick |
| jittery frames (4–60 ms) vs steady | same hash at the same tick |
| 1× for 100 s vs 100× for 1 s | same hash, same simulated time |
| a coast stepped vs jumped vs 100,000× | same hash at the same tick |
| save → step → load | hash returns to the saved value |
| a coasting save → load → 300 ticks | same hash, on the same conic |
| a descent save → load → 400 ticks | same hash — the reused ground sample is exact |
| worlds differing only in control input | different hashes |
| worlds differing only in flight assist | different hashes |
| worlds differing only in angular velocity | different hashes |
| replay with identical inputs | same hash |
See simulation time for why the clock makes this possible.
The rules, restated
- No
Math.random()in anything canonical. - No
Date.now()orperformance.now()in anything canonical. Wall clock enters at exactly one call. - Derive from an address; never draw from a shared stream.
- If generating object B first can change object A, it is wrong.
- Changing a generator changes its version, in the same commit.
- Draw, then override. A
??that skips the draw makes every later value for that object depend on whether the override happened to be present.
Related
- Identity — the address that doubles as the seed path
- Time — the other half of determinism
- Persistence — why determinism means saves are tiny
- ADR-0005 — PRNG selection in full