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
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.
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
movimento
por 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: true
nas opçõesP2PGameLibrary
: 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 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
playerId
lexicograficamente 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
: sebufferedAmount
exceder o limite, abandonar novas mensagens demovimento
.coalesce-moves
: substituir omovimento
em 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.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
oudrop-moves
e 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 paraauthoritative
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; tratarmaxCapacityReached
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.
Links
- GitHub: aguiran/p2play-js
- npm: @p2play-js/p2p-game