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/autoritaire), 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

  • Horodatage (par défaut) : Dernier-Écrivain-Gagne (LWW) par séquence par expéditeur.
  • Autoritaire : accepter les actions seulement de l'autorité (hôte ou id fixe).
Cohérence dans les jeux
En mode horodatage, la dernière action par expéditeur est acceptée en utilisant une règle Dernier-Écrivain-Gagne (LWW) : tout message dont le seq est inférieur au dernier vu pour cet expéditeur est ignoré. En mode autoritaire, un pair (souvent un hôte de confiance) applique toutes les actions pour éviter les conflits et la triche ; les autres pairs envoient des intentions et acceptent les corrections.

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 autoritaire.

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

Aperçu des événements

Événement Signature Description
playerMove(playerId, position)Mouvement appliqué
inventoryUpdate(playerId, items)Inventaire mis à jour
objectTransfer(from, to, item)Objet transféré
sharedPayload(from, payload, channel?)Charge utile générique reçue
stateSync(state)Instantané complet reçu
stateDelta(delta)Delta d'état reçu
peerJoin(playerId)Pair connecté
peerLeave(playerId)Pair déconnecté
hostChange(hostId)Nouvel hôte
ping(playerId, ms)RTT vers pair
maxCapacityReached(maxPlayers)Capacité atteinte ; nouvelles connexions refusées

Cycle de vie & présence

  • Présence : appelez announcePresence(playerId) tôt pour émettre un mouvement initial afin que les pairs rendent le joueur immédiatement.
  • peerJoin/peerLeave : l'IU peut montrer/cacher les entités. Le nettoyage côté hôte peut être automatisé en activant cleanupOnPeerLeave: true dans les options P2PGameLibrary : l'hôte supprime les entrées du joueur partant et diffuse un delta en conséquence.
  • Limite de capacité : définissez maxPlayers pour plafonner la taille de salle. Quand la capacité est atteinte, la bibliothèque n'initiera pas de nouvelles connexions et ignorera les offres entrantes ; elle émet maxCapacityReached(maxPlayers) pour que vous puissiez informer l'utilisateur/IU.

Types Reference

GameLibOptions

type SerializationStrategy = "json" | "binary-min";
type SyncStrategy = "full" | "delta"; // consultatif : pas de commutateur de mode 'hybrid'
type ConflictResolution = "timestamp" | "authoritative";

interface BackpressureOptions {
  strategy?: "off" | "drop-moves" | "coalesce-moves";
  thresholdBytes?: number; // défaut ~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; // consultatif : vous décidez quand envoyer complet 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;
}

Événements

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

Constructeur

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

Cycle de vie

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 // appliquer interpolation/collisions une fois

Utilitaires d'état

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

APIs de gameplay

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)

// Union NetMessage (sélection)
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 utilisée par la bibliothèque pour échanger SDP/ICE via n'importe quel backend (WebSocket, REST, etc.).

interface SignalingAdapter {
  localId: string;
  roomId?: string;
  register(): Promise<void>; // rejoindre salle et recevoir liste
  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>;
}

Exemple : adaptateur personnalisé minimal (WebSocket)

Une implémentation minuscule de l'interface utilisant un serveur de signalisation WebSocket simple.

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
      }));
    }
  }
  
  // Utilisation avec la bibliothèque
  const signaling = new SimpleWsSignaling('alice', 'salle-1', 'wss://votre-signal.exemple');
  await signaling.register();
  const multiP2PGame = new P2PGameLibrary({
    signaling
  });
  await multiP2PGame.start();

WebSocketSignaling

Implémentation de référence utilisée dans les exemples ; protocole : { sys:'roster', roster:string[] } diffusions ; messages ciblés via to.

new WebSocketSignaling(localId: string, roomId: string, serverUrl: string)
await signaling.register();
signaling.onRoster((list) => {/* mettre à jour IU */});
signaling.onRemoteDescription((desc, from) => {/* passer à PeerManager */});
signaling.onIceCandidate((cand, from) => {/* passer à PeerManager */});

Formes de messages

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

// Serveur → clients (diffusion de liste)
{ sys: 'roster', roomId: string, roster: string[] }

// Client → serveur (SDP/ICE, ciblé ou diffusé en salle)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

Voir examples/server/ws-server.mjs.

PeerManager (interne)

  • Il maintient une RTCPeerConnection et un RTCDataChannel par pair, câblant les callbacks nécessaires.
  • Pour chaque paire de pairs, le pair avec le playerId lexicographiquement plus petit initie la connexion en créant l'offre ; l'autre répond. Cela évite les offres simultanées.
  • Il émet les événements peerJoin, peerLeave, hostChange, et ping, et il transfère les messages réseau décodés comme netMessage.
  • Backpressure :
    • off : toujours envoyer si le canal est ouvert.
    • drop-moves : si bufferedAmount dépasse le seuil, abandonner les nouveaux messages de mouvement.
    • coalesce-moves : remplacer l'ancien mouvement en queue par le plus récent.
  • Capacité : applique maxPlayers (pas de nouvelles inits ; ignorer les offres supplémentaires) et émet maxCapacityReached(maxPlayers).

EventBus (interne)

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
}

Vous vous abonnez habituellement via P2PGameLibrary.on(), qui délègue au bus interne.

PingOverlay

