Docs
🌐 Language:
Local static HTML @p2play-js/p2p-game Theme

Introduction

@p2play-js/p2p-game is a modular TypeScript library to build browser‑based P2P (WebRTC) multiplayer games. It provides state synchronization (full/delta), consistency strategies (timestamp/authoritative), a minimal WebSocket signaling adapter, movement helpers, host election/migration, and a ping overlay.

Quick start

npm install @p2play-js/p2p-game
import { P2PGameLibrary, WebSocketSignaling } from "@p2play-js/p2p-game";

const signaling = new WebSocketSignaling("playerA", "room-42", "wss://your-ws.example");
const multiP2PGame = new P2PGameLibrary({
  signaling,
  maxPlayers: 4,
  syncStrategy: "delta",
  conflictResolution: "timestamp",
});

await multiP2PGame.start();

multiP2PGame.on("playerMove", (id, pos) => {/* render */});

Demos

Architecture

  • The library uses a WebSocket signaling server to manage rooms, maintain a roster of player identifiers, and route SDP/ICE messages to specific peers.
  • Peers form a full‑mesh: for each pair of peers, the one whose playerId sorts first lexicographically creates the WebRTC offer. This prevents offer collisions.
  • Once DataChannels are established, gameplay messages flow peer‑to‑peer; the signaling server no longer relays application traffic.
  • Host election is deterministic: the smallest playerId becomes the host. When the host leaves, the next smallest is elected and sends a fresh full snapshot.
What is signaling?
Browsers cannot open WebRTC connections without first exchanging metadata (SDP offers/answers and ICE candidates) through an out‑of‑band channel. The signaling server does only that exchange and keeps a room roster; it does not forward gameplay once DataChannels are open.

Signaling sequence

sequenceDiagram participant A as Client A participant S as WS Signaling participant B as Client B A->>S: register {roomId, from, announce} B->>S: register {roomId, from, announce} S-->>A: sys: roster [A,B] S-->>B: sys: roster [A,B] note over A,B: Smallest playerId initiates offer A->>S: kind: desc (offer), to: B S->>B: kind: desc (offer) from A B->>S: kind: desc (answer), to: A S->>A: kind: desc (answer) from B A->>S: kind: ice, to: B B->>S: kind: ice, to: A note over A,B: DataChannel opens → gameplay becomes P2P

Full‑mesh topology

graph LR A[Player A] --- B[Player B] A --- C[Player C] B --- C classDef host fill:#2b79c2,stroke:#2a3150,color:#fff; class A host;

State synchronization

  • Full snapshots: joins/migrations, corrective resync.
  • Delta updates: targeted path changes (hybrid approach in practice).
Full vs Delta
Full snapshots are robust and simple but heavy; deltas are compact and efficient but require a stable state schema. In practice, use deltas most of the time and a full snapshot when peers join or after host migration.

Consistency

  • Timestamp (default): Last‑Writer‑Wins (LWW) by per‑sender sequence.
  • Authoritative: accept actions only from the authority (host or fixed id).
Consistency in games
In timestamp mode, the latest action per sender is accepted using a Last‑Writer‑Wins (LWW) rule: any message whose seq is lower than the last seen for that sender is ignored. In authoritative mode, one peer (often a trusted host) applies all actions to prevent conflicts and cheating; other peers send intents and accept corrections.

Movement

This library aims for smooth but predictable motion under network jitter. It combines interpolation (smooth between known samples) and capped extrapolation (short prediction windows) to hide late updates without diverging too far from ground truth.

Interpolation

When a new remote position is received, we don’t instantly snap to it. Instead, each frame we move a fraction of the remaining distance. The smoothing factor controls that fraction (0..1). Larger values reduce visual lag but can look “floaty”.

// Pseudocode
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec accounts for the extrapolation cap (see below)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// optional Z axis if provided
Tuning smoothing
Start around 0.2–0.3. If motion lags behind inputs, increase; if it oscillates or overshoots, decrease.

