Docs
🌐 Idioma:
HTML estático local @p2play-js/p2p-game Tema
⚠️ ADVERTENCIA: Esta documentación ha sido traducida automáticamente del inglés. Pueden existir errores. Versión original en inglés

Introducción

@p2play-js/p2p-game es una biblioteca TypeScript modular para construir juegos multijugador P2P (WebRTC) basados en navegador. Proporciona sincronización de estado (completa/delta), estrategias de consistencia (marca de tiempo/autoritaria), un adaptador de señalización WebSocket mínimo, ayudantes de movimiento, elección/migración de host, y una superposición de ping.

Inicio rápido

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

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

await multiP2PGame.start();

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

Demos

Arquitectura

  • La biblioteca utiliza un servidor de señalización WebSocket para gestionar salas, mantener una lista de identificadores de jugadores y enrutar mensajes SDP/ICE a pares específicos.
  • Los pares forman una malla completa: para cada par de pares, aquel cuyo playerId ordena primero lexicográficamente crea la oferta WebRTC. Esto evita colisiones de ofertas.
  • Una vez establecidos los DataChannels, los mensajes de juego fluyen punto a punto; el servidor de señalización ya no retransmite tráfico de aplicación.
  • La elección de host es determinista: el playerId más pequeño se convierte en host. Cuando el host se va, el siguiente más pequeño es elegido y envía una instantánea completa nueva.
¿Qué es la señalización?
Los navegadores no pueden abrir conexiones WebRTC sin primero intercambiar metadatos (ofertas/respuestas SDP y candidatos ICE) a través de un canal fuera de banda. El servidor de señalización solo hace ese intercambio y mantiene una lista de sala; no reenvía juego una vez que los DataChannels están abiertos.

Secuencia de señalización

sequenceDiagram participant A as Client A participant S as WS Signaling participant B as Client 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: El playerId más pequeño inicia la oferta A->>S: kind: desc (offer), to: B S->>B: kind: desc (offer) from A B->>S: kind: desc (answer), to: A S->>A: kind: desc (answer) from B A->>S: kind: ice, to: B B->>S: kind: ice, to: A note over A,B: DataChannel se abre → el juego se vuelve P2P

Topología de malla completa

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

Sincronización de estado

  • Instantáneas completas: uniones/migraciones, resincronización correctiva.
  • Actualizaciones delta: cambios de ruta específicos (enfoque híbrido en práctica).
Completo vs Delta
Las instantáneas completas son robustas y simples pero pesadas; los deltas son compactos y eficientes pero requieren un esquema de estado estable. En práctica, use deltas la mayoría del tiempo e instantánea completa cuando los pares se unen o después de migración de host.

Consistencia

  • Marca de tiempo (predeterminado): Último‑Escritor‑Gana (LWW) por secuencia por remitente.
  • Autoritario: aceptar acciones solo de la autoridad (host o id fijo).
Consistencia en juegos
En modo marca de tiempo, la última acción por remitente es aceptada usando una regla Último‑Escritor‑Gana (LWW): cualquier mensaje cuyo seq sea menor al último visto para ese remitente es ignorado. En modo autoritario, un par (a menudo un host confiable) aplica todas las acciones para prevenir conflictos y trampas; otros pares envían intenciones y aceptan correcciones.

Movimiento

Esta biblioteca busca movimiento suave pero predecible bajo jitter de red. Combina interpolación (suave entre muestras conocidas) y extrapolación limitada (ventanas de predicción cortas) para ocultar actualizaciones tardías sin alejarse demasiado de la verdad.

Interpolación

Cuando se recibe una nueva posición remota, no cambiamos instantáneamente a ella. En su lugar, cada frame movemos una fracción de la distancia restante. El factor smoothing controla esa fracción (0..1). Valores más grandes reducen el retraso visual pero pueden parecer “flotantes”.

// Pseudocode
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec considera el límite de extrapolación (ver abajo)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// eje Z opcional si se proporciona
Ajustando smoothing
Comience alrededor de 0.2–0.3. Si el movimiento se retrasa con las entradas, aumente; si oscila o se excede, disminuya.

Extrapolación (con límite)

Si no llega ninguna actualización nueva en este frame, usamos temporalmente la última velocidad conocida para proyectar hacia adelante. Para prevenir deriva, limitamos la ventana de proyección con extrapolationMs (por ejemplo, 120–140 ms). Pasado ese presupuesto, dejamos de proyectar y esperamos la siguiente actualización autoritativa.