L'overlay rend un petit tableau de bord au-dessus de votre page qui suit les temps d'aller-retour (RTT) vers chaque pair connecté. Il écoute les événements ping émis par la couche réseau et garde un court historique roulant (jusqu'à ~60 échantillons). Utilisez-le en développement pour repérer les pics, vérifier l'usage TURN, et comparer les pairs.

Options

{
  enabled?: boolean; // défaut false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // défaut 'top-right'
  canvas?: HTMLCanvasElement | null; // fournir votre propre canevas, ou laisser l'overlay en créer un
}

Utilisation

const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// Basculer marche/arrêt à l'exécution
multiP2PGame.setPingOverlayEnabled(false);
Lire le graphique
Chaque ligne colorée est un pair. Des valeurs plates basses sont bonnes ; des motifs en dents de scie ou des sauts soudains suggèrent une congestion ou un relais (TURN). Si un pair est constamment plus haut, considérez rééquilibrer les rôles (ex. éviter d'en faire l'hôte).

Serialization

  • Stratégies : json (frames de chaîne) ou binary-min (ArrayBuffer UTF‑8 JSON).
  • Les stratégies inconnues lèvent une erreur.
interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

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

Exemples

Hôte autoritaire appliquant des intentions

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

Persister des charges utiles éphémères dans l'état partagé

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

Mises à jour delta sélectives

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} a donné ${item.id} à ${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(`Salle pleine (${max})`));
  

Production notes

  • Provisionner ICE (TURN) et signalisation sécurisée (WSS).
  • Considérer le mode autoritaire avec un hôte de confiance/sans tête pour l'équité.
  • Surveiller RTCDataChannel.bufferedAmount et ajuster le Backpressure.

Reconnect & UX checklist

  • Afficher IU de reconnexion quand les pairs tombent ; s'appuyer sur la liste pour détecter les retours.
  • L'hôte envoie un state_full frais après migration pour réaligner les clients.
  • Optionnellement activer cleanupOnPeerLeave pour élaguer l'état au départ (hôte seulement).

Débogage

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

Compatibilité navigateur

  • Les Chrome/Firefox/Edge/Safari récents supportent les DataChannels ; Safari requiert HTTPS/WSS en production.
  • Déployer TURN pour réseaux d'entreprise/hôtel ; s'attendre à une latence plus élevée quand relayé.

Troubleshooting

La connexion WebRTC échoue à s'établir

  • Contenu mixte : assurer que votre page et signalisation utilisent HTTPS/WSS (les navigateurs bloquent WS depuis les pages HTTPS).
  • TURN manquant : sur réseaux d'entreprise/hôtel, le P2P direct est bloqué. Fournir des identifiants TURN (username/credential) dans iceServers.
  • CORS/pare-feu : votre endpoint de signalisation doit accepter l'origine ; vérifier les règles de proxy inverse et ouvrir les ports (TLS 443).

DataChannel stagne (haute latence, entrées retardées)

  • Backpressure : activer coalesce-moves ou drop-moves et ajuster thresholdBytes (commencer à 256–512 KB).
  • Réduire taille de message : préférer deltas ; compresser charges utiles (binary‑min) ; quantifier vecteurs (ex. mm → cm).
  • Baisser taux d'envoi : throttler diffusions de mouvement (ex. 30–60 Hz) et s'appuyer sur l'interpolation pour remplir les frames.

Pairs désynchronisés après changement d'hôte

  • S'assurer que le nouvel hôte diffuse un state_full (la bibliothèque déclenche cela automatiquement au changement d'hôte).
  • Les clients devraient appliquer l'instantané complet et vider les caches locaux (laisser l'interpolation se stabiliser quelques frames).

Problèmes spécifiques Safari

  • Requiert HTTPS/WSS pour WebRTC hors localhost.
  • Vérifier que les URLs STUN/TURN incluent des paramètres de transport (ex. ?transport=udp) si nécessaire par votre relais.

Game workflows

Motifs bout-en-bout pour câbler réseau, cohérence et état pour différents genres de jeu.

1) Arène temps réel (action/tir)

  • Cohérence : commencer avec timestamp ; optionnellement basculer vers authoritative si vous exécutez un hôte de confiance.
  • Sync : deltas pour l'état stable ; instantané complet occasionnel sur migration d'hôte.
  • Backpressure : coalesce-moves pour garder seulement le dernier mouvement.
// Configuration
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();

// Entrée locale → diffuser mouvement (prédiction client gérée par votre moteur de rendu)
function onInput(vec){
  const pos = getPredictedPosition(vec);
  multiP2PGame.broadcastMove(localId, pos, vec);
}

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

2) RPG coopératif (inventaire, hôte applique intentions)

  • Cohérence : authoritative (hôte valide usage d'objet, portes, coffres).
  • Protocole : clients envoient intentions (charges utiles) ; hôte mute état partagé et diffuse deltas.
// Client envoie intentions à l'hôte seulement
function usePotion(){
  multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}

// Hôte gère intentions et mute état
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) Tactique au tour par tour (déterministe, instantanés complets)

  • Cohérence : hôte authoritative applique règles et ordre des tours.
  • Sync : diffuser un petit delta par mouvement ; envoyer instantané complet tous les N tours pour sécurité.
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; // rejeter mouvement illégal
  applyMove(currentState, mv);
  multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});

// Tous les 10 tours, envoyer instantané complet
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);

4) Jeu de fête avec lobby (capacité & migration)

  • Définir maxPlayers pour protéger UX ; gérer maxCapacityReached pour informer l'utilisateur.
  • Utiliser liste pour présenter le lobby ; auto-migrer hôte au départ.
const multiP2PGame = new P2PGameLibrary({ signaling, maxPlayers: 8 });

multiP2PGame.on('maxCapacityReached', (max) => showToast(`Salle pleine (${max})`));

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

5) Bac à sable monde ouvert (pas de limites, axe Z)

  • Désactiver limites du monde ; s'appuyer sur collisions joueur-vs-joueur seulement.
  • Utiliser binary-min pour gains de taille de payload si vous expédiez des mises à jour fréquentes.
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.