Extrapolation (with a cap)

If no fresh update arrived this frame, we temporarily use the last known velocity to project forward. To prevent drift, we cap the projection window with extrapolationMs (for example, 120–140 ms). Past this budget, we stop projecting and wait for the next authoritative update.

Why cap?
Extrapolating too long creates obvious errors (running through walls, teleporting on correction). A short cap hides transient jitter but keeps the view close to truth.

2D vs 3D

Positions and velocities are 2D by default; add z for simple 3D. If you define worldBounds.depth, Z will also be clamped.

World bounds vs open world

With worldBounds we clamp positions to [0..width] and [0..height] (and Z to [0..depth] if provided). For open‑world sandboxes, set ignoreWorldBounds: true to disable all clamping (collisions remain player‑vs‑player only).

Collisions (circles/spheres)

Collisions are handled as symmetric separations between equal‑radius circles (2D) or spheres (3D). When two players overlap, we compute the normalized vector between them and nudge both apart by half the overlap distance. This is simple and stable for casual games.

// Given two players A,B with radius r
            const dx = B.x - A.x, dy = B.y - A.y, dz = (B.z||0) - (A.z||0);
            const dist = Math.max(1e-6, Math.hypot(dx, dy, dz));
            const overlap = Math.max(0, 2*r - dist) / 2;
            const nx = dx / dist, ny = dy / dist, nz = dz / dist;
            A.x -= nx * overlap; A.y -= ny * overlap; A.z = (A.z||0) - nz * overlap;
            B.x += nx * overlap; B.y += ny * overlap; B.z = (B.z||0) + nz * overlap;
            
Complexity note
The naive algorithm checks all pairs (O(n²)). This is fine for small rooms. For crowded scenes, spatial partitioning (grids/quadtrees) can be added at app level.

Flow: movement step

flowchart TD A[Last known state] --> B{New net update?} B -- Yes --> C[Reset timestamps; apply interpolation] B -- No --> D{Extrapolation budget left?} D -- Yes --> E[Project with last velocity * smoothing] D -- No --> F[Hold position] C --> G{World bounds?} E --> G G -- Clamp --> H[Apply clamps] G -- Open world --> I[Skip clamps] H --> J[Resolve collisions] I --> J J --> K[Render]

Collision resolution (circle/sphere)

flowchart TD A(Start) --> B(Compute distance between A and B) B --> C{Is distance less than 2 times radius?} C -- No --> Z(No overlap) C -- Yes --> D(Compute normalized vector from A to B) D --> E(Compute overlap = 2 times radius minus distance) E --> F(Move A by negative normalized vector times overlap divided by 2) E --> G(Move B by positive normalized vector times overlap divided by 2) F --> H(Done) G --> H
Interpolation vs Extrapolation
Interpolation smooths between known positions; extrapolation predicts short‑term motion using velocity when updates are late. Keep extrapolation windows short (extrapolationMs) to avoid visible error.

Networking details

  • Backpressure strategies: coalesce/drops for saturated channels.
  • Capacity: maxPlayers enforcement + maxCapacityReached event.
  • STUN/TURN: provide TURN for strict networks; use WSS for signaling.
About NATs and TURN
Many enterprise/hotel networks block direct P2P. A TURN server relays traffic so peers can still connect, at the cost of extra latency and server bandwidth. Provide TURN credentials in production for reliability.

Backpressure

Backpressure protects the DataChannel from overload. When the channel’s internal send buffer (exposed as RTCDataChannel.bufferedAmount) grows beyond a threshold, you can either momentarily stop sending, drop low‑value messages, or collapse multiple updates into the latest one.

How it works
Every send() increases bufferedAmount until the browser flushes data over the network. If you keep sending faster than the network can deliver, latency explodes and the app stutters. Strategies below mitigate this.

Strategies

  • off: no protection. Use only for tiny, infrequent messages.
  • drop-moves: when above threshold, ignore new move messages (inputs are transient; dropping is often acceptable).
  • coalesce-moves: keep only the latest move per peer in the queue, replacing older ones.
const multiP2PGame = new P2PGameLibrary({
    signaling,
    backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
  });
  
Recommended thresholds
Start with 256–512 KB. If you routinely hit the threshold, reduce your message frequency or payload size (e.g., use deltas, binary‑min, quantize vectors).

Events & API (selection)

  • on('playerMove'), on('inventoryUpdate'), on('objectTransfer')
  • on('stateSync'), on('stateDelta'), on('hostChange'), on('ping')
  • broadcastMove(), updateInventory(), transferItem()
  • broadcastPayload(), sendPayload()
  • setStateAndBroadcast(), announcePresence(), getHostId()

Events overview

Event Signature Description
playerMove(playerId, position)Movement applied
inventoryUpdate(playerId, items)Inventory updated
objectTransfer(from, to, item)Object transferred
sharedPayload(from, payload, channel?)Generic payload received
stateSync(state)Full snapshot received
stateDelta(delta)State delta received
peerJoin(playerId)Peer connected
peerLeave(playerId)Peer disconnected
hostChange(hostId)New host
ping(playerId, ms)RTT to peer
maxCapacityReached(maxPlayers)Capacity reached; new connections refused

Lifecycle & presence

  • Presence: call announcePresence(playerId) early to emit an initial move so peers render the player immediately.
  • peerJoin/peerLeave: the UI can show/hide entities. Host‑side cleanup can be automated by enabling cleanupOnPeerLeave: true in P2PGameLibrary options: the host removes the leaving player's entries and broadcasts a delta accordingly.
  • Capacity limit: set maxPlayers to cap the room size. When capacity is reached, the library will not initiate new connections and will ignore incoming offers; it emits maxCapacityReached(maxPlayers) so you can inform the user/UI.

Types Reference

GameLibOptions

type SerializationStrategy = "json" | "binary-min";
type SyncStrategy = "full" | "delta"; // advisory: no 'hybrid' mode switch
type ConflictResolution = "timestamp" | "authoritative";

interface BackpressureOptions {
  strategy?: "off" | "drop-moves" | "coalesce-moves";
  thresholdBytes?: number; // default ~256KB
}

interface DebugOptions {
  enabled?: boolean;
  onSend?: (info: {
    type: "broadcast" | "send";
    to: string | "all";
    payloadBytes: number;
    delivered: number;
    queued: number;
    serialization: SerializationStrategy;
    timestamp: number;
  }) => void;
}

interface MovementOptions {
  maxSpeed?: number;
  smoothing?: number; // 0..1
  extrapolationMs?: number;
  worldBounds?: { width: number; height: number; depth?: number };
  ignoreWorldBounds?: boolean;
  playerRadius?: number;
}

interface GameLibOptions {
  maxPlayers?: number;
  syncStrategy?: SyncStrategy; // advisory: you decide when to send full vs delta
  conflictResolution?: ConflictResolution;
  authoritativeClientId?: string;
  serialization?: SerializationStrategy;
  iceServers?: RTCIceServer[];
  cleanupOnPeerLeave?: boolean;
  debug?: DebugOptions;
  backpressure?: BackpressureOptions;
  pingOverlay?: { enabled?: boolean; position?: "top-left"|"top-right"|"bottom-left"|"bottom-right"; canvas?: HTMLCanvasElement | null };
  movement?: MovementOptions;
}

Events

type EventMap = {
  playerMove: (playerId: string, position: { x:number; y:number; z?:number }) => void;
  inventoryUpdate: (playerId: string, items: Array<{ id:string; type:string; quantity:number }>) => void;
  objectTransfer: (fromId: string, toId: string, item: { id:string; type:string; quantity:number }) => void;
  stateSync: (state: GlobalGameState) => void;
  stateDelta: (delta: StateDelta) => void;
  peerJoin: (playerId: string) => void;
  peerLeave: (playerId: string) => void;
  hostChange: (hostId: string) => void;
  ping: (playerId: string, ms: number) => void;
  sharedPayload: (from: string, payload: unknown, channel?: string) => void;
  maxCapacityReached: (maxPlayers: number) => void;
};

