html @p2play-js/p2p-game — Documentation
Docs
🌐 Langue:
HTML statique local @p2play-js/p2p-game Thème
⚠️ ATTENTION : Cette documentation a été traduite automatiquement de l'anglais. Des erreurs peuvent subsister. Version anglaise originale

Introduction

@p2play-js/p2p-game est une bibliothèque TypeScript modulaire pour créer des jeux multijoueurs P2P (WebRTC) basés sur le navigateur. Elle fournit la synchronisation d'état (complète/delta), des stratégies de cohérence (horodatage), un adaptateur de signalisation WebSocket minimal, des assistants de mouvement, l'élection/migration d'hôte, et un overlay de ping.

Démarrage rapide

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

const signaling = new WebSocketSignaling("joueurA", "salle-42", "wss://votre-ws.exemple");
const multiP2PGame = new P2PGameLibrary({
  signaling,
  maxPlayers: 4,
  syncStrategy: "delta",
  conflictResolution: "timestamp",
});

await multiP2PGame.start();

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

Démos

Architecture

  • La bibliothèque utilise un serveur de signalisation WebSocket pour gérer les salles, maintenir une liste des identifiants des joueurs, et router les messages SDP/ICE vers des pairs spécifiques.
  • Les pairs forment un maillage complet : pour chaque paire de pairs, celui dont le playerId est lexicographiquement le premier crée l'offre WebRTC. Cela évite les collisions d'offres.
  • Une fois les DataChannels établis, les messages de jeu circulent de pair à pair ; le serveur de signalisation ne relaie plus le trafic applicatif.
  • L'élection d'hôte est déterministe : le plus petit playerId devient l'hôte. Quand l'hôte part, le suivant plus petit est élu et envoie un instantané complet frais.
Qu'est-ce que la signalisation ?
Les navigateurs ne peuvent pas ouvrir de connexions WebRTC sans d'abord échanger des métadonnées (offres/réponses SDP et candidats ICE) via un canal hors bande. Le serveur de signalisation ne fait que cet échange et maintient une liste de salle ; il ne transfère pas le gameplay une fois les DataChannels ouverts.

Séquence de signalisation

sequenceDiagram participant A as Client A participant S as Signalisation WS 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: Le plus petit playerId initie l'offre A->>S: kind: desc (offre), to: B S->>B: kind: desc (offre) from A B->>S: kind: desc (réponse), to: A S->>A: kind: desc (réponse) from B A->>S: kind: ice, to: B B->>S: kind: ice, to: A note over A,B: DataChannel s'ouvre → le gameplay devient P2P

Topologie maillage complet

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

Synchronisation d'état

  • Instantanés complets : jointures/migrations, resynchronisation corrective.
  • Mises à jour delta : changements de chemin ciblés (approche hybride en pratique).
Complet vs Delta
Les instantanés complets sont robustes et simples mais lourds ; les deltas sont compacts et efficaces mais nécessitent un schéma d'état stable. En pratique, utilisez les deltas la plupart du temps et un instantané complet quand les pairs rejoignent ou après la migration d'hôte.

Cohérence

  • Timestamp (par défaut) : Last-Writer-Wins (LWW) par séquence par émetteur.
Cohérence en jeu
En mode timestamp, la dernière action par émetteur est acceptée selon une règle Last-Writer-Wins (LWW) : tout message dont le seq est inférieur au dernier vu pour cet émetteur est ignoré.

Mouvement

Cette bibliothèque vise un mouvement fluide mais prévisible sous la gigue réseau. Elle combine l'interpolation (lisse entre échantillons connus) et l'extrapolation plafonnée (fenêtres de prédiction courtes) pour masquer les mises à jour tardives sans diverger trop de la vérité terrain.

Interpolation

Quand une nouvelle position distante est reçue, nous ne basculons pas instantanément vers elle. Au lieu de cela, chaque frame nous déplaçons une fraction de la distance restante. Le facteur smoothing contrôle cette fraction (0..1). Des valeurs plus importantes réduisent le retard visuel mais peuvent paraître "flottantes".

// Pseudocode
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec tient compte du plafond d'extrapolation (voir ci-dessous)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// axe Z optionnel si fourni
Réglage du lissage
Commencez autour de 0.2–0.3. Si le mouvement traîne derrière les entrées, augmentez ; s'il oscille ou dépasse, diminuez.

Extrapolation (avec plafond)

Si aucune mise à jour fraîche n'arrive cette frame, nous utilisons temporairement la dernière vélocité connue pour projeter vers l'avant. Pour éviter la dérive, nous plafonnons la fenêtre de projection avec extrapolationMs (par exemple, 120–140 ms). Passé ce budget, nous arrêtons de projeter et attendons la prochaine mise à jour réseau.

Pourquoi plafonner ?
Extrapoler trop longtemps crée des erreurs évidentes (traverser les murs, téléportation lors de la correction). Un plafond court masque la gigue transitoire mais garde la vue proche de la vérité.

2D vs 3D

Les positions et vélocités sont 2D par défaut ; ajoutez z pour la 3D simple. Si vous définissez worldBounds.depth, Z sera aussi bridé.

Limites du monde vs monde ouvert

Avec worldBounds nous bridons les positions à [0..width] et [0..height] (et Z à [0..depth] si fourni). Pour les bacs à sable de monde ouvert, définissez ignoreWorldBounds: true pour désactiver tout bridage (les collisions restent joueur-vs-joueur seulement).

Collisions (cercles/sphères)

Les collisions sont gérées comme des séparations symétriques entre cercles (2D) ou sphères (3D) de rayon égal. Quand deux joueurs se chevauchent, nous calculons le vecteur normalisé entre eux et poussons les deux à l'écart de la moitié de la distance de chevauchement. C'est simple et stable pour les jeux occasionnels.

