Zero-dependency seeded RNG built for games, not data science.
Deterministic. Fast. Zero allocations. Game-ready API out of the box.
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().
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.-> C
Because 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 resumes
npm install @zakkster/lite-randomimport { 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 were| Operation | Ops/sec |
|---|---|
rng.next() |
~200M |
rng.range() |
~180M |
rng.int() |
~170M |
rng.chance() |
~190M |
rng.gaussian() |
~40M |
| Feature | lite‑random | Math.random | random-js | seedrandom |
|---|---|---|---|---|
| Deterministic | ✔ | ✘ | ✔ | ✔ |
| Zero allocations | ✔ | ✔ | ✘ | ✔ |
| Game‑focused API | ✔ | ✘ | ✘ | ✘ |
| <1KB | ✔ | ✔ | ✘ | ✘ |
| Weighted selection | ✔ | ✘ | ✔ | ✘ |
| Gaussian | ✔ | ✘ | ✔ | ✘ |
unitVector() |
✔ | ✘ | ✘ | ✘ |
| Serializable state | ✔ | ✘ | ✘ | ✘ |
| 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() |
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" : 5
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);
}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),
});
}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
}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);
}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]
);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);
}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
}));In-place shuffle for zero allocations:
const deck = rng.shuffleInPlace(cards); // mutates, GC-friendly
const hand = rng.shuffle(deck); // copy, original intact// Random left/right velocity
particle.vx = rng.sign() * rng.range(50, 150);
// Random clockwise/counterclockwise spin
particle.rotation = rng.sign() * rng.range(1, 5);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.-> Replay
Click to expand: Production-Grade ReplayManager Recipe
A 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);
}
}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);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 }MIT