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
-
Mini‑juego completo: examples/complete
- Probador de señalización básico: examples/basic
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.
Secuencia de señalización
Topología de malla completa
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).
Consistencia
- Marca de tiempo (predeterminado): Último‑Escritor‑Gana (LWW) por secuencia por remitente.
- Autoritario: aceptar acciones solo de la autoridad (host o id fijo).
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
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.
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;
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
Resolución de colisión (círculo/esfera)
extrapolationMs
) para evitar error visible.
Detalles de red
- Estrategias de contrapresión: fusión/descartes para canales saturados.
- Capacidad: aplicación de
maxPlayers
+ eventomaxCapacityReached
. - STUN/TURN: proporcione TURN para redes estrictas; use WSS para señalización.
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.
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 }
});
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
inP2PGameLibrary
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 emitsmaxCapacityReached(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 }
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
yping
, y reenvía mensajes de red decodificados comonetMessage
. - Contrapresión:
off
: siempre enviar si el canal está abierto.drop-moves
: sibufferedAmount
excede el umbral, descartar nuevos mensajes demove
.coalesce-moves
: reemplazar elmove
en cola más antiguo por el más reciente.
- Capacidad: aplica
maxPlayers
(sin nuevas inits; ignora ofertas extra) y 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
}
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);
Serialización
- Estrategias:
json
(frames de cadena) obinary-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
odrop-moves
y ajustethresholdBytes
(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 aauthoritative
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; handlemaxCapacityReached
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.
Enlaces
- GitHub: aguiran/p2play-js
- npm: @p2play-js/p2p-game