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,
conflictResolution: "timestamp",
});
await multiP2PGame.start();
multiP2PGame.on("playerMove", (id, pos) => {/* rendu */});
Démos
-
Mini-jeu complet : examples/complete
- Testeur de signalisation basique : examples/basic
- Voix sélective entre pairs (talk / listen) : examples/voice — Talk/Listen/Stop par pair, mute global, lecture audio distante (voir Voix)
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
playerIdest 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
playerIddevient l'hôte. Quand l'hôte part, le suivant plus petit est élu et envoie un instantané complet frais.
Séquence de signalisation
Topologie maillage complet
Synchronisation d'état
- Instantanés complets : jointures/migrations, resynchronisation corrective.
- Mises à jour delta : changements de chemin ciblés (approche hybride en pratique).
Cohérence
- Timestamp (par défaut) : Last-Writer-Wins (LWW) par séquence par émetteur.
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
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.
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 utilisent des cercles (2D) ou sphères (3D) de rayon égal. Seul le joueur local est repositionné : les positions distantes font autorité côté réseau et ne sont jamais modifiées localement. Quand le joueur local chevauche un joueur distant, nous calculons le vecteur normalisé entre eux et écartons le joueur local de la totalité de la distance de chevauchement. Cela préserve l'autorité de l'état distant tout en empêchant le joueur local de pénétrer les autres.
// Joueur local L vs joueur distant R, tous deux de rayon r
const dx = R.x - L.x, dy = R.y - L.y, dz = (R.z||0) - (L.z||0);
const dist = Math.max(1e-6, Math.hypot(dx, dy, dz));
const overlap = Math.max(0, 2*r - dist);
const nx = dx / dist, ny = dy / dist, nz = dz / dist;
// Seul L est déplacé, à l'écart de R, du chevauchement complet :
L.x -= nx * overlap; L.y -= ny * overlap; L.z = (L.z||0) - nz * overlap;
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
Résolution de collision (cercle/sphère)
extrapolationMs) pour éviter l'erreur visible.
Networking details
- Stratégies de Backpressure : coalescence/abandons pour canaux saturés.
- Capacité : application de
maxPlayers+ événementmaxCapacityReached. - STUN/TURN : fournir TURN pour réseaux stricts ; utiliser WSS pour signalisation.
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.
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
mouvementpar pair dans la queue, remplaçant les anciens.
const multiP2PGame = new P2PGameLibrary({
signaling,
backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
});
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: trueinP2PGameLibraryoptions: the host removes the leaving player's entries and broadcasts a delta accordingly. - Capacity limit: set
maxPlayersto cap the room size. When capacity is reached, the library will not initiate new connections and will ignore incoming offers; it emitsmaxCapacityReached(maxPlayers)so you can inform the user/UI.
Types Reference
GameLibOptions
type SerializationStrategy = "json" | "binary-min";
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;
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 }> }
Types voix (depuis v0.3.0)
type VoiceMode = "talk" | "listen";
type VoiceState = "connecting" | "connected" | "disconnected" | "failed";
interface VoiceOptions {
mode: VoiceMode;
/** talk uniquement — flux sortant personnalisé ; ignoré pour listen. Par défaut getUserMedia({ audio: true }). */
localStream?: MediaStream;
}
type VoiceEventMap = {
remoteStream: (peerId: string, stream: MediaStream) => void;
remoteStreamRemoved: (peerId: string) => void;
state: (peerId: string, state: VoiceState) => void;
error: (peerId: string, error: Error) => void;
};
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
API Voix (depuis v0.3.0)
readonly voice: VoiceManager // voir la section Voix
voice.start(remotePeerId: string, options: VoiceOptions): Promise<void>
voice.stop(remotePeerId: string): void
voice.stopAll(): void
voice.setMuted(muted: boolean): void
voice.isMuted(): boolean
voice.getActiveLinks(): Array<{ peerId: string; mode: VoiceMode; state: VoiceState }>
voice.on(event, handler): () => void // remoteStream | remoteStreamRemoved | state | error
Voix sélective entre pairs (talk / listen)
Depuis v0.3.0, la librairie prend en charge l'audio pair-à-pair sélectif sur les
mêmes RTCPeerConnections que les DataChannels, via la renégociation
SDP standard. Pas de seconde connexion par paire, pas de serveur média (SFU), et
aucun changement du protocole de signalisation : les offres/réponses de
renégociation réutilisent les enveloppes desc/ice existantes — les
implémentations SignalingAdapter personnalisées et le serveur WS de référence
fonctionnent sans modification (y compris STRICT_ENVELOPES=1).
Modes de lien
| Mode | Local (appelant) | Distant | Cas d'usage |
|---|---|---|---|
talk | sendrecv | sendrecv | Appel bidirectionnel |
listen | recvonly | sendonly | Écoute unidirectionnelle (spectateur, arbitre...) |
Démarrage rapide
// Appel bidirectionnel — seul l'appelant invoque start() ;
// la librairie distante répond automatiquement à la renégociation (aucun appel applicatif requis).
await multiplayer.voice.start("playerB", { mode: "talk" });
// Unidirectionnel : entendre playerB sans envoyer d'audio
await multiplayer.voice.start("playerB", { mode: "listen" });
multiplayer.voice.on("remoteStream", (peerId, stream) => { audioEl.srcObject = stream; });
multiplayer.voice.on("remoteStreamRemoved", (peerId) => { /* détacher l'audio */ });
multiplayer.voice.on("state", (peerId, state) => { /* connecting | connected | disconnected | failed */ });
multiplayer.voice.on("error", (peerId, error) => { /* ex. permission micro refusée */ });
multiplayer.voice.setMuted(true); // couper tout l'audio sortant, sans renégociation
multiplayer.voice.stop("playerB"); // le distant reçoit remoteStreamRemoved + state: disconnected
multiplayer.voice.stopAll();
Fonctionnement (piloté par SDP)
Décisions de conception
| Sujet | Comportement |
|---|---|
| Handshake distant | Géré automatiquement par la librairie : seul l'appelant de voice.start() initie. Le distant répond à la renégociation et attache son micro quand le lien demande son audio (le prompt de permission du navigateur est la barrière de consentement). En cas de refus, la négociation aboutit sans audio et un événement error est émis. |
Résolution de start() | Résolu quand la renégociation SDP est terminée (signalisation revenue à stable avec une direction négociée compatible). Le transport ICE est réutilisé — déjà connecté. remoteStream est émis séparément à l'arrivée de la piste distante. |
Symétrie de stop() | Désactive le transceiver audio localement (direction: "inactive", piste détachée) et renégocie ; le distant détecte le changement via SDP et émet remoteStreamRemoved + state: "disconnected". Les transceivers sont réutilisés au prochain start (pas de croissance des m-lines). |
start() répété | Appeler start() à nouveau sur une paire active remplace le mode précédent (idempotent, pas d'erreur). |
| Glare | Perfect negotiation : la politesse dérive du même ordre déterministe d'identifiants que l'élection d'hôte ; le pair polite fait un rollback, répond, puis rejoue sa propre offre. |
| Cycle de vie du micro | Une seule MediaStreamTrack partagée sur toutes les connexions (pas de clonage). Le micro acquis par la librairie est arrêté (l'indicateur navigateur s'éteint) dès qu'aucun lien n'envoie d'audio. Les localStream fournis par l'application ne sont jamais arrêtés par la librairie. setMuted bascule track.enabled — sans renégociation. |
| ICE / TURN | La voix réutilise les iceServers des options de P2PGameLibrary. Pour les réseaux stricts, un serveur TURN est recommandé (la bande passante audio est supérieure à celle des données de jeu). |
| Nettoyage | peerLeave, stop() et P2PGameLibrary.stop() nettoient l'état voix sans fuite. |
Scénario d'écoute (initiation unilatérale)
A et B sont en appel talk. C — seul — appelle
voice.start(A, { mode: "listen" }) et voice.start(B, { mode: "listen" }).
A et B répondent automatiquement en sendonly, en réutilisant leur piste micro déjà
active : C les entend tous les deux sans être entendu, et A↔B reste bidirectionnel. Aucun appel
applicatif n'est nécessaire côté A ou B.
Dépannage
- Aucun son : les navigateurs exigent un geste utilisateur avant la lecture audio (politique autoplay). Attachez le flux distant à un élément
<audio autoplay>depuis un gestionnaire de clic. - Permission micro refusée :
start(talk)rejette eterrorest émis ; côté distant la négociation aboutit sans son audio. Les DataChannels ne sont jamais affectés. - Safari : la renégociation est prise en charge, mais testez spécifiquement l'autoplay et le flux de permission micro ; Safari est plus strict sur les gestes utilisateur.
- Audio unidirectionnel derrière un NAT strict : configurez un serveur TURN dans
iceServers.
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 }
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
playerIdinitiates the connection by creating the offer; the other answers. This avoids simultaneous offers. - It emits
peerJoin,peerLeave,hostChange, andpingevents, and it forwards decoded network messages asnetMessage. - Backpressure:
off: always send if channel is open.drop-moves: ifbufferedAmountexceeds threshold, drop newmovemessages.coalesce-moves: replace the older queuedmovewith the most recent one.
- Capacity: enforces
maxPlayers(no new inits; ignore extra offers) and emitsmaxCapacityReached(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);
Serialization
- Strategies:
json(string frames) orbinary-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.bufferedAmountand tune backpressure.
Reconnect & UX checklist
- Show reconnecting UI when peers drop; rely on roster to detect returns.
- Host sends a fresh
state_fullafter migration to realign clients. - Optionally enable
cleanupOnPeerLeaveto 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-movesordrop-movesand tunethresholdBytes(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-movesto 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
maxPlayersto protect UX; handlemaxCapacityReachedto 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-minfor 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.
Links
- p2play-js : a p2p gaming lib - Peer-to-peer multiplayer js for games
- GitHub: aguiran/p2play-js
- npm: @p2play-js/p2p-game