¿Por qué limitar?
Extrapolar demasiado tiempo crea errores obvios (atravesar paredes, teletransportarse en la corrección). Un límite corto oculta el jitter transitorio pero mantiene la vista cerca de la verdad.

2D vs 3D

Las posiciones y velocidades son 2D por defecto; agregue z para 3D simple. Si define worldBounds.depth, Z también será limitado.

Límites del mundo vs mundo abierto

Con worldBounds limitamos las posiciones a [0..width] y [0..height] (y Z a [0..depth] si se proporciona). Para sandboxes de mundo abierto, establezca ignoreWorldBounds: true para deshabilitar todos los límites (las colisiones permanecen solo jugador‑vs‑jugador).

Colisiones (círculos/esferas)

Las colisiones se manejan como separaciones simétricas entre círculos (2D) o esferas (3D) de radio igual. Cuando dos jugadores se superponen, calculamos el vector normalizado entre ellos y empujamos a ambos separándolos por la mitad de la distancia de superposición. Esto es simple y estable para juegos casuales.

// Given two players A,B with radius 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 complejidad
El algoritmo ingenuo verifica todos los pares (O(n²)). Esto está bien para salas pequeñas. Para escenas llenas, el particionamiento espacial (cuadrículas/quadtrees) se puede agregar a nivel de aplicación.

Flujo: paso de movimiento

flowchart TD A[Último estado conocido] --> B{¿Nueva actualización de red?} B -- Sí --> C[Reiniciar timestamps; aplicar interpolación] B -- No --> D{¿Presupuesto de extrapolación restante?} D -- Sí --> E[Proyectar con última velocidad * smoothing] D -- No --> F[Mantener posición] C --> G{¿Límites del mundo?} E --> G G -- Limitar --> H[Aplicar límites] G -- Mundo abierto --> I[Saltar límites] H --> J[Resolver colisiones] I --> J J --> K[Renderizar]

Resolución de colisión (círculo/esfera)

flowchart TD A(Inicio) --> B(Calcular distancia entre A y B) B --> C{¿Distancia menor que 2 veces el radio?} C -- No --> Z(Sin superposición) C -- Sí --> D(Calcular vector normalizado de A a B) D --> E(Calcular superposición = 2 veces radio menos distancia) E --> F(Mover A por vector normalizado negativo por superposición dividido por 2) E --> G(Mover B por vector normalizado positivo por superposición dividido por 2) F --> H(Terminado) G --> H
Interpolación vs Extrapolación
La interpolación suaviza entre posiciones conocidas; la extrapolación predice movimiento a corto plazo usando velocidad cuando las actualizaciones llegan tarde. Mantenga las ventanas de extrapolación cortas (extrapolationMs) para evitar error visible.

Detalles de red

  • Estrategias de contrapresión: fusión/descartes para canales saturados.
  • Capacidad: aplicación de maxPlayers + evento maxCapacityReached.
  • STUN/TURN: proporcione TURN para redes estrictas; use WSS para señalización.
Acerca de NATs y TURN
Muchas redes empresariales/de hotel bloquean el P2P directo. Un servidor TURN retransmite el tráfico para que los pares aún puedan conectarse, a costa de mayor latencia y ancho de banda del servidor. Proporcione credenciales TURN en producción para mayor fiabilidad.

Contrapresión

La contrapresión protege el DataChannel de la sobrecarga. Cuando el búfer interno de envío del canal (expuesto como RTCDataChannel.bufferedAmount) crece más allá de un umbral, puede detener temporalmente el envío, descartar mensajes de bajo valor o colapsar múltiples actualizaciones en la más reciente.

Cómo funciona
Cada send() incrementa bufferedAmount hasta que el navegador vacía los datos por la red. Si sigue enviando más rápido de lo que la red puede entregar, la latencia explota y la aplicación tartamudea. Las estrategias de abajo mitigan esto.

Estrategias

  • off: sin protección. Úselo solo para mensajes pequeños e infrecuentes.
  • drop-moves: cuando está por encima del umbral, ignore nuevos mensajes de move (las entradas son transitorias; descartar suele ser aceptable).
  • coalesce-moves: mantenga solo el último move por par en la cola, reemplazando los más antiguos.
const multiP2PGame = new P2PGameLibrary({
    signaling,
    backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
  });
  
Umbrales recomendados
Comience con 256–512 KB. Si alcanza rutinariamente el umbral, reduzca su frecuencia de mensajes o el tamaño de la carga útil (p. ej., use deltas, binary‑min, cuantice vectores).

