Skip to content

PeshoVurtoleta/lite-random

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@zakkster/lite-random

npm version npm bundle size npm downloads npm total downloads TypeScript Zero Dependencies License: MIT

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 stategetState() / 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 distributiongaussian() 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.-> C
Loading

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
Loading

Installation

npm install @zakkster/lite-random

Quick 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 were

Benchmarks & 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" : 5
Loading

Procedural 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 intact

Random 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.-> Replay
Loading
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);
    }
}

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:

  1. int(min, max) is now uniform on negative ranges. Before 1.1.0, calling int(-5, 5) would never return -5 and would over-return 0. If your game relied on that bias (extremely unlikely), reseeding will now produce a slightly different sequence on calls that hit int() with negative min. Pure positive ranges (int(0, 10), int(1, 6)) are byte-identical to 1.0.x.
  2. reset() now returns this. 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

About

Zero-dependency seeded RNG for games — Mulberry32 PRNG with weighted drops, gaussian distribution, shuffle, and 2D vector helpers.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors