@zakkster/lite-rollback-local
v1.0.0
Published
BroadcastChannel-backed transport for @zakkster/lite-rollback. Same-machine, cross-tab rollback netcode. Ideal for tests, demos, and single-machine two-player play.
Downloads
76
Maintainers
Readme
@zakkster/lite-rollback-local
BroadcastChannel-backed transport for
@zakkster/lite-rollback. Same-machine, cross-tab rollback netcode with no server, no signalling. Open the same page in two browser windows and they're already connected.
npm install @zakkster/lite-rollback @zakkster/lite-rollback-localBuilt for development, smoke tests, and "two friends at one laptop with one window each" multiplayer.
import { createSession } from '@zakkster/lite-rollback';
import { createLocalTransport, connectPeer } from '@zakkster/lite-rollback-local';
const session = createSession({
capacity: 32,
fields: { paddleY: { type: Float32Array, length: 2 } },
numPlayers: 2,
simulate: (f, i) => {
f.paddleY[0] += (i[0] & 1 ? -2 : 0) + (i[0] & 2 ? 2 : 0);
f.paddleY[1] += (i[1] & 1 ? -2 : 0) + (i[1] & 2 ? 2 : 0);
},
});
const transport = createLocalTransport({ channelName: 'my-pong-game' });
const peer = connectPeer({ session, transport, localPlayer: 0 });
// Render loop:
function frame() {
peer.tickLocal(readMyKeyboard());
peer.drain();
session.step();
render(session.fields);
requestAnimationFrame(frame);
}What it provides
| Export | Purpose |
|---|---|
| createLocalTransport({ channelName, peerId? }) | Returns a Transport (send, onMessage, close, peerId) backed by BroadcastChannel. The HTML spec guarantees no self-echo, so messages are sent as raw Uint8Array with zero JS-side wrapper allocation. |
| connectPeer({ session, transport, localPlayer }) | Wires a Session and a Transport together. Returns { tickLocal, drain, close, peerId }. Zero JS-side allocation in steady state -- the incoming-message queue is a flat Uint32Array ring buffer, not an array of objects. |
| packInput(out, frame, player, input, inputWords) | Encode an INPUT message into a pre-allocated Uint8Array. Direct byte writes, no DataView allocation. 10 bytes for inputWords=1. |
| unpackMessageInto(buf, inputWords, scratch, scratchOff?) | Hot-path unpack. Writes decoded fields into a caller-owned Uint32Array. Returns the message type (1..3) or 0 on failure. Use this in render loops. |
| unpackMessage(buf, inputWords) | Convenience wrapper around unpackMessageInto that returns a plain object. Allocates the result; use for tests / non-hot paths only. Non-reentrant. |
| MSG | { INPUT: 1, CHECKSUM: 2, PING: 3 } |
| assertTransport(t) | Re-exported from the core for symmetry. |
Wire protocol
All multi-byte values are little-endian.
INPUT msgType=1 | frame:u32 | player:u8 | input:(u32 x inputWords)
CHECKSUM msgType=2 | frame:u32 | hash:u32
PING msgType=3 | timestamp:u32A 1-inputWord INPUT message is 10 bytes. At 60 Hz x 2 players, that's 1.2 KB/s per peer -- negligible on any reasonable channel.
Allocation guarantees
After construction, the hot loop (tickLocal + drain + session.step) is zero-allocation:
tickLocal:setLocalInputwrites into a pre-allocated input ring,packInputwrites into a pre-allocated send buffer,transport.sendcallsBroadcastChannel.postMessage(the structured-clone copy lives in C++, not JS).- The incoming-message queue inside
connectPeeris oneUint32Array(1024 * (3 + inputWords))allocated at construction. Each received message parses directly into the next slot viaunpackMessageInto. drainreads from the queue and callssession.feedRemoteInput(player, frame, number)-- passing a numericinputforinputWords === 1, or a pre-allocatedUint32Arrayreused across calls forinputWords > 1.
The test suite pins this with npm run test:gc -- 10 000 iterations of (2x tickLocal + 2x drain + 2x step) grows the JS heap by less than 128 KB total.
Backpressure: the receive queue caps at 1024 messages. If it fills, the oldest message is dropped (LRU eviction). At 60 Hz that's ~17 seconds of unread inputs -- you have a different problem if you're hitting this.
Why BroadcastChannel?
- No setup. No signalling, no STUN/TURN, no peer discovery. Just pick a
channelNameand you're connected. - No latency. Cross-tab messages are delivered on the same event loop in most browsers -- perfect for testing.
- Same-origin only. Which makes it ideal for development and totally useless for production internet play. For production, use
@zakkster/lite-rollback-webrtc.
Caveats
- BroadcastChannel is browser-only and Node 22+ at the top level (or Node 21+ inside
worker_threads). For Node-side tests, build an in-memory transport against theTransportcontract -- there's a reference implementation in the core README. - The transport doesn't filter messages by game version. If you open two tabs with different builds of your game on the same
channelName, you'll desync. Include a version byte in yourchannelNameor in the wire format. unpackMessage(the convenience form, notunpackMessageInto) is non-reentrant -- it uses a module-level scratch buffer. Don't call it from inside anunpackMessagecallback. UseunpackMessageIntoif you need reentrancy.
Compatibility
| Target | Supported |
|---|---|
| Chrome / Edge 54+ | yes |
| Firefox 38+ | yes |
| Safari 15.4+ (iOS 15.4+) | yes |
| Node 22+ (top-level BroadcastChannel) | yes |
| Node 21 (worker_threads only) | partial |
| Node <= 20 | no |
| Bun / Deno | recent versions, yes |
Testing
npm test # node:test, alloc tests skip cleanly
npm run test:gc # node:test with --expose-gc, alloc tests run24 tests across wire-protocol round-trips, message-type decoding (INPUT/CHECKSUM/PING), construction validation, connectPeer rollback correctness, multi-word input round-trips, allocation budgets, and a smoke test against the real BroadcastChannel transport (runs in Node 22+ and any modern browser).
License
MIT (c) Zahary Shinikchiev
