@zakkster/lite-random
v1.1.0
Published
Zero-dependency seeded RNG for games — Mulberry32 PRNG with weighted drops, gaussian distribution, shuffle, and 2D vector helpers.
Maintainers
Readme
@zakkster/lite-random
Zero-dependency seeded RNG built for games, not data science.
Deterministic. Fast. Zero allocations. Game-ready API out of the box.
Why This Library?
Most RNG libraries on npm are built for statisticians. They return closures and thunks (random.uniform(0, 1)()), creating garbage in a tight 60fps game loop. They ship Poisson distributions when you need a dice roll.
@zakkster/lite-random is different:
- No GC spikes — direct execution (
rng.range(0, 1)), zero allocations per call - Deterministic replay — same seed = same sequence, every time. Replay bugs, test edge cases, ship replays
- Serializable state —
getState()/setState()snapshot the entire RNG in a single 32-bit integer - Game-ready API — loot drops (
weighted()), critical hits (chance()), 2D physics (unitVector(),sign()) - Mulberry32 PRNG — entire state in a single 32-bit integer. Blazing fast, minimal memory
- Natural distribution —
gaussian()for particle spread, procedural terrain, organic variation - Zero dependencies — < 1KB minified
You don't need a statistics library to make a scratch card. You need rng.chance(0.05) and rng.weighted().
How Determinism Works
The whole engine fits in a single 32-bit integer. Every call to next() advances state through Mulberry32's mixer; every other method is built on top of next(). Same seed in → same sequence out, forever.
flowchart LR
A["new Random(seed)"] --> B["state = seed | 0"]
B --> C["next()"]
C --> D["state += 0x6D2B79F5 | 0"]
D --> E["mix: imul, xor, shift"]
E --> F["÷ 2³² → [0, 1)"]
F --> G["range / int / pick / weighted / ..."]
D -.advances.-> CBecause the entire state is one integer, you can snapshot and restore it at any point — making rollback netcode, save/load, and golden-master testing trivial.
sequenceDiagram
participant Game
participant RNG as Random
participant Save as Save File
Game->>RNG: new Random(seed)
Game->>RNG: next() × N
Game->>RNG: getState()
RNG-->>Game: state (int32)
Game->>Save: persist {seed, state}
Note over Game,Save: ...later, in another session...
Save-->>Game: load {seed, state}
Game->>RNG: new Random(seed).setState(state)
Game->>RNG: next()
Note over RNG: identical sequence resumesInstallation
npm install @zakkster/lite-randomQuick Start
import { Random } from '@zakkster/lite-random';
const rng = new Random(42); // deterministic seed
rng.next(); // 0.0–1.0 float
rng.range(5, 10); // float in [5, 10)
rng.int(1, 6); // integer 1–6 inclusive (dice roll)
rng.chance(0.2); // 20% chance → true/false
rng.bool(); // 50/50
rng.sign(); // -1 or 1
rng.pick(['a','b']); // random element
rng.gaussian(0, 1); // normal distribution
// New in 1.1.0 — snapshot & restore
const snap = rng.getState();
rng.setState(snap); // resume exactly where you wereBenchmarks & Comparison
Micro‑Benchmarks (Chrome M1, 2026)
| Operation | Ops/sec |
|------------------|---------|
| rng.next() | ~200M |
| rng.range() | ~180M |
| rng.int() | ~170M |
| rng.chance() | ~190M |
| rng.gaussian() | ~40M |
Comparison
| Feature | lite‑random | Math.random | random-js | seedrandom |
|---------|-------------|-------------|-----------|------------|
| Deterministic | ✔ | ✘ | ✔ | ✔ |
| Zero allocations | ✔ | ✔ | ✘ | ✔ |
| Game‑focused API | ✔ | ✘ | ✘ | ✘ |
| <1KB | ✔ | ✔ | ✘ | ✘ |
| Weighted selection | ✔ | ✘ | ✔ | ✘ |
| Gaussian | ✔ | ✘ | ✔ | ✘ |
| unitVector() | ✔ | ✘ | ✘ | ✘ |
| Serializable state | ✔ | ✘ | ✘ | ✘ |
API Reference
| Method | Returns | Description |
|--------|---------|-------------|
| new Random(seed?) | | Create RNG. Defaults to Date.now(). |
| .next() | number | Float in [0, 1) |
| .reset(seed?) | this | Reset to seed (or original). Chainable. |
| .getState() | number | Snapshot the 32-bit state. |
| .setState(state) | this | Restore the 32-bit state. Chainable. |
| .range(min, max) | number | Float in [min, max) |
| .int(min, max) | number | Integer in [min, max] inclusive — unbiased on negatives |
| .chance(p) | boolean | True with probability p |
| .bool() | boolean | 50/50 |
| .sign() | -1 \| 1 | Random direction multiplier |
| .unitVector(out?) | {x, y} | Normalized 2D direction. Pass out for Zero-GC. |
| .unitVectorArray(buf, i?) | buf | Write unit vector into buf[i], buf[i+1] |
| .gaussian(mean?, std?) | number | Normal distribution (Box-Muller, 2 next() calls) |
| .pick(arr) | T \| null | Random element |
| .shuffle(arr) | T[] | New shuffled array |
| .shuffleInPlace(arr) | T[] | Mutates in-place (GC-friendly) |
| .weighted(items, weights) | T | Weighted random selection |
| .pickWeighted(...) | T | Alias for .weighted() |
Recipes
Weighted Loot Drops
The core of any game economy. Weights don't need to sum to 100 — they're relative:
const loot = rng.weighted(
['Common', 'Rare', 'Epic', 'Legendary'],
[60, 25, 10, 5]
);
// With item objects
const drop = rng.weighted(
[{ name: 'Potion', value: 10 }, { name: 'Sword', value: 500 }],
[90, 10]
);Visualized as relative odds:
pie showData title Loot Drop Distribution
"Common" : 60
"Rare" : 25
"Epic" : 10
"Legendary" : 5Procedural Dungeon Generation
Build deterministic levels that play the same every time for the same seed:
const rng = new Random(levelSeed);
for (const room of rooms) {
if (rng.chance(0.3)) spawnEnemies(room);
if (rng.chance(0.1)) spawnTreasure(room);
if (rng.chance(0.02)) spawnBoss(room);
}Zero-GC Particle Burst
Reuse a single out object across the loop — no allocations per particle:
rng.reset(123);
const dir = { x: 0, y: 0 };
for (let i = 0; i < 200; i++) {
rng.unitVector(dir);
emitter.emit({
vx: dir.x * rng.range(100, 300),
vy: dir.y * rng.range(100, 300),
life: rng.range(0.5, 1.5),
});
}ECS Component Buffers
Write directly into a Float32Array slot — perfect for SoA component layouts:
const VELOCITIES = new Float32Array(MAX_ENTITIES * 2);
function spawnAsteroid(id) {
rng.unitVectorArray(VELOCITIES, id * 2);
// VELOCITIES[id*2] = vx
// VELOCITIES[id*2+1] = vy
}Snapshot & Restore (Rollback Netcode)
getState() returns a single 32-bit integer that fully describes the RNG. Cheap to send over the wire, cheap to store per-frame.
// Each frame, snapshot before applying inputs
const frameState = rng.getState();
frameBuffer.push({ frame, state: frameState, inputs });
// On rollback, restore and re-simulate
rng.setState(frameBuffer[rollbackFrame].state);
for (let f = rollbackFrame; f <= currentFrame; f++) {
simulate(frameBuffer[f].inputs);
}Weighted Enemy Spawning
Harder enemies become more common as the player progresses:
const wave = rng.weighted(
['Slime', 'Goblin', 'Orc', 'Dragon'],
[Math.max(0, 60 - level * 5), 25, 10 + level, 5 + level * 2]
);Random Walk / Brownian Motion
Simple but effective for fireflies, dust particles, or ambient movement:
function updateFirefly(firefly, rng) {
firefly.x += rng.range(-1, 1);
firefly.y += rng.range(-1, 1);
}Natural-Looking Particle Spread
gaussian() clusters values near the center with natural falloff — much better than flat range() for particle effects:
emitter.emitBurst(50, () => ({
x: origin.x + rng.gaussian(0, 30), // clustered near center
y: origin.y + rng.gaussian(0, 30),
vx: rng.gaussian(0, 50), // most go slow, few go fast
vy: rng.gaussian(-100, 40), // mostly upward
life: 1 + rng.gaussian(0, 0.3), // ~1s ± 0.3s
}));Shuffle a Deck of Cards
In-place shuffle for zero allocations:
const deck = rng.shuffleInPlace(cards); // mutates, GC-friendly
const hand = rng.shuffle(deck); // copy, original intactRandom Sign for Directional Variety
// Random left/right velocity
particle.vx = rng.sign() * rng.range(50, 150);
// Random clockwise/counterclockwise spin
particle.rotation = rng.sign() * rng.range(1, 5);Deterministic Replay (VCR Engine)
The killer feature for bug reports, competitive games, and rollback netcode. Feed the same seed and the same inputs into your game loop, and your game plays out frame-perfect every time.
flowchart TD
subgraph Live["Live Mode"]
L1[Player input] --> L2{Input changed?}
L2 -- yes --> L3[Record keyframe<br/>frame + input]
L2 -- no --> L4[Skip — last keyframe still valid]
L3 --> L5[Run sim with input]
L4 --> L5
end
subgraph Replay["Replay Mode"]
R1[Tick frame counter] --> R2{Reached next<br/>keyframe?}
R2 -- yes --> R3[Advance current input]
R2 -- no --> R4[Reuse current input]
R3 --> R5[Run sim with input]
R4 --> R5
end
Live -.same seed,<br/>same Random.-> ReplayA complete, framework-agnostic ReplayManager using delta-recording (keyframing) to keep memory usage tiny:
import { Random } from '@zakkster/lite-random';
export class ReplayManager {
/**
* @param {Object} defaultInput - The baseline input schema for the game
*/
constructor(defaultInput = {}) {
this.defaultInput = defaultInput;
this.mode = 'live';
this.seed = null;
this.rng = null;
this.frames = []; // [{ frame, input }]
this.frameIndex = 0; // Array pointer for reading replays
this.frame = 0; // Master timeline counter
this.playbackSpeed = 1;
this._lastLiveInput = null;
this._currentReplayInput = null;
}
startLive(seed = Date.now()) {
this.mode = 'live';
this.seed = seed;
this.rng = new Random(seed);
this.frames = [];
this.frame = 0;
this.frameIndex = 0;
this.playbackSpeed = 1;
this._lastLiveInput = null;
}
startReplay(replayData) {
this.mode = 'replay';
this.seed = replayData.seed;
this.rng = new Random(this.seed);
this.frames = replayData.frames || [];
this.frame = 0;
this.frameIndex = 0;
this.playbackSpeed = 1;
this._currentReplayInput = structuredClone(this.defaultInput);
}
recordInput(inputState) {
if (this.mode !== 'live') return;
if (!this._lastLiveInput || this._hasInputChanged(inputState)) {
const clonedInput = structuredClone(inputState);
this.frames.push({ frame: this.frame, input: clonedInput });
this._lastLiveInput = clonedInput;
}
}
getInput(liveInputState) {
if (this.mode === 'live') return liveInputState;
const nextKeyframe = this.frames[this.frameIndex];
if (nextKeyframe && this.frame >= nextKeyframe.frame) {
this._currentReplayInput = nextKeyframe.input;
this.frameIndex++;
}
return this._currentReplayInput;
}
nextFrame() {
this.frame += this.playbackSpeed;
}
isReplayFinished() {
return this.mode === 'replay' && this.frameIndex >= this.frames.length;
}
jumpToFrame(targetFrame) {
if (this.mode !== 'replay') return;
this.frame = targetFrame;
let foundIndex = 0;
for (let i = this.frames.length - 1; i >= 0; i--) {
if (this.frames[i].frame <= targetFrame) {
foundIndex = i;
break;
}
}
this.frameIndex = foundIndex + 1;
this._currentReplayInput = this.frames[foundIndex]?.input
|| structuredClone(this.defaultInput);
}
getRng() {
return this.rng;
}
toJSON() {
return { seed: this.seed, frames: this.frames };
}
_hasInputChanged(newState) {
return JSON.stringify(this._lastLiveInput) !== JSON.stringify(newState);
}
}Migration: 1.0.x → 1.1.0
No breaking changes. Existing code keeps working unchanged.
Two behaviors are tightened up that you may want to know about:
int(min, max)is now uniform on negative ranges. Before 1.1.0, callingint(-5, 5)would never return-5and would over-return0. If your game relied on that bias (extremely unlikely), reseeding will now produce a slightly different sequence on calls that hitint()with negativemin. Pure positive ranges (int(0, 10),int(1, 6)) are byte-identical to 1.0.x.reset()now returnsthis. Old code that ignored the return value still works. New code can chain:rng.reset(seed).next().
New surface to take advantage of:
// Snapshot + restore
const snap = rng.getState();
rng.setState(snap);
// Zero-GC unit vectors
rng.unitVector(reusedObj);
rng.unitVectorArray(float32Buffer, entityId * 2);TypeScript
Full generic support — pick(), shuffle(), and weighted() preserve element types:
import { Random } from '@zakkster/lite-random';
const rng = new Random(42);
const item: string = rng.pick(['sword', 'shield', 'potion'])!;
const shuffled: number[] = rng.shuffle([1, 2, 3, 4, 5]);
const out = { x: 0, y: 0 };
rng.unitVector(out); // typed as { x: number; y: number }License
MIT
