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
-
Mini-jogo completo: examples/complete
- Testador de sinalização básico: examples/basic
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
playerIdordena 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
playerIdtorna-se o host. Quando o host sai, o próximo menor é eleito e envia um snapshot completo novo.
Sequência de sinalização
Topologia de malha completa
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).
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).
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
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.
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;
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
Resolução de colisão (círculo/esfera)
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+ eventomaxCapacityReached. - STUN/TURN: forneça TURN para redes restritivas; use WSS para sinalização.
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.
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
movimentopor par na fila, substituindo os mais antigos.
const multiP2PGame = new P2PGameLibrary({
signaling,
backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
});
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: truenas opçõesP2PGameLibrary: o host remove as entradas do jogador que sai e transmite um delta adequadamente. - Limite de capacidade: defina
maxPlayerspara limitar o tamanho da sala. Quando a capacidade é atingida, a biblioteca não iniciará novas conexões e ignorará ofertas recebidas; ela emitemaxCapacityReached(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 }
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
playerIdlexicograficamente menor inicia a conexão criando a oferta; o outro responde. Isso evita ofertas simultâneas. - Emite eventos
peerJoin,peerLeave,hostChange, eping, e encaminha mensagens de rede decodificadas comonetMessage. - Contrapressão:
off: sempre enviar se o canal estiver aberto.drop-moves: sebufferedAmountexceder o limite, abandonar novas mensagens demovimento.coalesce-moves: substituir omovimentoem fila mais antigo pelo mais recente.
- Capacidade: aplica
maxPlayers(sem novas inicializações; ignorar ofertas extras) e emitemaxCapacityReached(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);
Serialização
- Estratégias:
json(frames de string) oubinary-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.bufferedAmounte 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_fullnovo após migração para realinhar clientes. - Opcionalmente habilitar
cleanupOnPeerLeavepara 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-movesoudrop-movese ajustethresholdBytes(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 paraauthoritativese você executar um host confiável. - Sync: deltas para estado estável; snapshot completo ocasional na migração de host.
- Contrapressão:
coalesce-movespara 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
authoritativeaplica 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
maxPlayerspara proteger UX; tratarmaxCapacityReachedpara 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-minpara 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.
Links
- GitHub: aguiran/p2play-js
- npm: @p2play-js/p2p-game