html @p2play-js/p2p-game — Documentação
Docs
🌐 Idioma:
HTML estático local @p2play-js/p2p-game Tema
⚠️ AVISO: Esta documentação foi traduzida automaticamente do inglês. Podem existir erros. Versão original em inglês

Introdução

@p2play-js/p2p-game é uma biblioteca TypeScript modular para construir jogos multijogador P2P (WebRTC) baseados em navegador. Ela fornece sincronização de estado (completa/delta), estratégias de consistência (timestamp), um adaptador de sinalização WebSocket mínimo, auxiliares de movimento, eleição/migração de host, e uma sobreposição de ping.

Início rápido

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

const signaling = new WebSocketSignaling("jogadorA", "sala-42", "wss://seu-ws.exemplo");
const multiP2PGame = new P2PGameLibrary({
  signaling,
  maxPlayers: 4,
  syncStrategy: "delta",
  conflictResolution: "timestamp",
});

await multiP2PGame.start();

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

Demos

Arquitetura

  • A biblioteca usa um servidor de sinalização WebSocket para gerenciar salas, manter uma lista de identificadores de jogadores, e rotear mensagens SDP/ICE para pares específicos.
  • Os pares formam uma malha completa: para cada par de pares, aquele cujo playerId ordena primeiro lexicograficamente cria a oferta WebRTC. Isso evita colisões de ofertas.
  • Uma vez estabelecidos os DataChannels, as mensagens de jogo fluem ponto-a-ponto; o servidor de sinalização não retransmite mais o tráfego da aplicação.
  • A eleição de host é determinística: o menor playerId torna-se o host. Quando o host sai, o próximo menor é eleito e envia um snapshot completo novo.
O que é sinalização?
Os navegadores não podem abrir conexões WebRTC sem primeiro trocar metadados (ofertas/respostas SDP e candidatos ICE) através de um canal fora de banda. O servidor de sinalização apenas faz essa troca e mantém uma lista da sala; ele não encaminha o gameplay uma vez que os DataChannels estão abertos.

Sequência de sinalização

sequenceDiagram participant A as Cliente A participant S as Sinalização WS participant B as Cliente 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: Menor playerId inicia a oferta A->>S: kind: desc (oferta), to: B S->>B: kind: desc (oferta) from A B->>S: kind: desc (resposta), to: A S->>A: kind: desc (resposta) from B A->>S: kind: ice, to: B B->>S: kind: ice, to: A note over A,B: DataChannel abre → gameplay torna-se P2P

Topologia de malha completa

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

Sincronização de estado

  • Snapshots completos: junções/migrações, ressincronização corretiva.
  • Atualizações delta: mudanças de caminho direcionadas (abordagem híbrida na prática).
Completo vs Delta
Snapshots completos são robustos e simples mas pesados; deltas são compactos e eficientes mas requerem um esquema de estado estável. Na prática, use deltas na maior parte do tempo e um snapshot completo quando pares se juntam ou após migração de host.

Consistência

  • Timestamp (padrão): Last-Writer-Wins (LWW) por sequência por remetente.
Consistência em jogos
No modo timestamp, a ação mais recente por remetente é aceita usando a regra Last-Writer-Wins (LWW): qualquer mensagem cujo seq seja menor que o último valor visto para aquele remetente é ignorada.

Movimento

Esta biblioteca visa movimento suave mas previsível sob jitter de rede. Ela combina interpolação (suave entre amostras conhecidas) e extrapolação limitada (janelas de predição curtas) para esconder atualizações tardias sem divergir muito da verdade absoluta.

Interpolação

Quando uma nova posição remota é recebida, não mudamos instantaneamente para ela. Em vez disso, a cada frame movemos uma fração da distância restante. O fator smoothing controla essa fração (0..1). Valores maiores reduzem o atraso visual mas podem parecer "flutuantes".

// Pseudocódigo
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec considera o limite de extrapolação (veja abaixo)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// eixo Z opcional se fornecido
Ajustando smoothing
Comece por volta de 0.2–0.3. Se o movimento atrasa as entradas, aumente; se oscila ou ultrapassa, diminua.

Extrapolação (com limite)

Se nenhuma atualização nova chegar neste frame, usamos temporariamente a última velocidade conhecida para projetar adiante. Para prevenir deriva, limitamos a janela de projeção com extrapolationMs (por exemplo, 120–140 ms). Passado esse orçamento, paramos de projetar e aguardamos a próxima atualização de rede.

Por que limitar?
Extrapolar por muito tempo cria erros óbvios (atravessar paredes, teletransportar na correção). Um limite curto esconde jitter transitório mas mantém a visualização próxima à verdade.

2D vs 3D

Posições e velocidades são 2D por padrão; adicione z para 3D simples. Se você definir worldBounds.depth, Z também será limitado.

Limites do mundo vs mundo aberto

Com worldBounds limitamos posições a [0..width] e [0..height] (e Z a [0..depth] se fornecido). Para sandboxes de mundo aberto, defina ignoreWorldBounds: true para desabilitar todos os limites (colisões permanecem apenas jogador-vs-jogador).

Colisões (círculos/esferas)

Colisões são tratadas como separações simétricas entre círculos (2D) ou esferas (3D) de raio igual. Quando dois jogadores se sobrepõem, calculamos o vetor normalizado entre eles e empurramos ambos para longe pela metade da distância de sobreposição. Isso é simples e estável para jogos casuais.

// Dados dois jogadores A,B com raio 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;
            
Nota de complexidade
O algoritmo ingênuo verifica todos os pares (O(n²)). Isso é adequado para salas pequenas. Para cenas lotadas, particionamento espacial (grades/quadtrees) pode ser adicionado ao nível da aplicação.

Fluxo: etapa de movimento

flowchart TD A[Último estado conhecido] --> B{Nova atualização de rede?} B -- Sim --> C[Redefinir timestamps; aplicar interpolação] B -- Não --> D{Orçamento de extrapolação restante?} D -- Sim --> E[Projetar com última velocidade * smoothing] D -- Não --> F[Manter posição] C --> G{Limites do mundo?} E --> G G -- Limitar --> H[Aplicar limites] G -- Mundo aberto --> I[Pular limites] H --> J[Resolver colisões] I --> J J --> K[Renderizar]

Resolução de colisão (círculo/esfera)

flowchart TD A(Início) --> B(Calcular distância entre A e B) B --> C{Distância menor que 2 vezes o raio?} C -- Não --> Z(Sem sobreposição) C -- Sim --> D(Calcular vetor normalizado de A para B) D --> E(Calcular sobreposição = 2 vezes raio menos distância) E --> F(Mover A por vetor normalizado negativo vezes sobreposição dividido por 2) E --> G(Mover B por vetor normalizado positivo vezes sobreposição dividido por 2) F --> H(Concluído) G --> H
Interpolação vs Extrapolação
Interpolação suaviza entre posições conhecidas; extrapolação prevê movimento de curto prazo usando velocidade quando atualizações chegam tarde. Mantenha janelas de extrapolação curtas (extrapolationMs) para evitar erro visível.

Detalhes de rede

  • Estratégias de contrapressão: coalescência/abandonos para canais saturados.
  • Capacidade: aplicação de maxPlayers + evento maxCapacityReached.
  • STUN/TURN: forneça TURN para redes restritivas; use WSS para sinalização.
Sobre NATs e TURN
Muitas redes empresariais/de hotel bloqueiam P2P direto. Um servidor TURN retransmite tráfego para que os pares ainda possam se conectar, ao custo de latência extra e largura de banda do servidor. Forneça credenciais TURN em produção para confiabilidade.

Contrapressão

Contrapressão protege o DataChannel de sobrecarga. Quando o buffer de envio interno do canal (exposto como RTCDataChannel.bufferedAmount) cresce além de um limite, você pode momentaneamente parar de enviar, abandonar mensagens de baixo valor, ou colapsar múltiplas atualizações na mais recente.