interface GlobalGameState {
  players: Record<string, { id:string; position:{x:number;y:number;z?:number}; velocity?:{x:number;y:number;z?:number} }>;
  inventories: Record<string, Array<{ id:string; type:string; quantity:number }>>;
  objects: Record<string, { id:string; kind:string; data:Record<string,unknown> }>;
  tick: number;
}

interface StateDelta { tick:number; changes: Array<{ path:string; value:unknown }> }

Delta paths rules

  • Paths are dot‑separated object keys (no array index support).
  • Keep structures shallow and keyed for targeted updates (e.g., objects.chest.42), avoid deep arrays.
// Good: object map
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }

// Not supported: array index path like 'objects[3]' or 'players.list.0'

P2PGameLibrary

Constructor

new P2PGameLibrary(options: GameLibOptions & { signaling: WebSocketSignaling | SignalingAdapter })

Lifecycle

await start(): Promise<void>
on<N extends keyof EventMap>(name: N, handler: EventMap[N]): () => void
getState(): GlobalGameState
getHostId(): string | undefined
setPingOverlayEnabled(enabled: boolean): void
tick(now?: number): void // apply interpolation/collisions once

State utilities

setStateAndBroadcast(selfId: string, changes: Array<{ path:string; value:unknown }>): string[]
broadcastFullState(selfId: string): void
broadcastDelta(selfId: string, paths: string[]): void

Gameplay APIs

announcePresence(selfId: string, position = { x:0, y:0 }): void
broadcastMove(selfId: string, position: {x:number;y:number;z?:number}, velocity?: {x:number;y:number;z?:number}): void
updateInventory(selfId: string, items: Array<{ id:string; type:string; quantity:number }>): void
transferItem(selfId: string, to: string, item: { id:string; type:string; quantity:number }): void

Payload APIs

broadcastPayload(selfId: string, payload: unknown, channel?: string): void
sendPayload(selfId: string, to: string, payload: unknown, channel?: string): void

Messages (transport)

// NetMessage union (selected)
type NetMessage =
  | { t:"move"; from:string; ts:number; seq?:number; position:{x:number;y:number;z?:number}; velocity?:{x:number;y:number;z?:number} }
  | { t:"inventory"; from:string; ts:number; seq?:number; items:Array<{id:string;type:string;quantity:number}> }
  | { t:"transfer"; from:string; ts:number; seq?:number; to:string; item:{id:string;type:string;quantity:number} }
  | { t:"state_full"; from:string; ts:number; seq?:number; state: GlobalGameState }
  | { t:"state_delta"; from:string; ts:number; seq?:number; delta: StateDelta }
  | { t:"payload"; from:string; ts:number; seq?:number; payload: unknown; channel?: string };

// Serialization
// strategy: "json" (string frames) or "binary-min" (ArrayBuffer UTF-8 JSON)

Signaling Adapter

Abstraction used by the library to exchange SDP/ICE via any backend (WebSocket, REST, etc.).

interface SignalingAdapter {
  localId: string;
  roomId?: string;
  register(): Promise<void>; // join room and receive roster
  announce(desc: RTCSessionDescriptionInit, to?: string): Promise<void>;
  onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void): void;
  onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void): void;
  onRoster(cb: (roster: string[]) => void): void;
  sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise<void>;
}

Example: minimal custom adapter (WebSocket)

A tiny implementation of the interface using a plain WebSocket signaling server.

class SimpleWsSignaling implements SignalingAdapter {
    constructor(public localId: string, public roomId: string, private url: string) {
      this.ws = new WebSocket(this.url);
    }
    private ws: WebSocket;
    private rosterCb ? :(list: string[]) = >void;
    private descCb ? :(d: RTCSessionDescriptionInit, from: string) = >void;
    private iceCb ? :(c: RTCIceCandidateInit, from: string) = >void;
  
    async register() : Promise & lt;
    void & gt; {
      await new Promise & lt;
      void & gt; ((resolve) = >{
        this.ws.addEventListener('open', () = >{
          this.ws.send(JSON.stringify({
            kind: 'register',
            roomId: this.roomId,
            from: this.localId,
            announce: true
          }));
          resolve();
        });
      });
      this.ws.addEventListener('message', (ev) = >{
        const msg = JSON.parse(ev.data);
        if (msg.sys === 'roster' && this.rosterCb) this.rosterCb(msg.roster);
        if (msg.kind === 'desc' && this.descCb) this.descCb(msg.payload, msg.from);
        if (msg.kind === 'ice' && this.iceCb) this.iceCb(msg.payload, msg.from);
      });
    }
  
    onRoster(cb: (roster: string[]) = >void) {
      this.rosterCb = cb;
    }
    onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) = >void) {
      this.descCb = cb;
    }
    onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) = >void) {
      this.iceCb = cb;
    }
  
    async announce(desc: RTCSessionDescriptionInit, to ? :string) : Promise & lt;
    void & gt; {
      this.ws.send(JSON.stringify({
        kind: 'desc',
        roomId: this.roomId,
        from: this.localId,
        to,
        payload: desc
      }));
    }
    async sendIceCandidate(candidate: RTCIceCandidateInit, to ? :string) : Promise & lt;
    void & gt; {
      this.ws.send(JSON.stringify({
        kind: 'ice',
        roomId: this.roomId,
        from: this.localId,
        to,
        payload: candidate
      }));
    }
  }
  
  // Usage with the library
  const signaling = new SimpleWsSignaling('alice', 'room-1', 'wss://your-signal.example');
  await signaling.register();
  const multiP2PGame = new P2PGameLibrary({
    signaling
  });
  await multiP2PGame.start();

Example: REST + long‑polling adapter

For environments without WebSockets, use HTTP endpoints and a polling loop to receive messages.

class RestPollingSignaling implements SignalingAdapter {
    constructor(public localId: string, public roomId: string, private baseUrl: string) {}
    private rosterCb ? :(list: string[]) = >void;
    private descCb ? :(d: RTCSessionDescriptionInit, from: string) = >void;
    private iceCb ? :(c: RTCIceCandidateInit, from: string) = >void;
    private polling = false;
  
    async register() : Promise & lt;
    void & gt; {
      await fetch(`$ {
        this.baseUrl
      }
      /register`, {
        method: 'POST', headers: { 'content-type': 'application/json ' },
        body: JSON.stringify({ roomId: this.roomId, from: this.localId, announce: true })
      });
      this.polling = true;
      void this.poll();
    }
    
    private async poll(): Promise<void> {
        while (this.polling) {
            try {
            const res = await fetch(`${this.baseUrl}/poll?roomId=${encodeURIComponent(this.roomId)}&from=${encodeURIComponent(this.localId)}`);
            if (!res.ok) { await new Promise(r => setTimeout(r, 1000)); continue; }
            const msgs = await res.json();
            for (const msg of msgs) {
                if (msg.sys === 'roster ' && this.rosterCb) this.rosterCb(msg.roster);
                if (msg.kind === 'desc ' && this.descCb) this.descCb(msg.payload, msg.from);
                if (msg.kind === 'ice ' && this.iceCb) this.iceCb(msg.payload, msg.from);
                }
            } catch {
            await new Promise(r => setTimeout(r, 1000));
            }
        }
    }
    
