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/autoritária), 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): Último-Escritor-Ganha (LWW) por sequência por remetente.
  • Autoritário: aceitar ações apenas da autoridade (host ou id fixo).
Consistência em jogos
No modo timestamp, a última ação por remetente é aceita usando uma regra Último-Escritor-Ganha (LWW): qualquer mensagem cujo seq seja menor que o último visto para esse remetente é ignorada. No modo autoritário, um par (frequentemente um host confiável) aplica todas as ações para prevenir conflitos e trapaças; outros pares enviam intenções e aceitam correções.

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 autoritária.

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

Eventos e API (seleção)

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

Visão geral dos eventos

Evento Assinatura Descrição
playerMove(playerId, position)Movimento aplicado
inventoryUpdate(playerId, items)Inventário atualizado
objectTransfer(from, to, item)Objeto transferido
sharedPayload(from, payload, channel?)Payload genérico recebido
stateSync(state)Snapshot completo recebido
stateDelta(delta)Delta de estado recebido
peerJoin(playerId)Par conectado
peerLeave(playerId)Par desconectado
hostChange(hostId)Novo host
ping(playerId, ms)RTT para par
maxCapacityReached(maxPlayers)Capacidade atingida; novas conexões recusadas

Ciclo de vida & presença

  • Presença: chame announcePresence(playerId) cedo para emitir um movimento inicial para que os pares renderizem o jogador imediatamente.
  • peerJoin/peerLeave: a UI pode mostrar/esconder entidades. Limpeza do lado do host pode ser automatizada habilitando cleanupOnPeerLeave: true nas opções P2PGameLibrary: o host remove as entradas do jogador que sai e transmite um delta adequadamente.
  • Limite de capacidade: defina maxPlayers para limitar o tamanho da sala. Quando a capacidade é atingida, a biblioteca não iniciará novas conexões e ignorará ofertas recebidas; ela emite maxCapacityReached(maxPlayers) para que você possa informar o usuário/UI.

Referência de tipos

GameLibOptions

type SerializationStrategy = "json" | "binary-min";
type SyncStrategy = "full" | "delta"; // consultivo: sem mudança de modo 'hybrid'
type ConflictResolution = "timestamp" | "authoritative";

interface BackpressureOptions {
  strategy?: "off" | "drop-moves" | "coalesce-moves";
  thresholdBytes?: number; // padrão ~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; // consultivo: você decide quando enviar completo 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;
}

Eventos

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

Construtor

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

Ciclo de vida

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 // aplicar interpolação/colisões uma vez

Utilitários de estado

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

APIs de payload

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

Mensagens (transporte)

// União NetMessage (seleção)
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 };

// Serialização
// estratégia: "json" (frames de string) ou "binary-min" (ArrayBuffer UTF-8 JSON)

Adaptador de sinalização

Abstração usada pela biblioteca para trocar SDP/ICE via qualquer backend (WebSocket, REST, etc.).

interface SignalingAdapter {
  localId: string;
  roomId?: string;
  register(): Promise<void>; // juntar-se à sala e receber lista
  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>;
}

Exemplo: adaptador personalizado mínimo (WebSocket)

Uma implementação pequena da interface usando um servidor de sinalização WebSocket simples.

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
      }));
    }
  }
  
  // Uso com a biblioteca
  const signaling = new SimpleWsSignaling('alice', 'sala-1', 'wss://seu-sinal.exemplo');
  await signaling.register();
  const multiP2PGame = new P2PGameLibrary({
    signaling
  });
  await multiP2PGame.start();

Exemplo: adaptador REST + long‑polling

Para ambientes sem WebSockets, use endpoints HTTP e um loop de polling para receber mensagens.

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 & lt;
    void & gt; {
      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 })
      });
    }
  }
    
  // Uso
  const restSignaling = new RestPollingSignaling('alice ','sala - 1 ','https: //seu-sinal.exemplo');
  await restSignaling.register(); const multiP2PGame = new P2PGameLibrary({
      signaling: restSignaling
  }); 
  await multiP2PGame.start();

WebSocketSignaling

Implementação de referência usada nos exemplos; protocolo: { sys:'roster', roster:string[] } transmissões; mensagens direcionadas via to.

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

Formas de mensagem

// Cliente → servidor (registro)
{ roomId: string, from: string, announce: true, kind: 'register' }

// Servidor → clientes (transmissão de lista)
{ sys: 'roster', roomId: string, roster: string[] }

// Cliente → servidor (SDP/ICE, direcionado ou transmitido na sala)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

Veja examples/server/ws-server.mjs.

PeerManager (interno)

  • Mantém uma RTCPeerConnection e um RTCDataChannel por par, conectando os callbacks necessários.
  • Para cada par de pares, o par com o playerId lexicograficamente menor inicia a conexão criando a oferta; o outro responde. Isso evita ofertas simultâneas.
  • Emite eventos peerJoin, peerLeave, hostChange, e ping, e encaminha mensagens de rede decodificadas como netMessage.
  • Contrapressão:
    • off: sempre enviar se o canal estiver aberto.
    • drop-moves: se bufferedAmount exceder o limite, abandonar novas mensagens de movimento.
    • coalesce-moves: substituir o movimento em fila mais antigo pelo mais recente.
  • Capacidade: aplica maxPlayers (sem novas inicializações; ignorar ofertas extras) e emite maxCapacityReached(maxPlayers).

EventBus (interno)

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
}

Você geralmente se inscreve através de P2PGameLibrary.on(), que delega ao bus interno.

PingOverlay

O overlay renderiza um pequeno painel no topo da sua página que rastreia tempos de ida e volta (RTT) para cada par conectado. Ele escuta eventos ping emitidos pela camada de rede e mantém um breve histórico rolante (até ~60 amostras). Use em desenvolvimento para detectar picos, verificar uso de TURN, e comparar pares.

Opções

{
  enabled?: boolean; // padrão false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // padrão 'top-right'
  canvas?: HTMLCanvasElement | null; // forneça seu próprio canvas, ou deixe o overlay criar um
}

Uso

const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// Alternar ligado/desligado em tempo de execução
multiP2PGame.setPingOverlayEnabled(false);
Lendo o gráfico
Cada linha colorida é um par. Valores baixos e planos são bons; padrões de dente de serra ou saltos súbitos sugerem congestionamento ou retransmissão (TURN). Se um par está consistentemente mais alto, considere reequilibrar papéis (ex. evitar torná-lo host).

Serialização

  • Estratégias: json (frames de string) ou binary-min (ArrayBuffer UTF‑8 JSON).
  • Estratégias desconhecidas lançam erro.
interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

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

Exemplos

Host autoritário aplicando intenções

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

Persistindo payloads efêmeros no estado compartilhado

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

Atualizações delta seletivas

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

Referência de eventos

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} deu ${item.id} para ${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(`Sala cheia (${max})`));
  

Notas de produção

  • Provisionar ICE (TURN) e sinalização segura (WSS).
  • Considerar modo autoritário com um host confiável/headless para justiça.
  • Monitorar RTCDataChannel.bufferedAmount e ajustar contrapressão.

Lista de verificação de reconexão & UX

  • Mostrar UI de reconexão quando pares caem; confiar na lista para detectar retornos.
  • Host envia um state_full novo após migração para realinhar clientes.
  • Opcionalmente habilitar cleanupOnPeerLeave para podar estado na saída (apenas host).

Depuração

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

Compatibilidade do navegador

  • Chrome/Firefox/Edge/Safari recentes suportam DataChannels; Safari requer HTTPS/WSS em produção.
  • Implante TURN para redes empresariais/de hotel; espere latência maior quando retransmitido.

Solução de problemas

Conexão WebRTC falha ao estabelecer

  • Conteúdo misto: certifique-se de que sua página e sinalização usam HTTPS/WSS (navegadores bloqueiam WS de páginas HTTPS).
  • TURN faltando: em redes empresariais/de hotel, P2P direto é bloqueado. Forneça credenciais TURN (username/credential) em iceServers.
  • CORS/firewall: seu endpoint de sinalização deve aceitar a origem; verifique regras de proxy reverso e portas abertas (TLS 443).

DataChannel trava (alta latência, entradas atrasadas)

  • Contrapressão: habilite coalesce-moves ou drop-moves e ajuste thresholdBytes (comece em 256–512 KB).
  • Reduzir tamanho de mensagem: prefira deltas; comprima payloads (binary‑min); quantize vetores (ex. mm → cm).
  • Taxa de envio menor: restrinja transmissões de movimento (ex. 30–60 Hz) e confie na interpolação para preencher frames.

Pares fora de sincronia após mudança de host

  • Certifique-se de que o novo host transmita um state_full (a biblioteca dispara isso automaticamente na mudança de host).
  • Clientes devem aplicar o snapshot completo e limpar caches locais (deixar a interpolação se estabilizar por alguns frames).

Problemas específicos do Safari

  • Requer HTTPS/WSS para WebRTC fora do localhost.
  • Verifique se URLs STUN/TURN incluem parâmetros de transporte (ex. ?transport=udp) se necessário pelo seu relay.

Fluxos de trabalho de jogo

Padrões fim-a-fim para conectar rede, consistência e estado para diferentes gêneros de jogo.

1) Arena em tempo real (ação/tiro)

  • Consistência: comece com timestamp; opcionalmente mude para authoritative se você executar um host confiável.
  • Sync: deltas para estado estável; snapshot completo ocasional na migração de host.
  • Contrapressão: coalesce-moves para manter apenas o último movimento.
// Configuração
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();

// Entrada local → transmitir movimento (predição do cliente tratada pelo seu renderizador)
function onInput(vec){
  const pos = getPredictedPosition(vec);
  multiP2PGame.broadcastMove(localId, pos, vec);
}

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

2) RPG cooperativo (inventário, host aplica intenções)

  • Consistência: authoritative (host valida uso de item, portas, baús).
  • Protocolo: clientes enviam intenções (payloads); host muta estado compartilhado e transmite deltas.
// Cliente envia intenções apenas para o host
function usePotion(){
  multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}

// Host trata intenções e muta estado
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) Táticas por turnos (determinístico, snapshots completos)

  • Consistência: host authoritative aplica regras e ordem de turno.
  • Sync: transmitir um pequeno delta por movimento; enviar snapshot completo a cada N turnos para segurança.
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; // rejeitar movimento ilegal
  applyMove(currentState, mv);
  multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});

// A cada 10 turnos, enviar snapshot completo
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);

4) Jogo de festa com lobby (capacidade & migração)

  • Definir maxPlayers para proteger UX; tratar maxCapacityReached para informar o usuário.
  • Usar lista para apresentar o lobby; auto-migrar host na saída.
const multiP2PGame = new P2PGameLibrary({ signaling, maxPlayers: 8 });

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

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

5) Sandbox de mundo aberto (sem limites, eixo Z)

  • Desabilitar limites do mundo; confiar apenas em colisões jogador-vs-jogador.
  • Usar binary-min para ganhos de tamanho de payload se você enviar atualizações frequentes.
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.