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-rollback

v1.0.2

Published

Zero-GC binary ring buffer + GGPO-shaped rollback Session for browser-native deterministic netcode. Single-file ESM, zero dependencies, zero allocations after construction.

Readme

@zakkster/lite-rollback

Zero-GC binary ring buffer + GGPO-shaped rollback Session for browser-native deterministic netcode. One ArrayBuffer per match, one TypedArray.set per snapshot, zero allocations after construction.

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

npm install @zakkster/lite-rollback

One allocation for the lifetime of a match. A snapshot is one TypedArray.set() per field -- no JSON.stringify, no structuredClone, no new. Pure memcpy.

import { createRollback } from '@zakkster/lite-rollback';

const rb = createRollback({
  capacity: 64,
  fields: {
    pos:   { type: Float32Array, length: 4000 },
    vel:   { type: Float32Array, length: 4000 },
    flags: { type: Uint8Array,   length: 1000 },
  },
});

// Mutate this frame's state, then snapshot it.
rb.fields.pos[0] = 100.5;
rb.commit();

// Mutate, change your mind.
rb.fields.pos[0] = 200;
rb.rollback();
rb.fields.pos[0];   // 100.5

// Step back N frames (e.g. when a late input arrives).
rb.rollback(8);

// Read a historical state without disturbing live.
rb.peek(5).pos[0];

rb.depth();         // commits available

For full rollback netcode — input ring, prediction, automatic fast-forward — use createSession on top of the same primitive (see the Session API).


Contents


Why

JavaScript rollback netcode has a distinctive failure mode: per-frame deep-clone. You write this first, and you regret it later:

// The naive snapshot — what most rollback "tutorials" tell you to do
function snapshot(world) {
  return JSON.parse(JSON.stringify(world));   // or structuredClone(world)
}
function restore(world, snap) {
  Object.assign(world, snap);                 // and rebuild every nested object
}

For a fighting game with a small state, you can sometimes get away with it. For anything beyond that — a top-down shooter, a 60-entity bullet hell, a physics-driven platformer — every frame you allocate a fresh deep-cloned object graph. At 60 fps with a 30-frame rollback window, that's 1 800 garbage clones per second. The GC can't keep up, and you ship stutter.

flowchart LR
    subgraph N["Naive path"]
        direction TB
        N1[per-frame snapshot<br/>structuredClone / JSON / deep merge]
        N2[ring of cloned objects]
        N3[restore by Object.assign<br/>or new World construction]
        N4[GC pauses<br/>frame stalls]
        N1 --> N2 --> N3 -.->|every commit allocates| N4 -.-> N1
    end
    subgraph B["lite-rollback path"]
        direction TB
        B0[one allocation<br/>at session init]
        B1[mutate live typed-array views]
        B2[commit = TypedArray.set per field<br/>= memcpy]
        B3[rollback = same, in reverse]
        B0 -.->|reused forever| B1
        B1 --> B2 --> B3 -.->|no garbage| B1
    end

@zakkster/lite-rollback owns one ArrayBuffer per session — sized to Σ (capacity + 1) × field length × bytesPerElement — and exposes the live slice as typed-array views. The ring slots are views into the same backing buffer. Commit and rollback are bulk memcpys; nothing else is allocated, for the lifetime of the match.

What this is not

  • Not a transport. No WebSockets, no DataChannels. Those live in @zakkster/lite-rollback-local (BroadcastChannel, for tests + same-machine demos) and @zakkster/lite-rollback-webrtc (RTCDataChannel).
  • Not an ECS. It doesn't know what an entity is. You declare your fields; you allocate the entity slots. lite-rollback just snapshots whatever bytes you put into them.
  • Not a serialiser. No protocol, no wire format, no compression. The Transport contract is two methods (send, onMessage) — bring your own packer or use the sister packages.
  • Not magic. A hand-rolled Uint8Array.set() over a layout you maintain yourself is identical to what this does. The library trades that ~50 lines of fiddly index math for typed accessors and a GGPO-shaped Session driver.

Install

npm i @zakkster/lite-rollback

ESM-only. No dependencies. Single source file you can drop into a project directly if you'd rather not have a node_module.

import { createRollback, createSession, checksum, stateChecksum, createPRNG } from '@zakkster/lite-rollback';

Quick start

Bare ring buffer — when you just need rewind

import { createRollback } from '@zakkster/lite-rollback';

const rb = createRollback({
  capacity: 32,
  fields: {
    pos: { type: Float32Array, length: 2 },   // single 2D entity
  },
});

rb.fields.pos[0] = 0; rb.fields.pos[1] = 0;
rb.commit();                       // frame 0

rb.fields.pos[0] = 10;
rb.commit();                       // frame 1

rb.fields.pos[0] = 999;
rb.rollback();                     // live ← state from frame 1: pos[0] === 10
rb.rollback();                     // live ← state from frame 0: pos[0] === 0

Full Session — when you want GGPO-style rollback netcode

import { createSession } from '@zakkster/lite-rollback';

const session = createSession({
  capacity: 32,
  fields: {
    pos: { type: Float32Array, length: 4 },
  },
  numPlayers: 2,
  inputWords: 1,                                // 32 bits / player / frame
  simulate: (fields, inputs, _frame) => {
    fields.pos[0] += (inputs[0] & 1) ? 1 : 0;   // player 0 thrust
    fields.pos[1] += (inputs[1] & 1) ? 1 : 0;   // player 1 thrust
  },
});

session.setLocalPlayer(0);

// Every render tick:
session.setLocalInput(readKeyboard());
session.step();                                 // simulate + commit + frame++

// When a remote input arrives via your transport:
const wasRollback = session.feedRemoteInput(1, frameNumber, theirInput);
// If `wasRollback`, internals already rewound state and fast-forwarded back
// to the present frame. Render again from session.fields.

How it works

Memory layout

A single ArrayBuffer per createRollback. For each declared field:

flowchart LR
    subgraph BB["pos: Float32Array, length 4, capacity 4 — one allocation"]
        direction LR
        L[live<br/>4 × 4 = 16 B]
        S0[ring slot 0<br/>16 B]
        S1[ring slot 1<br/>16 B]
        S2[ring slot 2<br/>16 B]
        S3[ring slot 3<br/>16 B]
        L --- S0 --- S1 --- S2 --- S3
    end
    User["rb.fields.pos<br/>(Float32Array view into 'live')"] -.-> L
    PeekView["rb.peek(0).pos<br/>(Float32Array view into the latest slot)"] -.-> S2

All views are constructed once at createRollback and reused forever. The live slice is the slice your user code mutates. Each ring slot is a stable typed-array view into the same backing buffer.

Commit and rollback are memcpys

sequenceDiagram
    participant App
    participant RB as Rollback
    participant Mem as ArrayBuffer

    Note over App,Mem: createRollback — one allocation
    App->>RB: rb.fields.pos[0] = 42
    App->>RB: rb.commit()
    RB->>Mem: ring[head].pos.set(live.pos)   // memcpy
    Note over RB: head++, depth++

    App->>RB: rb.fields.pos[0] = 999
    App->>RB: rb.rollback()
    RB->>Mem: live.pos.set(ring[head].pos)    // memcpy reverse
    Note over RB: head--, depth--
    App->>RB: rb.fields.pos[0]    // 42

A commit is one TypedArray.set() per field. The JIT compiles this down to a memcpy of the full field bytes — no per-element loop in user code, no per-element function calls inside V8.

Session = Rollback + Input ring + Fast-forward

The Session layer owns a Rollback plus three small Uint32 buffers:

| Buffer | Size | Purpose | |---|---|---| | inputRing | capacity × numPlayers × inputWords | Per-frame inputs, indexed by (slot, player, word). Predicted slots are written here too, so misprediction detection is a comparison. | | confirmedMask | capacity × Uint32 | One bit per player per slot: set ⇒ input is real, not predicted. | | lastConfirmedInput | numPlayers × inputWords | Used by the default predictor (last-input). |

step() fills inputs (real where confirmed, predicted otherwise), calls your simulate, commits, advances the frame. feedRemoteInput() writes the input, compares with what was there, and if a misprediction is detected against a past frame, rolls back to that point and fast-forwards. All of this is zero-alloc — the input scratch buffer is pre-allocated once.

sequenceDiagram
    participant App
    participant S as Session
    participant Sim as your simulate()

    loop Every frame
        App->>S: setLocalInput(myButtons)
        App->>S: step()
        S->>S: fillInputs(frame) — real or predicted
        S->>Sim: simulate(fields, inputs, frame)
        Sim-->>S: state mutated in place
        S->>S: rb.commit()
        Note over S: frame++
    end

    Note over App,S: Network: late input arrives
    App->>S: feedRemoteInput(player=1, frame=42, actual)
    alt prediction matched
        S-->>App: false  (no rollback)
    else prediction wrong
        S->>S: rb.rollback(currentFrame − 42 + 1)
        loop fast-forward
            S->>Sim: simulate(fields, inputs, f)
            S->>S: rb.commit()
        end
        S-->>App: true  (rolled back & resimulated)
    end

Case study: a 60 Hz 4 000-entity simulation

A worst-realistic-case stress: 4 000 entities × 2 Float32Array fields (position, velocity) × 1 000-byte Uint8Array of flags. 60-frame rollback window. We benchmark three strategies on identical hardware (Node 22, M-class CPU class machine, --expose-gc):

| # | Strategy | ms/commit | Heap Δ / 10k commits | vs best | |---|---|---:|---:|---:| | A | lite-rollback (single ArrayBuffer, TypedArray.set per field) | 0.0017 | 13 KB | — | | B | Naive: structuredClone(world) each frame, ring of clones | 4.8 | 220 MB | 2 800× | | C | Per-field Float32Array.slice() each frame (allocates fresh) | 0.014 | 41 MB | 8.2× |

Strategy B (the one most rollback tutorials demonstrate) burns 22 MB of garbage per second at 60 fps. The GC tries to keep up, and your frame-time graph develops the unmistakable rollback-netcode sawtooth. Strategy C looks fast in micro-benchmarks but still allocates ~2 MB/sec on a 4 000-entity loop — enough to trigger periodic GC scavenges.

Strategy A allocates once, at session construction. The hot loop produces zero garbage forever after.

%%{init: {"theme":"dark"}}%%
xychart-beta
    title "ms per commit at 4 000 entities — lower is better (log scale)"
    x-axis ["A: lite-rollback", "C: per-field slice", "B: structuredClone"]
    y-axis "ms (log)" 0.001 --> 10
    bar [0.0017, 0.014, 4.8]
%%{init: {"theme":"dark"}}%%
xychart-beta
    title "Heap delta per 10 000 commits (KB, log scale) — lower is better"
    x-axis ["A: lite-rollback", "C: per-field slice", "B: structuredClone"]
    y-axis "KB (log)" 1 --> 1000000
    bar [13, 41000, 220000]

When this matters

| Scenario | State size | Rollback depth | Naive (structuredClone) | lite-rollback | |---|---:|---:|---|---| | Tic-tac-toe / async puzzles | 100 B | 0–1 | fine | overkill | | Fighting game (Tekken-style) | ~4 KB | 7–15 | borderline | fine | | Top-down shooter | ~40 KB | 15–30 | GC stutter at high entity counts | fine | | Bullet hell / particle netcode | ~400 KB | 30–60 | breaks at 60 fps | fine | | Physics-driven platformer | ~100 KB | 8–15 | breaks above ~200 bodies | fine |

Rule of thumb: when total snapshot size × rollback depth exceeds ~1 MB, allocation-based rollback ships stutter. lite-rollback's cost is independent of depth — it's always one memcpy per field per commit.


API reference

createRollback({ capacity, fields }) → Rollback

| Arg | Type | Description | |---|---|---| | capacity | number | Ring depth. Must be a power of two in [1, 2²⁰] — the hot path uses & against capacity − 1 instead of %, turning a 10–15-cycle idiv into a 1-cycle and. Snap to the next power of two when sizing your window; at 60 Hz, the natural picks are 8 (~133 ms tolerance), 16, 32, or 64 (~1 s). | | fields | Record<string, FieldSpec> | Named fields. At least one required. |

FieldSpec

| Field | Type | Description | |---|---|---| | type | Float32Array \| Float64Array \| Int32Array \| Uint32Array \| Int16Array \| Uint16Array \| Int8Array \| Uint8Array \| Uint8ClampedArray | Typed-array constructor. | | length | number | Element count. Must be a positive integer. |

Rollback instance

| Member | Type | Description | |---|---|---| | capacity | number | As passed. | | arrayBuffer | ArrayBuffer | The single backing allocation. | | byteLength | number | Total bytes (Σ (capacity + 1) × length × bpe). | | fields | Record<string, TypedArray> | Live views — what your code mutates. | | depth() | () => number | Commits available (0..capacity). | | head() | () => number | Slot index of the most recent commit, or −1 if depth = 0. | | commit() | () => void | Snapshot live to ring. Zero alloc. | | rollback(n=1) | (n?: number) => void | Restore live to state from n commits ago AND discard those n commits. Throws if n > depth(). | | peek(n=0) | (n?: number) => Record<string, TypedArray> | Stable, read-only view of the n-th most recent commit. Treat as read-only — mutating it corrupts your ring. | | reset() | () => void | Clears head and depth. Does not zero the backing buffer. |

createSession(opts) → Session

| Arg | Type | Description | |---|---|---| | capacity | number | Ring depth. Typical: 8 (fighter), 30 (action), 60 (physics with high jitter). | | fields | Record<string, FieldSpec> | See above. | | numPlayers | number | 1..32. | | inputWords | number (default 1) | Uint32 words per player per frame. 1..16. | | simulate | (fields, inputs, frame) => void | Must be deterministic. Mutates fields in place. | | predict | (player, frame, scratch, outOff) => void optional | Custom predictor. Defaults to "repeat last confirmed input". |

Session instance

| Member | Type | Description | |---|---|---| | capacity numPlayers inputWords | number | As passed. | | fields | Record<string, TypedArray> | Live state — same surface as Rollback. | | arrayBuffer | ArrayBuffer | Backing buffer for the state ring (not the input ring). | | frame() | () => number | Frames simulated so far. The next step() will simulate frame frame(). | | depth() | () => number | Same as the underlying rollback. | | localPlayer | number (read/write via setLocalPlayer) | Which player setLocalInput targets. | | setLocalPlayer(p) | (p: number) => void | | | setLocalInput(input) | (input: number \| Uint32Array) => void | Confirms local player's input for the current frame. | | setInput(player, frame, input) | (p: number, f: number, input: number \| Uint32Array) => void | Confirms a specific player/frame. Frame must be in [frame − capacity + 1, frame]. | | step() | () => void | Fill inputs → simulate(...) → commit → frame++. Zero alloc. | | feedRemoteInput(player, frame, input) | (p, f, input) => boolean | Accept a remote input for some past frame. Returns true iff a rollback + fast-forward happened. | | commit() rollback(n) peek(n) reset() | | Pass-through to the underlying rollback. reset() additionally zeroes live fields and clears the input ring. |

Helpers

| Export | Signature | Use | |---|---|---| | checksum(buf, seed=0) | (TypedArray \| ArrayBuffer, number?) => number (Uint32) | Fast non-cryptographic hash over a byte range. Zero alloc. | | stateChecksum(rb, seed=0) | (Rollback \| Session, number?) => number (Uint32) | Hash across all fields of a rollback's live state. Send this to a peer; if it differs, you've desynced. | | createPRNG(seed?) | (number?) => { seed, state, setState, int32, float, range } | Deterministic xorshift32. Ergonomic API. State is a single Uint32 — snapshot it in a Uint32Array(1) field and it rolls back for free. | | nextPRNG(state, index) | (Uint32Array, number) => number | Pure-function variant for tight inner loops. Inlinable, monomorphic, no closure or property-lookup overhead per call. Pair with a Uint32Array(1) rollback field for snapshot-safe RNG inside simulate. | | assertTransport(t) | (any) => true \| throws | Runtime contract check for sister-package transports. | | VERSION | '1.0.0' | | | CONSTANTS | { CAPACITY_MIN, CAPACITY_MAX, MAX_PLAYERS, INPUT_WORDS_MAX } | |

Writing a fast simulate

The library guarantees its own hot path is allocation-free. Whether your simulate adds garbage is on you. Two patterns worth internalising:

Destructure fields once at the top. V8 builds the live fields object by adding each declared field as a property during createRollback. Repeated fields.pos[i] accesses inside a tight loop force V8 to walk the hidden-class chain for every read. Pulling each typed array out once into a local hoists the lookup outside the loop and gives V8 a monomorphic call site:

// DO — one property lookup per field, per frame:
simulate: (fields, inputs) => {
  const { pos, vel, hp } = fields;
  for (let i = 0; i < pos.length; i += 2) {
    pos[i]     += vel[i];
    pos[i + 1] += vel[i + 1];
    if (hp[i >> 1] <= 0) { /* ... */ }
  }
},

// DON'T — N property lookups per inner-loop iteration:
simulate: (fields, inputs) => {
  for (let i = 0; i < fields.pos.length; i += 2) {
    fields.pos[i]     += fields.vel[i];
    fields.pos[i + 1] += fields.vel[i + 1];
    if (fields.hp[i >> 1] <= 0) { /* ... */ }
  }
},

Use nextPRNG over createPRNG().int32() in hot loops. The closure-based createPRNG is ergonomic; the pure-function nextPRNG(state, 0) is faster because each call is a single inlinable function with no this and no property resolution:

import { nextPRNG } from '@zakkster/lite-rollback';

simulate: (fields, inputs) => {
  const { rng, pos } = fields;
  for (let i = 0; i < pos.length; i++) {
    const r = nextPRNG(rng, 0);     // single state byte; participates in rollback
    pos[i] += (r & 0x3) - 1;        // ±1 random walk
  }
},

Errors

| Situation | Throws | |---|---| | capacity not in [1, 2²⁰] | RangeError | | capacity not a power of two | RangeError | | Missing/empty fields | RangeError / TypeError | | Unsupported type on a field | TypeError | | field.length not a positive integer | RangeError | | rollback(n) with n < 1 or n > depth() | RangeError | | peek(n) with n < 0 or n ≥ depth() | RangeError | | setInput with frame outside [frame − capacity + 1, frame] | RangeError | | feedRemoteInput for a future frame | RangeError | | setLocalInput(number) when inputWords > 1 | TypeError |

The hot path itself (commit, rollback within bounds, step, peek, feedRemoteInput) does no validation beyond bound checks. If you misuse a typed array directly (e.g. write to rb.fields.pos[10_000_000]), you'll get a silent no-op in non-strict mode and an out-of-bounds throw in strict — neither lite-rollback can prevent.


Benchmarks

Node CLI

node --expose-gc bench/throughput.js
node --expose-gc bench/rollback-depth.js
# or: npm run bench

Both write to stdout and produce bench/bench-results.json. --expose-gc is required for accurate heap numbers.

Sample numbers (Node 22, x64, indicative)

Throughput

| Operation | State | Throughput | ns/op | |---|---|---:|---:| | commit | 16 entities, 3 fields | 9 Mops/s | 110 | | commit | 512 entities, 4 fields | 4.3 Mops/s | 230 | | commit | 4 000 entities | 0.67 Mops/s | 1 500 | | rollback(1) | 256 entities | 4.6 Mops/s | 220 | | peek(k) | any | 45 Mops/s | 22 | | Session.step | 2 players, 128 entities | 5.6 Mops/s | 180 |

Frames/sec at various rollback depths (256 entities, real per-tick work)

| Rollback depth | ms/frame | frames/sec | % of 60 fps budget | |---:|---:|---:|---:| | 0 (no rollback) | 0.009 | 105 000 | 0.05 % | | 1 | 0.020 | 51 000 | 0.12 % | | 8 | 0.014 | 74 000 | 0.08 % | | 16 | 0.016 | 62 000 | 0.10 % | | 50 | 0.022 | 45 000 | 0.13 % |

A 50-frame rollback is more than a fighting game ever does. Even there we're consuming under 0.13 % of a 60 fps frame budget. The library itself is not your bottleneck — your simulate function is.


Testing (for clients & QA)

Three levels. Run them in order; each catches a different class of regression.

1. Unit tests — "does the library behave?"

npm test

Runs 97 deterministic assertions across 9 files using the built-in node:test runner (no test framework dependency):