Eventos y API (selección)

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

Resumen de eventos

Evento Firma Descripción
playerMove(playerId, position)Movimiento aplicado
inventoryUpdate(playerId, items)Inventario actualizado
objectTransfer(from, to, item)Objeto transferido
sharedPayload(from, payload, channel?)Carga útil genérica recibida
stateSync(state)Instantánea completa recibida
stateDelta(delta)Delta de estado recibido
peerJoin(playerId)Par conectado
peerLeave(playerId)Par desconectado
hostChange(hostId)Nuevo host
ping(playerId, ms)RTT al par
maxCapacityReached(maxPlayers)Capacidad alcanzada; nuevas conexiones rechazadas

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.

Referencia de tipos

GameLibOptions

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

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

Reglas de rutas delta

  • Paths are dot‑separated object keys (no array index support).
  • Keep structures shallow and keyed for targeted updates (e.g., objects.chest.42), avoid deep arrays.
// Good: object map
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }

// Not supported: array index path like 'objects[3]' or 'players.list.0'

P2PGameLibrary

Constructor

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

Lifecycle

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 // 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

Mensajes (transporte)

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

// Serialización
// estrategia: "json" (frames de cadena) o "binary-min" (ArrayBuffer UTF-8 JSON)

Adaptador de señalización

Abstracción utilizada por la biblioteca para intercambiar SDP/ICE mediante cualquier 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>;
}

Ejemplo: adaptador personalizado mínimo (WebSocket)

Una pequeña implementación de la interfaz usando un servidor de señalización WebSocket.

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 con la biblioteca
  const signaling = new SimpleWsSignaling('alice', 'room-1', 'wss://your-signal.example');
  await signaling.register();
  const multiP2PGame = new P2PGameLibrary({
    signaling
  });
  await multiP2PGame.start();

Ejemplo: adaptador REST + long‑polling

Para entornos sin WebSockets, use endpoints HTTP y un bucle de polling para recibir mensajes.

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 ','room - 1 ','https: //your-signal.example');
  await restSignaling.register(); const multiP2PGame = new P2PGameLibrary({
      signaling: restSignaling
  }); 
  await multiP2PGame.start();

WebSocketSignaling

Implementación de referencia usada en los ejemplos; protocolo: { sys:'roster', roster:string[] } difusiones; mensajes dirigidos vía 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 */});

Formas de mensaje

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

// Servidor → clientes (difusión de lista)
{ sys: 'roster', roomId: string, roster: string[] }

// Cliente → servidor (SDP/ICE, dirigido o difusión en sala)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

Ver examples/server/ws-server.mjs.

PeerManager (interno)

  • Mantiene un RTCPeerConnection y un RTCDataChannel por par, conectando los callbacks necesarios.
  • Para cada par de pares, el par con el playerId lexicográficamente menor inicia la conexión creando la oferta; el otro responde. Esto evita ofertas simultáneas.
  • Emite eventos peerJoin, peerLeave, hostChange y ping, y reenvía mensajes de red decodificados como netMessage.
  • Contrapresión:
    • off: siempre enviar si el canal está abierto.
    • drop-moves: si bufferedAmount excede el umbral, descartar nuevos mensajes de move.
    • coalesce-moves: reemplazar el move en cola más antiguo por el más reciente.
  • Capacidad: aplica maxPlayers (sin nuevas inits; ignora ofertas extra) y 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
}

Normalmente te suscribes mediante P2PGameLibrary.on(), que delega en el bus interno.

PingOverlay

La superposición renderiza un pequeño tablero sobre tu página que rastrea los tiempos de ida y vuelta (RTT) a cada par conectado. Escucha eventos ping emitidos por la capa de red y mantiene un breve historial rodante (hasta ~60 muestras). Úsala en desarrollo para detectar picos, verificar el uso de TURN y comparar pares.

Opciones

{
  enabled?: boolean; // predeterminado false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // predeterminado 'top-right'
  canvas?: HTMLCanvasElement | null; // proporciona tu propio canvas o deja que la superposición cree uno
}

Uso

const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// Alternar encendido/apagado en tiempo de ejecución
multiP2PGame.setPingOverlayEnabled(false);
Leyendo el gráfico
Cada línea coloreada es un par. Los valores bajos y planos son buenos; patrones dentados o saltos repentinos sugieren congestión o retransmisión (TURN). Si un par está consistentemente más alto, considera reequilibrar roles (p. ej., evitar convertirlo en host).

