npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@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

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

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

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

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