Como funciona
Cada send() aumenta bufferedAmount até que o navegador despeje dados pela rede. Se você continuar enviando mais rápido do que a rede pode entregar, a latência explode e a aplicação gagueja. As estratégias abaixo mitigam isso.

Estratégias

  • off: nenhuma proteção. Use apenas para mensagens pequenas e infrequentes.
  • drop-moves: quando acima do limite, ignore novas mensagens de movimento (entradas são transitórias; abandonar é frequentemente aceitável).
  • coalesce-moves: mantenha apenas o último movimento por par na fila, substituindo os mais antigos.
const multiP2PGame = new P2PGameLibrary({
    signaling,
    backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
  });
  
Limites recomendados
Comece com 256–512 KB. Se você rotineiramente atingir o limite, reduza sua frequência de mensagem ou tamanho de payload (ex. use deltas, binary‑min, quantize vetores).

Events & API (selection)

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

Events overview

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

Lifecycle & presence

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

Types Reference

GameLibOptions

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

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

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

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

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

Events

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

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

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

Regras de caminhos delta

  • Caminhos são chaves de objeto separadas por pontos (sem suporte para índices de array).
  • Mantenha estruturas rasas e com chaves para atualizações direcionadas (ex. objects.chest.42), evite arrays profundos.
// Bom: mapa de objetos
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }

// Não suportado: caminho de índice de array como 'objects[3]' ou 'players.list.0'

P2PGameLibrary

Constructor

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

Lifecycle

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

State utilities

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

Gameplay APIs

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

Payload APIs

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

Messages (transport)

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

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

Signaling Adapter

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

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

Example: minimal custom adapter (WebSocket)

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

class SimpleWsSignaling implements SignalingAdapter {
  constructor(public localId: string, public roomId: string, private url: string) {
    this.ws = new WebSocket(this.url);
  }
  private ws: WebSocket;
  private rosterCb?: (list: string[]) => void;
  private descCb?: (d: RTCSessionDescriptionInit, from: string) => void;
  private iceCb?: (c: RTCIceCandidateInit, from: string) => void;

  async register(): Promise<void> {
    await new Promise<void>((resolve) => {
      this.ws.addEventListener("open", () => {
        this.ws.send(JSON.stringify({ kind: "register", roomId: this.roomId, from: this.localId, announce: true }));
        resolve();
      });
    });
    this.ws.addEventListener("message", (ev) => {
      const msg = JSON.parse(ev.data);
      if (msg.sys === "roster" && this.rosterCb) this.rosterCb(msg.roster);
      if (msg.kind === "desc" && this.descCb) this.descCb(msg.payload, msg.from);
      if (msg.kind === "ice" && this.iceCb) this.iceCb(msg.payload, msg.from);
    });
  }

  onRoster(cb: (roster: string[]) => void){ this.rosterCb = cb; }
  onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void){ this.descCb = cb; }
  onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void){ this.iceCb = cb; }

  async announce(desc: RTCSessionDescriptionInit, to?: string): Promise<void> {
    this.ws.send(JSON.stringify({ kind: "desc", roomId: this.roomId, from: this.localId, to, payload: desc }));
  }
  async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise<void> {
    this.ws.send(JSON.stringify({ kind: "ice", roomId: this.roomId, from: this.localId, to, payload: candidate }));
  }
}

// Usage with the library
const signaling = new SimpleWsSignaling("alice", "room-1", "wss://your-signal.example");
await signaling.register();
const game = new P2PGameLibrary({ signaling });
await game.start();

Example: REST + long‑polling adapter

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

class RestPollingSignaling implements SignalingAdapter {
  constructor(public localId: string, public roomId: string, private baseUrl: string) {}
  private rosterCb?: (list: string[]) => void;
  private descCb?: (d: RTCSessionDescriptionInit, from: string) => void;
  private iceCb?: (c: RTCIceCandidateInit, from: string) => void;
  private polling = false;

