One deterministic random stream (mulberry32).
Instances are mutable — each draw advances the cursor — and must not be shared
between subsystems. Two subsystems on one stream means the contents of each depend on how
often the other drew, which is a bug that only ever shows up as "the world changed when I
opened the menu". Use Rng.derive instead; that is the entire reason it exists.
Construct with createRng, or with Rng.fromSnapshot to resume a saved one.
readonly seed: numberThe stream's identity as a uint32, invariant under drawing.
derive forks from this and never from the cursor, so a child stream is the same
stream no matter how far its parent has advanced. It is also what noise and hash
want: fbm2(rng.derive('terrain').seed, ...) is a pure function of a number, with no
stream to thread through a renderer.
private stateThe mulberry32 cursor. uint32, advances once per Rng.nextUint32.
static fromUint32Seed(seed: number): RngBuild a stream whose identity and cursor are both seed.
static fromSnapshot(snapshot: RngSnapshot): RngRebuild a stream from a snapshot — the save/load and replay path.
ThrowsRangeError if either field is not a uint32, i.e. a corrupted or hand-edited
save. Accepting it would resume a stream at a cursor that no longer means anything,
and the divergence would be blamed on whatever drew next.
nextUint32(): numberThe raw generator: one mulberry32 step, uniform over the full uint32 range.
Every multiply is Math.imul because a plain a * b on two 32-bit integers produces up
to 64 bits, which exceeds the 53-bit mantissa — and the bits that round away are the low
ones, which are the entire output of a hash.
next(): numberA float in [0, 1). Exactly nextUint32() / 2**32.
An integer below 2^32 over a power of two is exactly representable as a double, so this
is bit-identical everywhere. Any other normalization — / (2**32 - 1), or the rounded
literal * 2.3283064365386963e-10 — reintroduces rounding and quietly breaks replay on
one engine out of three.
int(minInclusive: number, maxExclusive: number): numberA uniform integer in [minInclusive, maxExclusive).
Rejection-samples against the largest multiple of the span that fits in a uint32.
min + (nextUint32() % span) over-represents the low 2^32 % span values: invisible on
a d6, and visible on a one-in-three loot table over a session — a bias with no symptom
until someone counts. The expected number of retries is below one.
The bound checks are written out here rather than delegated to guard, deliberately:
shuffleInPlace calls this once per element per frame, and guard's own contract is
that a validator belongs at an API entry point and not in a per-entity loop.
ThrowsRangeError unless both bounds are integers, max > min, and the span is at
most 2^32. A span of exactly 2^32 is allowed and never rejects a draw.
float(min: number, max: number): numberA float in [min, max), or exactly min when the bounds are equal.
The quantity that has to be finite is the span, not the bounds. Checking the two
bounds and stopping there is a guard that passes at exactly the moment it should fire:
float(-Number.MAX_VALUE, Number.MAX_VALUE) has two perfectly finite arguments whose
difference overflows, and min + next() * Infinity is Infinity on every draw — or
NaN on a draw of exactly 0, since 0 * Infinity is NaN. That is the worst possible
value to produce here: JSON.stringify writes both as null, so the number vanishes
from a save with the checksum still matching and no layer downstream can see it happen.
ThrowsRangeError unless both bounds are finite, max >= min, and max - min is
itself finite.
bool(probability?: number): booleanA coin flip.
probability <= 0 never fires and >= 1 always does, since Rng.next is
half-open — so a rate driven to its extremes by a game's own maths degrades to
"never" and "always" rather than to an off-by-one.
ThrowsRangeError if probability is not finite. NaN would compare false against
everything and read as a silently dead branch.
pick<T>(items: readonly T[]): TA uniformly chosen element.
ThrowsRangeError on an empty array. Under noUncheckedIndexedAccess that is the only
way to return T rather than T | undefined, and an empty pick is a caller bug in
every case we have — a table that is empty for two of four biomes is exactly the
shape that shipped a black screen in the source game.
weighted(weights: readonly number[]): numberThe index of a weighted choice. Weights need not sum to 1, and zero weights are never
chosen.
This exists because every game hand-rolls it and half of them accumulate in a different
order each call — a determinism bug with no symptom until a replay diverges. Exactly one
draw is consumed regardless of the table's size, so adding a zero-weight row to a loot
table does not shift every later draw in the session.
A hole in a sparse array counts as a zero weight, the same as an explicit 0.
ThrowsRangeError if weights is empty, contains a negative or non-finite value, or
sums to zero. A table that sums to zero has no answer to give, and returning 0 would
make "everything is disabled" look like "the first one always wins".
shuffle<T>(items: readonly T[]): T[]Fisher-Yates.
Not items.sort(() => rng.next() - 0.5): that is not a uniform permutation under any
sort algorithm, and its result depends on the engine's sort implementation — so it is
non-deterministic across platforms on top of being biased.
shuffleInPlace<T>(items: T[]): T[]Fisher-Yates in place, returning the same array.
The hot-path form: shuffling a 400-entry draw order every frame must not allocate a
400-entry array every frame. Draws the same sequence as Rng.shuffle for the same
input, so switching between them does not change a replay.
derive(...labels: readonly (number | string)[]): RngFork an independent child stream from this stream's IDENTITY, not its cursor.
rng.derive('scenery') returns the same stream whether the parent has drawn zero times
or a million. That is what lets terrain stay byte-stable while the UI draws freely, and
what lets a per-tick stream be addressed as derive('event', tick). Forking from the
cursor instead passes every test and then regenerates the world differently because a
menu animation drew a sparkle first — which is the exact failure this method exists to
make unreachable.
Labels are order-sensitive: derive('a', 'b') and derive('b', 'a') are different
streams, so a (category, id) pair cannot collide with its own transpose. They are also
a path: derive('a').derive('b') is derive('a', 'b'), so a subsystem may be handed
either a pre-derived stream or the labels to derive with and reach the same world.
ThrowsRangeError if called with no labels — an unlabelled fork is a clone, and
silently sharing a stream is the bug this method exists to prevent — or if a numeric
label is not finite.
snapshot(): RngSnapshotCapture the full internal state. JSON-serialisable; see RngSnapshot.
restore(snapshot: RngSnapshot): RngRestore in place when the identity matches, otherwise return a new instance.
seed is readonly, and a stream that could change identity under you would make every
derive call above it a lie — the children would keep forking from an identity their
parent no longer has. So a cross-identity restore hands back a different object, and a
caller that ignores the return value keeps the stream it already had rather than a
silently mutated one.
ThrowsRangeError if either snapshot field is not a uint32.
clone(): RngAn independent copy positioned exactly here. Advancing it never touches this one —
which is what lets a system speculate ahead (a preview, a lookahead) without spending
draws the real stream will need.