    onRoster(cb: (roster: string[]) => void){ this.rosterCb = cb; }
    onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void){ this.descCb = cb; }
    onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void){ this.iceCb = cb; }
    
    async announce(desc: RTCSessionDescriptionInit, to?: string): Promise<void> {
      await fetch(`${this.baseUrl}/send`, {
        method: 'POST ', headers: { 'content - type ': 'application / json ' },
        body: JSON.stringify({ kind:'desc ', roomId: this.roomId, from: this.localId, to, payload: desc })
      });
    }
    async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise<void> {
      await fetch(`${this.baseUrl}/send`, {
        method: 'POST ', headers: { 'content - type ': 'application / json ' },
        body: JSON.stringify({ kind:'ice ', roomId: this.roomId, from: this.localId, to, payload: candidate })
      });
    }
  }
    
  // Usage
  const restSignaling = new RestPollingSignaling('alice ','room - 1 ','https: //your-signal.example');
  await restSignaling.register(); const multiP2PGame = new P2PGameLibrary({
      signaling: restSignaling
  }); 
  await multiP2PGame.start();

WebSocketSignaling

Reference implementation used in examples; protocol: { sys:'roster', roster:string[] } broadcasts; targeted messages via to.

new WebSocketSignaling(localId: string, roomId: string, serverUrl: string)
await signaling.register();
signaling.onRoster((list) => {/* update UI */});
signaling.onRemoteDescription((desc, from) => {/* pass to PeerManager */});
signaling.onIceCandidate((cand, from) => {/* pass to PeerManager */});

Message shapes

// Client → server (register)
{ roomId: string, from: string, announce: true, kind: 'register' }

// Server → clients (roster broadcast)
{ sys: 'roster', roomId: string, roster: string[] }

// Client → server (SDP/ICE, targeted or broadcast in-room)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

See examples/server/ws-server.mjs.

PeerManager (internal)

  • It maintains one RTCPeerConnection and one RTCDataChannel per peer, wiring the necessary callbacks.
  • For each pair of peers, the peer with the lexicographically smaller playerId initiates the connection by creating the offer; the other answers. This avoids simultaneous offers.
  • It emits peerJoin, peerLeave, hostChange, and ping events, and it forwards decoded network messages as netMessage.
  • Backpressure:
    • off: always send if channel is open.
    • drop-moves: if bufferedAmount exceeds threshold, drop new move messages.
    • coalesce-moves: replace the older queued move with the most recent one.
  • Capacity: enforces maxPlayers (no new inits; ignore extra offers) and emits maxCapacityReached(maxPlayers).

EventBus (internal)

class EventBus {
  on<N extends keyof EventMap>(name: N, fn: EventMap[N]): () => void
  off<N extends keyof EventMap>(name: N, fn: EventMap[N]): void
  emit<N extends keyof EventMap>(name: N, ...args: Parameters<EventMap[N]>): void
}

You usually subscribe through P2PGameLibrary.on(), which delegates to the internal bus.

PingOverlay

The overlay renders a tiny dashboard on top of your page that tracks round‑trip times (RTT) to each connected peer. It listens to ping events emitted by the network layer and keeps a short rolling history (up to ~60 samples). Use it in development to spot spikes, verify TURN usage, and compare peers.

Options

{
  enabled?: boolean; // default false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // default 'top-right'
  canvas?: HTMLCanvasElement | null; // provide your own canvas, or let the overlay create one
}

Usage

const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// Toggle on/off at runtime
multiP2PGame.setPingOverlayEnabled(false);
Reading the chart
Each colored line is one peer. Flat low values are good; saw‑tooth patterns or sudden jumps suggest congestion or relay (TURN). If one peer is consistently higher, consider re‑balancing roles (e.g., avoid making them host).

Serialization

  • Strategies: json (string frames) or binary-min (ArrayBuffer UTF‑8 JSON).
  • Unknown strategies throw an error.
interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

function createSerializer(strategy: 'json'|'binary-min' = 'json'): Serializer

Examples

Authoritative host applying intents

const isHost = () => multiP2PGame.getHostId() === localId;
multiP2PGame.on("sharedPayload", (from, payload, channel) => {
  if (!isHost()) return;
  if (channel === "move-intent" && typeof payload === "object") {
    const p = payload as { pos:{x:number;y:number}; vel?:{x:number;y:number} };
    multiP2PGame.broadcastMove(multiP2PGame.getHostId()!, p.pos, p.vel);
  }
});