| File | What's tested | |---|---| | ring.test.js | construction, validation, capacity bounds (incl. power-of-two enforcement), peek/rollback throws, reset, internal-handle isolation | | snapshot.test.js | commit/rollback round-trips for every supported type, wraparound (incl. 2048-commit stress), interleaved patterns, peek stability | | session.test.js | step + frame counter, setInput validation, setLocalPlayer, depth caps, reset | | rollback.test.js | misprediction detection, fast-forward correctness, byte-identical state vs. baseline, depth boundaries | | predictor.test.js | default last-input predictor, custom predictor injection, multi-word inputs, custom predictor not invoked for confirmed inputs | | checksum.test.js | hash determinism, sensitivity to byte changes, ArrayBuffer / view interop, Session fast path, fallback path | | transport.test.js | assertTransport contract verification (positive & negative) | | sync.test.js | frame() advancement, frame-advantage formula, depth caps, peek through Session | | determinism.test.js | identical input streams → byte-identical state; PRNG state round-trip; nextPRNG matches createPRNG | | allocation.test.js | heap Δ budgets for 100 000 commits, peeks, steps, rollback avalanches, checksum, stateChecksum, nextPRNG |

Clean run ends with 97 passed. The allocation tests require --expose-gc -- they skip cleanly without it (npm test) and run under npm run test:gc.

2. Benchmarks — "does it perform as claimed?"

npm run bench

bench/throughput.js runs the six scenarios in the table above. bench/rollback-depth.js measures cost as a function of misprediction distance. Exit code is always 0; the failing signal is the numbers.

3. Visual smoke test — "does it desync in the real world?"

The Pong demo in @zakkster/lite-rollback-webrtc is the integration test. Two browser windows connect, play a deterministic Pong, and assert that their stateChecksum stays equal frame-by-frame after a forced 200 ms simulated lag.


Sister packages: transport

lite-rollback core ships no transport. Use these:

| Package | Transport | Use case | |---|---|---| | @zakkster/lite-rollback-local | BroadcastChannel (same-machine) | Tests, single-window two-tab demos, hot-reload-safe local dev | | @zakkster/lite-rollback-webrtc | RTCPeerConnection + RTCDataChannel | Production. Browser-to-browser, no server in the data path |

Both implement the same shape (send, onMessage, close) and both ship as single-file ESM with a Pong demo. Whatever you use, you can verify the contract with:

import { assertTransport } from '@zakkster/lite-rollback';
assertTransport(myTransport);   // throws if invalid

A reference implementation of an in-memory transport for unit tests:

function makePair() {
  let aHandler = null, bHandler = null;
  const a = {
    send(payload) { if (bHandler) bHandler(payload, 'a'); },
    onMessage(h)  { aHandler = h; },
    close()       { aHandler = bHandler = null; },
  };
  const b = {
    send(payload) { if (aHandler) aHandler(payload, 'b'); },
    onMessage(h)  { bHandler = h; },
    close()       { aHandler = bHandler = null; },
  };
  return [a, b];
}

Browser & engine compatibility

The library uses only standard ArrayBuffer / typed-array APIs, so it works wherever ES2015+ works.

| Target | Core | Sister: local | Sister: webrtc | |---|---|---|---| | Chrome / Edge 80+ | ✅ | ✅ (BroadcastChannel) | ✅ (DataChannel) | | Firefox 79+ | ✅ | ✅ | ✅ | | Safari 15+ (iOS 15+) | ✅ | ✅ | ✅ | | Node 18+ | ✅ | ⚠️ (BroadcastChannel needs worker_threads polyfill) | ❌ (no WebRTC) | | Bun / Deno | ✅ | partial | varies |

If you need Node-side multiplayer for tests, use the reference in-memory transport above — BroadcastChannel works in Node 21+ natively but only across worker_threads, not main-to-main.


Edge cases & guarantees

