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
-
Mini-jeu complet : examples/complete
- Testeur de signalisation basique : examples/basic
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.
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
- 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).
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
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.
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;
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
mouvement
par 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()
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 optionsP2PGameLibrary
: 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 émetmaxCapacityReached(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 }
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
, etping
, et il transfère les messages réseau décodés commenetMessage
. - Backpressure :
off
: toujours envoyer si le canal est ouvert.drop-moves
: sibufferedAmount
dépasse le seuil, abandonner les nouveaux messages demouvement
.coalesce-moves
: remplacer l'ancienmouvement
en queue par le plus récent.
- Capacité : applique
maxPlayers
(pas de nouvelles inits ; ignorer les offres supplémentaires) et émetmaxCapacityReached(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);
Serialization
- Stratégies :
json
(frames de chaîne) oubinary-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
oudrop-moves
et ajusterthresholdBytes
(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 versauthoritative
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érermaxCapacityReached
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.
Links
- GitHub : aguiran/p2play-js
- npm : @p2play-js/p2p-game