// Étant donné deux joueurs A,B avec rayon 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;
            
Note de complexité
L'algorithme naïf vérifie toutes les paires (O(n²)). C'est correct pour les petites salles. Pour les scènes bondées, le partitionnement spatial (grilles/quadtrees) peut être ajouté au niveau app.

Flux : étape de mouvement

flowchart TD A[Dernier état connu] --> B{Nouvelle mise à jour réseau?} B -- Oui --> C[Réinitialiser horodatages; appliquer interpolation] B -- Non --> D{Budget d'extrapolation restant?} D -- Oui --> E[Projeter avec dernière vélocité * lissage] D -- Non --> F[Maintenir position] C --> G{Limites du monde?} E --> G G -- Brider --> H[Appliquer bridages] G -- Monde ouvert --> I[Ignorer bridages] H --> J[Résoudre collisions] I --> J J --> K[Rendu]

Résolution de collision (cercle/sphère)

flowchart TD A(Début) --> B(Calculer distance entre A et B) B --> C{Distance < 2 fois le rayon?} C -- Non --> Z(Pas de chevauchement) C -- Oui --> D(Calculer vecteur normalisé de A vers B) D --> E(Calculer chevauchement = 2 fois rayon moins distance) E --> F(Déplacer A par vecteur normalisé négatif fois chevauchement divisé par 2) E --> G(Déplacer B par vecteur normalisé positif fois chevauchement divisé par 2) F --> H(Terminé) G --> H
Interpolation vs Extrapolation
L'interpolation lisse entre positions connues ; l'extrapolation prédit le mouvement à court terme en utilisant la vélocité quand les mises à jour sont tardives. Gardez les fenêtres d'extrapolation courtes (extrapolationMs) pour éviter l'erreur visible.

Networking details

  • Stratégies de Backpressure : coalescence/abandons pour canaux saturés.
  • Capacité : application de maxPlayers + événement maxCapacityReached.
  • STUN/TURN : fournir TURN pour réseaux stricts ; utiliser WSS pour signalisation.
À propos des NAT et TURN
Beaucoup de réseaux d'entreprise/hôtel bloquent le P2P direct. Un serveur TURN relaie le trafic pour que les pairs puissent encore se connecter, au coût de latence supplémentaire et bande passante serveur. Fournir des identifiants TURN en production pour la fiabilité.

Backpressure

Le Backpressure protège le DataChannel de la surcharge. Quand le tampon d'envoi interne du canal (exposé comme RTCDataChannel.bufferedAmount) croît au-delà d'un seuil, vous pouvez soit momentanément arrêter d'envoyer, abandonner les messages de faible valeur, ou effondrer plusieurs mises à jour en la dernière.

Comment ça marche
Chaque send() augmente bufferedAmount jusqu'à ce que le navigateur évacue les données sur le réseau. Si vous continuez d'envoyer plus vite que le réseau ne peut livrer, la latence explose et l'app bégaie. Les stratégies ci-dessous atténuent cela.

Stratégies

  • off : aucune protection. Utilisez seulement pour messages minuscules et peu fréquents.
  • drop-moves : quand au-dessus du seuil, ignorer les nouveaux messages de mouvement (les entrées sont transitoires ; l'abandon est souvent acceptable).
  • coalesce-moves : garder seulement le dernier mouvement par pair dans la queue, remplaçant les anciens.
const multiP2PGame = new P2PGameLibrary({
    signaling,
    backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
  });
  
Seuils recommandés
Commencez avec 256–512 KB. Si vous atteignez régulièrement le seuil, réduisez votre fréquence de message ou taille de charge utile (ex. utilisez deltas, binary‑min, quantifiez vecteurs).

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";

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;
    channel: "reliable" | "unreliable";
    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;
  serialization?: SerializationStrategy;
  iceServers?: RTCIceServer[];
  cleanupOnPeerLeave?: boolean;
  debug?: DebugOptions;
  backpressure?: BackpressureOptions;
  timing?: { pendingOfferTimeoutMs?: number; pingIntervalMs?: number };
  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 }> }

Règles des chemins delta

  • Les chemins sont des clés d'objet séparées par des points (pas de support d'index de tableau).
  • Gardez les structures peu profondes et avec clés pour les mises à jour ciblées (ex. objects.chest.42), évitez les tableaux profonds.
// Bon : carte d'objets
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }

// Non supporté : chemin d'index de tableau comme 'objects[3]' ou 'players.list.0'

P2PGameLibrary

Constructor

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

Lifecycle

await start(): Promise<void>
stop(): 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<void> {
    await new Promise<void>((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<void> {
    this.ws.send(JSON.stringify({ kind: "desc", roomId: this.roomId, from: this.localId, to, payload: desc }));
  }
  async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise<void> {
    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 game = new P2PGameLibrary({ signaling });
await game.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<void> {
    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 game2 = new P2PGameLibrary({ signaling: restSignaling });
await game2.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

Host-validated intents (application pattern)

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).
  • 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)

  • 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)

  • 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)

  • 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 }
});

Glossaire

  • SDP : Session Description Protocol ; décrit les paramètres de session média/données utilisés par WebRTC.
  • ICE : Interactive Connectivity Establishment ; découvre les routes réseau entre pairs (via STUN/TURN).
  • STUN : Serveur qui aide un client à apprendre son adresse publique ; utilisé pour traversée NAT.
  • TURN : Serveur relais qui transfère le trafic quand le P2P direct n'est pas possible.
  • DataChannel : Transport de données bi-directionnel WebRTC utilisé pour messages de gameplay.
  • LWW : Last‑Writer‑Wins ; résolution de conflit où la dernière mise à jour gagne basée sur une séquence par expéditeur.