@zakkster/lite-rollback-webrtc
v1.0.0
Published
RTCDataChannel-backed transport for @zakkster/lite-rollback. Browser-to-browser, no server in the data path. Includes a minimal manual-signalling helper and a deterministic 2-player Pong demo.
Maintainers
Readme
@zakkster/lite-rollback-webrtc
RTCDataChannel transport for
@zakkster/lite-rollback. Browser-to-browser, no server in the data path. Ships with a manual-signalling helper for LAN play and a deterministic 2-player Pong demo as the reference integration.
npm install @zakkster/lite-rollback @zakkster/lite-rollback-local @zakkster/lite-rollback-webrtcimport { wrapDataChannel, connectPeer } from '@zakkster/lite-rollback-webrtc';
import { createSession } from '@zakkster/lite-rollback';
// You bring the open RTCDataChannel.
const transport = wrapDataChannel(myOpenDataChannel);
const session = createSession({ /* ... */ });
const peer = connectPeer({ session, transport, localPlayer: 0 });
function frame() {
peer.tickLocal(readInputBitfield());
peer.drain();
session.step();
render(session.fields);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);What this package provides
| Export | Purpose |
|---|---|
| wrapDataChannel(dc) | Wrap an already-open RTCDataChannel as a lite-rollback Transport. The only function you actually need. |
| createHost(opts) | Convenience: returns a host-side WebRTC controller with createOffer, acceptAnswer, onOpen, pc. |
| createJoiner(opts) | Joiner-side mirror: acceptOffer, onOpen, pc. |
| connectPeer({...}) | Re-exported from @zakkster/lite-rollback-local: glues a Session + a Transport. |
| packInput, unpackMessage, unpackMessageInto, MSG | Re-exported wire-protocol helpers. Same encoding as the local transport. |
| assertTransport(t) | Re-exported runtime contract check. |
wrapDataChannel is the universal entry point. createHost / createJoiner are convenience for the simplest possible signalling: copy and paste SDP into a text box.
Allocation honesty
This is the only sister package with a non-zero per-message cost. Worth being explicit about:
| Path | Allocation per call |
|---|---|
| transport.send(payload) | 0 bytes. RTCDataChannel.send accepts an ArrayBufferView directly; the SCTP stack copies at the C++ boundary. |
| transport receive (per incoming message) | 1 Uint8Array view (~24 bytes). The WebRTC API delivers each message as a fresh ArrayBuffer owned by the browser; we cannot pre-allocate or pool the underlying buffer. We wrap it in a Uint8Array view before handing it to the handler. The buffer itself is browser-allocated and out of our control. |
The downstream connectPeer parses these bytes directly into a pre-allocated Uint32Array FIFO via unpackMessageInto, so no further per-message garbage is produced. At 60 Hz × 2 players, that's ~120 view-headers per second per peer -- well within any GC budget.
The other two packages (lite-rollback core, lite-rollback-local) are strictly zero-alloc after construction. This package is zero-alloc except for the unavoidable per-receive view -- a fixed cost of the WebRTC API.
Real signalling
The built-in SDP-paste helpers are demoware. For production, use a real signalling channel -- WebSocket, Firebase Realtime Database, Supabase, your own backend, doesn't matter. The pattern:
import { wrapDataChannel } from '@zakkster/lite-rollback-webrtc';
const pc = new RTCPeerConnection({ iceServers: [/* your STUN/TURN */] });
// Host side:
const dc = pc.createDataChannel('rollback', { ordered: false, maxRetransmits: 0 });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendOverSignalling(offer);
const answer = await waitForAnswer();
await pc.setRemoteDescription(answer);
// Once `dc.readyState === 'open'`:
const transport = wrapDataChannel(dc);Joiner side is symmetric: receive offer, call pc.setRemoteDescription, createAnswer, setLocalDescription, send the answer back, wait for ondatachannel, wrap.
Recommended RTCDataChannel options:
| Option | Value | Why |
|---|---|---|
| ordered | false | Inputs are self-contained per-frame; ordering is reconstructed from the frame number inside the payload. Out-of-order delivery is fine. |
| maxRetransmits | 0 | Unreliable mode. A lost input is fine -- the next frame's input message updates the same slot with newer data. Reliability would only add latency. |
Pong demo
Open examples/pong/index.html in a modern browser. Three modes:
| Mode | What it does |
|---|---|
| Local (two tabs) | Opens a BroadcastChannel-backed Session in this tab. Open the page in two tabs, choose P0 in one and P1 in the other, play with yourself. |
| WebRTC · host | Generates an SDP offer. Paste it to your peer; paste their answer back. Real RTCDataChannel. |
| WebRTC · joiner | Paste in the host's offer; copy the generated answer back to them. |
The demo's <script type="importmap"> resolves @zakkster/lite-rollback and @zakkster/lite-rollback-local to jsdelivr CDN URLs, so the demo runs from file:// or any static server with no monorepo checkout and no build step. The @zakkster/lite-rollback-webrtc package itself resolves to a path relative to the demo, which works wherever the demo is served from inside the installed package.
What you'll see
- Frame counter, rollback count, state checksum in the status bar.
- Both peers' checksums stay identical every frame -- that's the determinism contract being verified in real time.
- Open the network tab; throttle to "Slow 3G" -> the game keeps simulating; rollbacks tick up; visuals stay smooth.
Transport contract
All sister packages implement the same shape (verified by @zakkster/lite-rollback's assertTransport):
interface Transport {
send(payload: Uint8Array, peer?: string): void;
onMessage(handler: (payload: Uint8Array, peer?: string) => void): void;
close(): void;
}If you have an RTCDataChannel of any flavour (DTLS, your own QUIC bridge, anything that satisfies the spec interface), wrapDataChannel makes it a Transport. If you have a WebSocket, write your own three-method wrapper -- it's ~30 lines.
Testing
npm test # node:test, 18 cases pass
npm run test:gc # same suite under --expose-gcWhat's tested in Node:
- Wire-protocol round-trips via the re-exports.
wrapDataChannelagainst a mockRTCDataChannelshape (binaryType / readyState / onmessage / send / close): construction validation, send-after-close graceful drop, close clears the handler, multiple delivery modes (ArrayBuffer, Uint8Array, ignored strings).createHost/createJoinerthrow the documented error whenRTCPeerConnectionis unavailable (which is always the case in Node).- End-to-end: two Sessions wired up via two mock-DC-backed
wrapDataChanneltransports throughconnectPeer. The mock dispatches asynchronously viaqueueMicrotask-- matching realRTCDataChannelsemantics, which never deliver synchronously inside.send(). A simulated misprediction triggers a rollback; both sessions converge byte-for-byte.
The real RTCDataChannel transport gets exercised end-to-end by the Pong demo -- that's the visual smoke test that can only run in a browser.
Browser compatibility
| Target | Supported |
|---|---|
| Chrome / Edge 76+ | yes |
| Firefox 78+ | yes |
| Safari 15+ (iOS 15+) | yes |
| Node.js | no (no RTCPeerConnection in stdlib) |
For Node-side testing of the wire protocol, use @zakkster/lite-rollback-local's packInput / unpackMessage -- same encoding, no DOM dependency.
License
MIT (c) Zahary Shinikchiev
