Foundation · Seeding & determinism

Every Draw Is Accounted For

The entire game world unfolds from one seed string. Same seed, same world, same sequence of artefacts, on every machine, forever. That guarantee rests on a module about a hundred lines long and two habits: every generation function takes the PRNG as a parameter, and no function changes how many draws it makes.

The generator

The seed string is hashed by xmur3 into a stream of well-mixed 32-bit integers, which seed the four state words of a xoshiro128** generator. Each call advances the state and returns a float in [0, 1). There's a guard against the all-zero state (xoshiro is degenerate there; xmur3 makes it vanishingly unlikely, but a guard is cheaper than an incident) and that, with weightedSelect below, is the whole module.

"twcb-demo" xmur3 s0 s1 s2 s3 xoshiro128** .481 .077 .930 … seed string four state words floats in [0, 1) draw 1: grammar option · draw 2: parameter band · draw 3: …
One string in, one infinite stream out. Which draw feeds which decision is fixed by contract.

Determinism is injected

No engine module ever calls Math.random(). Every generation function takes a prng: () => number parameter, and determinism flows entirely from that injection. The engine modules are pure TypeScript with no framework or browser dependencies; the design docs call this the engine boundary. The same pipeline runs in a test, a CLI sampler or a browser panel with identical output.

Draw ordering is part of the API

How many times a function draws from the PRNG is part of its public contract. selectGrammarOption documents that it consumes exactly one draw per call regardless of how many options it weighs. If a refactor made it draw twice, every seed in existence would produce a different world, and every regression fixture would be lies. Changing the draw count is a breaking change even when the function's return type is untouched.

Draw order is a wire format In seeded procgen the PRNG call sequence is a serialisation format: the seed is the message and the draw order is the schema. A change to either deserves the review a wire-protocol change gets.

Weighted selection

One utility, weightedSelect, does nearly all the choosing in the pipeline. It takes candidates, a PRNG and a weight callback; the callback shape exists because callers weigh differently-shaped things (grammar options by effective weight, materials by a three-factor product). Two edge cases are handled deliberately: a zero-total weight table degrades to a uniform draw rather than crashing or silently picking the first item, and floating-point drift on the final subtraction falls through to the last candidate.

export function weightedSelect<T>(
  items: readonly T[],
  prng: () => number,
  getWeight: (item: T) => number, // callers weigh differently-shaped things
): T

What the seed does and doesn't pin

The guarantee is phrased carefully: same seed means the same sequence of artefacts. Artefact N may depend on artefacts 1 through N−1 (associated finds, site reuse, material precedent), so the seed determines the unfolding, and no artefact is addressable in isolation. If you want artefact 500, you generate the 499 before it.

Where it lives src/lib/engine/prng.ts (xmur3, xoshiro128**, weightedSelect) · determinism and distribution tests alongside · draw-count contracts in each generation module's JSDoc