Random
Seeded determinism in practice: reproducible runs, independent streams, and procedural worlds from coordinates.
The full surface - every Random method, the BT.random engine default, coordinate hashes, and the noise classes -
lives in API: Random. This guide walks through the ideas behind it: why the engine stays deterministic,
how a seed reproduces a whole run, when to split off an independent stream, and how to build worlds from coordinates
instead of a sequence.
Deterministic by default
The engine core makes zero Math.random() calls, so a run only varies where you introduce randomness. BT.random is
the one shared generator the engine exposes: a live Random instance, time-seeded from Date.now() when the engine
singleton is created. Read from it and every run differs, as you would expect.
The payoff is control. Call BT.randomSeed(seed) once at startup and the whole run becomes reproducible - the same
enemy waves, the same loot, the same particle scatter, every time. That is what makes seeded runs worth having:
regression tests that assert exact frames, replays that reconstruct a session from its seed and inputs, and
daily-challenge games where every player faces the identical board.
import { } from 'blit386';
// Time-seeded: different every run.
..(0, 100);
// Reseed once and the run is reproducible from here on.
.(1234);
..(0, 100); // same value on every run seeded with 1234Prefer BT.random for demo and game code. Reach for a standalone new Random(seed) only when you need a stream that is
independent of the shared engine one - see Independent streams.
Same seed, same world
A seeded generator is a pure function of its seed and how many times it has been drawn. Two generators seeded alike produce identical sequences; reseeding rewinds to the start. That is the whole contract behind "same seed = same world".
import { } from 'blit386';
const = new (42);
const = new (42);
.(0, 1000) === .(0, 1000); // true - identical streams
.(['fire', 'water', 'earth']) === .(['fire', 'water', 'earth']); // true
.(42); // rewind a to the beginning
.(0, 1000); // same first value as before
.; // 42 - the seed itself, readable back after the factThe order of draws is part of the state. Adding a rng.bool() call between two rng.int() calls shifts every value
that follows, so a saved seed only reproduces a world when the code that consumes it is unchanged. Keep seed-consuming
setup (level generation, spawn tables) in a stable order, and pull incidental effects (a cosmetic sparkle color) from a
separate stream so tweaking them never disturbs the layout.
Independent streams
clone() and fork() both branch a generator, for opposite reasons.
clone() copies the state, so the copy replays the parent's exact upcoming sequence - useful to preview draws without
consuming them, or to snapshot before a speculative rollback. fork() advances the parent once and seeds a child from
that draw, so the two streams diverge - the way to give a subsystem its own randomness without coupling it to the shared
engine stream.
import { } from 'blit386';
const = new (7);
const = .(); // independent stream; advances `world` once
// Drawing particles never disturbs world generation, and vice versa.
.(-1, 1);
.(0, 320);getState() / setState() are the lower-level primitives underneath: read the 32-bit state, draw ahead, then restore
it to replay. A deterministic replay records the seed plus the inputs and lets the same draws fall out; a rollback
netcode step snapshots the state, simulates forward, and rewinds when a prediction proves wrong.
import { } from 'blit386';
const = new (99);
const = .();
.(0, 6);
.(0, 6); // advance
.(); // rewind and replay the same drawsseedValue answers a different question than getState(): not "where am I in the sequence" but "what seed got me
here." It reports the last seed passed to the constructor or seed(), for exactly as long as that claim stays true.
setState() breaks the claim - an arbitrary saved state is not a seed - so it clears seedValue to undefined rather
than report something misleading. clone() and fork() diverge for the same opposite reasons as before: a clone is
defined to replay the parent's exact sequence, so it copies seedValue along with the state; a fork is defined to
diverge, so its child never claims a seed the caller didn't choose, even though fork() seeds the child internally from
a drawn value.
Procedural patterns from coordinates
A Random is a sequence: draw after draw, order matters. For a world you explore out of order - chunks that load as the
camera moves, a tile you query long before its neighbors - you want the opposite: ask "what belongs at (x, y)?" and
get a stable answer with no stored state per cell. That is coordinate hashing.
hash2i(x, y, seed?) returns the same unsigned 32-bit value for the same inputs, every call, from anywhere. hash2
gives the [0, 1) float form for probabilities. Nothing is remembered between calls, so a 10,000-tile map costs no
per-tile RNG.
import { } from 'blit386';
const = 9001;
// Deterministic 12% treasure chance per tile, queried in any order.
function (: number, : number): boolean {
return (, , ) < 0.12;
}
(4, 2);
(4, 2); // identical - no state, safe to re-askHashing gives independent per-cell values; for fields that vary smoothly - terrain height, cloud cover, organic drift -
use the noise classes. ValueNoise, PerlinNoise, and SimplexNoise sample continuous space in approximately
[-1, 1], and their fbm* methods layer octaves for natural detail.
import { } from 'blit386';
const = new (9001);
// Smooth height in [0, 1] across a chunk; scale x/y to set the feature size.
function (: number, : number): number {
return (.( * 0.05, * 0.05) + 1) * 0.5;
}
(12, 30);Seed the noise (or pass 0 for a fixed default world) the same way you seed hash2i, so a world's terrain and its
hashed spawns line up under one seed. The API: Random page covers the full noise surface
and credits the Perlin and simplex references.
Migrating from Math.random()
Hand-rolled randInt / randFloat / randPick helpers map straight onto integer-first Random methods, and gain
determinism for free:
| Hand-rolled | Random |
|---|---|
Math.floor(Math.random() * n) | rng.int(n) |
min + Math.random() * (max - min) | rng.float(min, max) |
arr[Math.floor(Math.random() * arr.length)] | rng.pick(arr) |
Math.random() < p | rng.bool(p) |
import { } from 'blit386';
// Before: BT.random gives the same ergonomics, but a seeded run is reproducible.
..(150, 420);
..(['glitch', 'noise', 'static']);
..(0.25);API history
See also
API: Random
API: Core Types
Game Loop Guide
Performance Best Practices
Last updated on September 9, 2026