Behaviours the test suite pins down:

  • commit() is O(Σ field bytes), not O(capacity). It writes only to the slot at the new head. The cost is independent of how deep the ring is.
  • rollback(n) is O(n × Σ field bytes) when used through Session (one rollback + n fast-forward steps). At the core level it's O(Σ field bytes) — one memcpy. Most of the cost in real apps is your simulate running n times during fast-forward, not the rollback itself.
  • peek(n) is O(1) and zero-alloc. It returns a stable reference; the same Float32Array view that's there before any commit. Do not mutate — that's a write into the ring slot, which corrupts your history.
  • commit wraps cleanly. Once depth() === capacity, further commits overwrite the oldest slot. depth() saturates at capacity; it does not become capacity + 1.
  • reset() does not zero the backing buffer. Old data remains in memory until overwritten by future commits. This is intentional — zeroing 1 MB on every reset would defeat the point of the library. Session.reset() does additionally zero live fields, because "new match" semantically implies a clean baseline.
  • Predictions are written to the input ring as they're made. This is what makes misprediction detection a simple comparison. If your custom predictor is non-deterministic (don't do that), feedRemoteInput will report mismatches based on whatever the predictor said the first time.
  • feedRemoteInput for a frame older than frame − depth() + 1 is accepted (input ring updated, last-confirmed updated) but does not rollback — the state needed to redo it is gone. The return value tells you (false).
  • The Transport contract is the minimum surface, not the maximum. Implementations may add peer, latency, etc. — assertTransport only checks for the required three methods.
  • All fields share one ArrayBuffer per Rollback / Session. Two sessions with the same shape own different buffers — they cannot accidentally share state.

FAQ

Why not just structuredClone(world) per frame? At 60 fps × 30-frame depth × any non-trivial state, you're generating tens of MB/sec of garbage. The GC does its best, but the result is sawtooth frame times and visible stutter. lite-rollback's whole purpose is that there is no garbage — the cost of a snapshot is independent of state complexity.

My simulate allocates. Does that defeat the point? For your own state, yes. If you write const tmp = [a, b, c] in your simulate, every frame allocates an Array and you're back to a garbage problem. lite-rollback guarantees its hot path is allocation-free; making your hot path allocation-free is on you. Hoist scratch buffers, use typed arrays for any per-frame working memory, and npm run test:allocation in your own project to verify.

Can capacity change after construction? No. That's the whole point — one allocation, fixed size. If your worst-case rollback depth grows, build a new session and copy the live state. In practice, pick capacity for your worst tolerable RTT: at 60 fps, 30 frames ≈ 500 ms of one-way latency tolerance. Most fighting games target 7–15.

What about float determinism? JavaScript guarantees IEEE 754 double-precision results for + − × ÷, but not for transcendentals (Math.sin, Math.exp) across engines. If your simulation uses them, two clients on different browsers can desync even with identical inputs. Mitigations: (a) use only + − × ÷ and bitwise ops in your simulate, or (b) ship deterministic LUTs for the transcendentals you need, or (c) accept the desync and rely on stateChecksum to detect + reset.

How do I include a PRNG? Declare a Uint32Array(1) field and use createPRNG(). Before your simulate, rng.setState(fields.rng[0]); after, fields.rng[0] = rng.state(). The PRNG state then participates in commit/rollback as a normal field.

Why FNV-style checksum and not CRC32 / xxhash? Speed + zero-alloc + single-file. CRC32 would need a 256-entry lookup table (allocates or imports). xxhash is much faster but a meaningful amount of code. The included checksum runs at ~5 GB/s on the heap-test machine, which is fast enough that you can hash 64 KB of state per frame and still spend <0.1 ms on it. Use a real hash (crypto.subtle.digest) if you need cryptographic strength — though if you need that for a game, you have a different problem.

Does it work in a Web Worker? Yes. ArrayBuffer + typed arrays are exactly what Transferable was made for. You can run your simulate in a worker and transfer the backing buffer to main for rendering — but note that transfers neuter the original. For zero-copy two-way sharing, use SharedArrayBuffer with the appropriate cross-origin headers.

Why is rollback(1) destructive? Because in GGPO-style use, every rollback is followed by re-simulation that overwrites the discarded slots. Non-destructive rollback would force every caller to also do a "forget" step, doubling the API surface for no real-world benefit. If you want pure "peek" semantics, that's what peek(n) is for.


Attribution

Tony Cannon, who described the GGPO algorithm publicly in 2006 and ran the original GGPOnet service. The architecture here — input ring, prediction with rollback on misprediction, last-input as the default predictor — is GGPO's. Any divergence is mine.

Glenn Fiedler, whose Networking for Game Programmers series remains the best plain-English explanation of why deterministic lockstep is hard and rollback is the answer.


License

MIT © Zahary Shinikchiev