Persisting ephemeral payloads into shared state

multiP2PGame.on("sharedPayload", (from, payload, channel) => {
  if (channel !== "status") return;
  if (payload && typeof payload === "object" && "hp" in (payload as any)) {
    multiP2PGame.setStateAndBroadcast(multiP2PGame.getHostId()!, [
      { path: `objects.playerStatus.${from}`, value: { id:`playerStatus.${from}`, kind:"playerStatus", data:{ hp:(payload as any).hp } } }
    ]);
  }
});

Selective delta updates

const paths = multiP2PGame.setStateAndBroadcast(localId, [
  { path:"objects.chest.42", value:{ id:"chest.42", kind:"chest", data:{ opened:true } } }
]);
// paths == ["objects.chest.42"]

Event reference

playerMove

game.on('playerMove', (playerId, position) => {
    drawAvatar(playerId, position);
  });
  

inventoryUpdate

game.on('inventoryUpdate', (playerId, items) => {
    ui.updateInventory(playerId, items);
  });
  

objectTransfer

game.on('objectTransfer', (from, to, item) => {
    ui.toast(`${from} gave ${item.id} to ${to}`);
  });
  

sharedPayload

game.on('sharedPayload', (from, payload, channel) => {
    if (channel === 'chat') chat.add(from, (payload as any).text);
  });
  

stateSync

game.on('stateSync', (state) => {
    world.hydrate(state);
  });
  

stateDelta

game.on('stateDelta', (delta) => {
    world.applyDelta(delta);
  });
  

peerJoin / peerLeave

game.on('peerJoin', (id) => ui.addPeer(id));
  game.on('peerLeave', (id) => ui.removePeer(id));
  

hostChange

game.on('hostChange', (hostId) => ui.setHost(hostId));
  

ping

game.on('ping', (id, ms) => ui.setPing(id, ms));
  

maxCapacityReached

game.on('maxCapacityReached', (max) => ui.alert(`Room is full (${max})`));
  

Production notes

  • Provision ICE (TURN) and secure signaling (WSS).
  • Consider authoritative mode with a trusted/headless host for fairness.
  • Monitor RTCDataChannel.bufferedAmount and tune backpressure.

Reconnect & UX checklist

  • Show reconnecting UI when peers drop; rely on roster to detect returns.
  • Host sends a fresh state_full after migration to realign clients.
  • Optionally enable cleanupOnPeerLeave to prune state upon leave (host only).

Debugging

const game = new P2PGameLibrary({
  signaling,
  debug: {
    enabled: true,
    onSend(info){
      console.log('[send]', info.type, 'to', info.to, 'bytes=', info.payloadBytes, 'queued=', info.queued);
    }
  }
});

Browser compatibility

  • Recent Chrome/Firefox/Edge/Safari support DataChannels; Safari requires HTTPS/WSS in production.
  • Deploy TURN for enterprise/hotel networks; expect higher latency when relayed.

Troubleshooting

WebRTC connection fails to establish

  • Mixed content: ensure your page and signaling use HTTPS/WSS (browsers block WS from HTTPS pages).
  • TURN missing: on enterprise/hotel networks, direct P2P is blocked. Provide TURN credentials (username/credential) in iceServers.
  • CORS/firewall: your signaling endpoint must accept the origin; verify reverse proxy rules and open ports (TLS 443).

DataChannel stalls (high latency, inputs delayed)

  • Backpressure: enable coalesce-moves or drop-moves and tune thresholdBytes (start at 256–512 KB).
  • Reduce message size: prefer deltas; compress payloads (binary‑min); quantize vectors (e.g., mm → cm).
  • Lower send rate: throttle movement broadcasts (e.g., 30–60 Hz) and rely on interpolation to fill frames.

Peers out of sync after host change

  • Ensure the new host broadcasts a state_full (the library triggers this automatically on host change).
  • Clients should apply the full snapshot and clear local caches (let interpolation settle for a few frames).

