BLIT386BLIT386

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 1234

Prefer 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 fact

The 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 draws

seedValue 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-ask

Hashing 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-rolledRandom
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() < prng.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

Last updated on September 9, 2026

On this page