Serialización

  • Estrategias: json (frames de cadena) o binary-min (ArrayBuffer UTF‑8 JSON).
  • Estrategias desconocidas lanzan un error.
interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

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

Ejemplos

Host autoritario aplicando intenciones

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

Persistiendo cargas útiles efímeras en estado compartido

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

Actualizaciones delta selectivas

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

Referencia 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} dio ${item.id} a ${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})`));
  

Notas de producción

  • Provision ICE (TURN) and secure signaling (WSS).
  • Consider authoritative mode with a trusted/headless host for fairness.
  • Monitor RTCDataChannel.bufferedAmount and tune backpressure.

Lista de verificación de reconexión y UX

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

Depuración

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

Compatibilidad del navegador

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

Solución de problemas

La conexión WebRTC falla al establecerse

  • Contenido mixto: asegúrese de que su página y la señalización usen HTTPS/WSS (los navegadores bloquean WS desde páginas HTTPS).
  • Falta TURN: en redes empresariales/de hotel, el P2P directo está bloqueado. Proporcione credenciales TURN (username/credential) en iceServers.
  • CORS/firewall: su endpoint de señalización debe aceptar el origen; verifique reglas de proxy inverso y puertos abiertos (TLS 443).

DataChannel se atasca (alta latencia, entradas retrasadas)

  • Contrapresión: habilite coalesce-moves o drop-moves y ajuste thresholdBytes (comience en 256–512 KB).
  • Reducir tamaño de mensaje: prefiera deltas; comprima cargas útiles (binary‑min); cuantice vectores (p. ej., mm → cm).
  • Menor tasa de envío: limite las transmisiones de movimiento (p. ej., 30–60 Hz) y confíe en la interpolación para rellenar frames.

Pares fuera de sincronización después del cambio de host

  • Asegúrese de que el nuevo host transmita un state_full (la biblioteca desencadena esto automáticamente en el cambio de host).
  • Los clientes deben aplicar la instantánea completa y limpiar cachés locales (deje que la interpolación se estabilice por unos frames).

Problemas específicos de Safari

  • Requiere HTTPS/WSS para WebRTC fuera de localhost.
  • Verifique que las URLs STUN/TURN incluyan parámetros de transporte (p. ej., ?transport=udp) si lo requiere su relay.

Flujos de trabajo de juego

Patrones de extremo a extremo para conectar red, consistencia y estado para diferentes géneros de juego.

1) Arena en tiempo real (acción/disparos)

  • Consistencia: comience con timestamp; opcionalmente cambie a authoritative si ejecuta un host confiable.
  • Sincronización: deltas para estado estable; instantánea completa ocasional en migración de host.
  • Contrapresión: coalesce-moves para mantener solo el último movimiento.
// Configuración
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();

// Entrada local → transmitir movimiento (predicción del cliente manejada por tu renderizador)
function onInput(vec){
  const pos = getPredictedPosition(vec);
  multiP2PGame.broadcastMove(localId, pos, vec);
}

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

2) RPG cooperativo (inventario, host aplica intenciones)

  • Consistencia: authoritative (el host valida uso de objetos, puertas, cofres).
  • Protocolo: los clientes envían intenciones (cargas útiles); el host muta el estado compartido y difunde deltas.
// El cliente envía intenciones solo al host
function usePotion(){
  multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}

// El host maneja intenciones y muta el 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ácticas por turnos (determinista, instantáneas completas)

  • Consistencia: el host authoritative aplica reglas y orden de turno.
  • Sincronización: transmitir un pequeño delta por movimiento; enviar una instantánea completa cada N turnos por seguridad.
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; // rechazar movimiento ilegal
  applyMove(currentState, mv);
  multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});

// Cada 10 turnos, enviar una instantánea completa
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);

4) Juego de fiesta con lobby (capacidad y migración)

  • 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) Sandbox de mundo abierto (sin límites, eje Z)

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

Glosario

  • SDP: Protocolo de Descripción de Sesión; describe los parámetros de sesión de medios/datos usados por WebRTC.
  • ICE: Establecimiento de Conectividad Interactiva; descubre rutas de red entre pares (vía STUN/TURN).
  • STUN: Servidor que ayuda a un cliente a conocer su dirección pública; usado para la travesía NAT.
  • TURN: Servidor de retransmisión que reenvía tráfico cuando el P2P directo no es posible.
  • DataChannel: Transporte de datos bidireccional WebRTC usado para mensajes de juego.
  • LWW: Último‑Escritor‑Gana; resolución de conflictos donde la actualización más reciente gana basada en una secuencia por remitente.