Safari specific issues

  • Requires HTTPS/WSS for WebRTC outside localhost.
  • Check that STUN/TURN URLs include transport parameters (e.g., ?transport=udp) if needed by your relay.

Game workflows

End‑to‑end patterns to wire networking, consistency and state for different game genres.

1) Real‑time arena (action/shooter)

  • Consistency: start with timestamp; optionally switch to authoritative if you run a trusted host.
  • Sync: deltas for steady‑state; occasional full snapshot on host migration.
  • Backpressure: coalesce-moves to keep only the latest movement.
// Setup
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();

// Local input → broadcast movement (client prediction handled by your renderer)
function onInput(vec){
  const pos = getPredictedPosition(vec);
  multiP2PGame.broadcastMove(localId, pos, vec);
}

multiP2PGame.on('playerMove', (id, pos) => renderPlayer(id, pos));

2) Co‑op RPG (inventory, host applies intents)

  • Consistency: authoritative (host validates item use, doors, chests).
  • Protocol: clients send intents (payloads); host mutates shared state and broadcasts deltas.
// Client sends intents to host only
function usePotion(){
  multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}

// Host handles intents and mutates state
const isHost = () => multiP2PGame.getHostId() === localId;
multiP2PGame.on('sharedPayload', (from, payload, channel) => {
  if (!isHost() || channel !== 'intent') return;
  if ((payload as any).action === 'use-item') {
    const inv = getInventoryAfterUse(from, (payload as any).itemId);
    multiP2PGame.setStateAndBroadcast(localId, [ { path: `inventories.${from}`, value: inv } ]);
  }
});

3) Turn‑based tactics (deterministic, full snapshots)

  • Consistency: authoritative host enforces rules and turn order.
  • Sync: broadcast a small delta per move; send a full snapshot every N turns for safety.
interface TurnMove { unitId:string; to:{x:number;y:number} }

multiP2PGame.on('sharedPayload', (from, payload, channel) => {
  if (channel !== 'turn-move' || multiP2PGame.getHostId() !== localId) return;
  const mv = payload as TurnMove;
  const ok = validateMove(currentState, from, mv);
  if (!ok) return; // reject illegal move
  applyMove(currentState, mv);
  multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});

// Every 10 turns, send a full snapshot
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);

4) Party game with lobby (capacity & migration)

  • Set maxPlayers to protect UX; handle maxCapacityReached to inform the user.
  • Use roster to present the lobby; auto‑migrate host on leave.
const multiP2PGame = new P2PGameLibrary({ signaling, maxPlayers: 8 });

multiP2PGame.on('maxCapacityReached', (max) => showToast(`Room is full (${max})`));

multiP2PGame.on('hostChange', (host) => updateLobbyHost(host));

5) Open‑world sandbox (no bounds, Z axis)

  • Disable world bounds; rely on player‑vs‑player collisions only.
  • Use binary-min for payload size wins if you ship frequent updates.
const multiP2PGame = new P2PGameLibrary({
  signaling,
  serialization: 'binary-min',
  movement: { ignoreWorldBounds: true, playerRadius: 20, smoothing: 0.25, extrapolationMs: 140 }
});

Glossary

  • SDP: Session Description Protocol; describes media/data session parameters used by WebRTC.
  • ICE: Interactive Connectivity Establishment; discovers network routes between peers (via STUN/TURN).
  • STUN: Server that helps a client learn its public address; used for NAT traversal.
  • TURN: Relay server that forwards traffic when direct P2P is not possible.
  • DataChannel: WebRTC bi‑directional data transport used for gameplay messages.
  • LWW: Last‑Writer‑Wins; conflict resolution where the latest update wins based on a per‑sender sequence.
  • P2P: Peer-to-peer is a network where gaming devices connect directly to each other without a central server. Each player's device communicates with others in the game session, sharing game data and updates directly between players. Common in multiplayer games for reduced latency and server costs