  async register(): Promise<void> {
    await fetch(`${this.baseUrl}/register`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ roomId: this.roomId, from: this.localId, announce: true }),
    });
    this.polling = true;
    void this.poll();
  }

  private async poll(): Promise<void> {
    while (this.polling) {
      try {
        const res = await fetch(`${this.baseUrl}/poll?roomId=${encodeURIComponent(this.roomId)}&from=${encodeURIComponent(this.localId)}`);
        if (!res.ok) { await new Promise((r) => setTimeout(r, 1000)); continue; }
        const msgs = await res.json();
        for (const msg of msgs) {
          if (msg.sys === "roster" && this.rosterCb) this.rosterCb(msg.roster);
          if (msg.kind === "desc" && this.descCb) this.descCb(msg.payload, msg.from);
          if (msg.kind === "ice" && this.iceCb) this.iceCb(msg.payload, msg.from);
        }
      } catch {
        await new Promise((r) => setTimeout(r, 1000));
      }
    }
  }

  onRoster(cb: (roster: string[]) => void){ this.rosterCb = cb; }
  onRemoteDescription(cb: (desc: RTCSessionDescriptionInit, from: string) => void){ this.descCb = cb; }
  onIceCandidate(cb: (candidate: RTCIceCandidateInit, from: string) => void){ this.iceCb = cb; }

  async announce(desc: RTCSessionDescriptionInit, to?: string): Promise<void> {
    await fetch(`${this.baseUrl}/send`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ kind: "desc", roomId: this.roomId, from: this.localId, to, payload: desc }),
    });
  }
  async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise<void> {
    await fetch(`${this.baseUrl}/send`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ kind: "ice", roomId: this.roomId, from: this.localId, to, payload: candidate }),
    });
  }
}

// Usage
const restSignaling = new RestPollingSignaling("alice", "room-1", "https://your-signal.example");
await restSignaling.register();
const game2 = new P2PGameLibrary({ signaling: restSignaling });
await game2.start();

WebSocketSignaling

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

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

Message shapes

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

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

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

See examples/server/ws-server.mjs.

PeerManager (internal)

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

EventBus (internal)

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

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

PingOverlay

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

Options

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

Usage

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

Serialization

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

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

Examples

Host-validated intents (application pattern)

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

Persisting ephemeral payloads into shared state

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

Selective delta updates

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

Event reference

playerMove

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

inventoryUpdate

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

objectTransfer

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

sharedPayload

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

stateSync

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

stateDelta

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

peerJoin / peerLeave

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

hostChange

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

ping

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

maxCapacityReached

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

Production notes

  • Provision ICE (TURN) and secure signaling (WSS).
  • Monitor RTCDataChannel.bufferedAmount and tune backpressure.

Reconnect & UX checklist

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

Debugging

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

Browser compatibility

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

Troubleshooting

WebRTC connection fails to establish

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

DataChannel stalls (high latency, inputs delayed)

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

Peers out of sync after host change

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

Safari specific issues

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

Game workflows

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

1) Real‑time arena (action/shooter)

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

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

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

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

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

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

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

  • Sync: broadcast a small delta per move; send a full snapshot every N turns for safety.
interface TurnMove { unitId:string; to:{x:number;y:number} }

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

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

4) Party game with lobby (capacity & migration)

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

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

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

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

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

Glossário

  • SDP: Session Description Protocol; descreve parâmetros de sessão de mídia/dados usados por WebRTC.
  • ICE: Interactive Connectivity Establishment; descobre rotas de rede entre pares (via STUN/TURN).
  • STUN: Servidor que ajuda um cliente a aprender seu endereço público; usado para travessia NAT.
  • TURN: Servidor relay que encaminha tráfego quando P2P direto não é possível.
  • DataChannel: Transporte de dados bi-direcional WebRTC usado para mensagens de gameplay.
  • LWW: Last‑Writer‑Wins; resolução de conflito onde a última atualização ganha baseada em uma sequência por remetente.