html @p2play-js/p2p-game — 文档
文档
🌐 语言:
本地静态HTML @p2play-js/p2p-game 主题
⚠️ 警告:本文档由英文自动翻译而来,可能存在错误。 英文原版

介绍

@p2play-js/p2p-game 是一个模块化的TypeScript库,用于构建基于浏览器的P2P(WebRTC)多人游戏。它提供 状态同步(完整/增量)、一致性策略(时间戳)、最小WebSocket信令适配器、移动助手、 主机选举/迁移和ping覆盖层。

快速开始

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

const signaling = new WebSocketSignaling("玩家A", "房间-42", "wss://你的-ws.示例");
const multiP2PGame = new P2PGameLibrary({
  signaling,
  maxPlayers: 4,
  syncStrategy: "delta",
  conflictResolution: "timestamp",
});

await multiP2PGame.start();

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

演示

架构

  • 该库使用 WebSocket 信令服务器来管理房间、维护玩家标识列表,并将 SDP/ICE 消息路由到特定对端。
  • 对端形成全互联网格:对于每一对对端,按字典序更小的 playerId 负责创建 WebRTC offer,从而避免同时发起导致的冲突。
  • 当 DataChannel 建立后,游戏消息改为点对点传输;信令服务器不再转发应用层流量。
  • 主机选举是确定性的:最小的 playerId 成为主机;主机离开时,由次小者继任并发送全量快照。
什么是“信令”?
浏览器在打开 WebRTC 连接前,必须先通过带外通道交换元数据(SDP offer/answer 与 ICE 候选)。 信令服务器仅负责该交换并维护房间名册;DataChannel 打开后不再转发游戏数据。

信令时序

sequenceDiagram participant A as 客户端 A participant S as WS 信令 participant B as 客户端 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: 最小的 playerId 发起 offer 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 打开 → 游戏转为 P2P

全互联拓扑

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

状态同步

  • 全量快照:加入/迁移、纠偏重同步。
  • 增量更新:针对路径的局部变更(实践中多采用混合策略)。
全量 vs 增量
全量快照健壮简单但开销大;增量轻量高效但需要稳定的状态结构。实践中,多数情况用增量;当新对端加入或主机迁移后用一次全量。

一致性

  • Timestamp(默认):按发送方序列执行 Last-Writer-Wins (LWW)。
游戏中的一致性
timestamp 模式下,每个发送方仅接受最新动作,采用 Last-Writer-Wins (LWW) 规则:凡是 seq 小于该发送方已见最新值的消息都会被忽略。

移动

本库旨在网络抖动下实现平滑且可预期的移动。它结合了插值(在已知采样间平滑过渡)与 限幅外推(短预测窗口),以在不偏离真实状态过多的前提下掩盖迟到更新。

插值

收到远端新位置时,我们不会瞬移至该位置;而是每帧推进剩余距离的一小部分。 smoothing 系数控制该比例(0..1)。值越大视觉延迟越小,但可能显得“漂浮”。

// 伪代码
const clampedVx = clamp(velocity.x, -maxSpeed, maxSpeed);
const clampedVy = clamp(velocity.y, -maxSpeed, maxSpeed);
// allowedDtSec 考虑外推上限(见下)
position.x += clampedVx * allowedDtSec * smoothing;
position.y += clampedVy * allowedDtSec * smoothing;
// 可选 Z 轴(如提供)
调节 smoothing
建议从 0.2–0.3 开始。若移动明显落后输入,可增大;若出现振荡或过冲,则减小。

外推(带上限)

若本帧没有收到新更新,则暂时以最近速度向前预测。为避免漂移,通过 extrapolationMs(如 120–140ms)限制预测窗口;超出预算后停止预测并等待下一次网络更新。

为何要限幅?
外推过久会产生明显错误(穿墙、纠正时瞬移)。短窗口可掩盖瞬时抖动,同时让视图保持接近真实。

2D 与 3D

默认位置与速度为二维;如需简单 3D,可添加 z。若定义了 worldBounds.depth,Z 轴同样会被限制。

世界边界 vs 开放世界

设定 worldBounds 时,位置被限制在 [0..width][0..height](若有 Z 则至 [0..depth])。 开放世界沙盒中,可设置 ignoreWorldBounds: true 以禁用所有限制(仅保留玩家间碰撞)。

碰撞(圆/球)

碰撞通过对半分离处理:圆(2D)/球(3D)半径相同,重叠时计算规范化向量并将双方沿该向量各推开一半重叠距离。简单且稳定,适合休闲类游戏。

// 两个玩家 A、B,半径 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²)),小房间可接受。若场景拥挤,可在应用层加入空间划分(网格/象限树)。

流程:移动步骤

flowchart TD A[最近已知状态] --> B{是否有新网络更新?} B -- 是 --> C[重置时间戳;应用插值] B -- 否 --> D{外推预算是否充足?} D -- 是 --> E[按最近速度 * smoothing 外推] D -- 否 --> F[保持位置] C --> G{有世界边界?} E --> G G -- 限制 --> H[应用限制] G -- 开放世界 --> I[跳过限制] H --> J[处理碰撞] I --> J J --> K[渲染]

网络细节

  • 背压策略:对饱和通道进行合并/丢弃。
  • 容量:强制 maxPlayers 并触发 maxCapacityReached 事件。
  • STUN/TURN:在严格网络下提供 TURN;信令使用 WSS。
关于 NAT 与 TURN
许多企业/酒店网络会阻止直连。TURN 服务器可中继流量以便对端仍可连接,但会增加延迟与服务器带宽开销。 生产环境建议提供 TURN 凭证以提高可靠性。

背压

背压用于保护 DataChannel 不被过载。当通道内部发送缓冲(RTCDataChannel.bufferedAmount)超阈值时, 可以暂时停止发送、丢弃低价值消息,或将多条更新折叠为最新一条。

工作原理
每次 send() 都会增加 bufferedAmount,直到浏览器将数据刷到网络。 若发送速度长期高于网络承载能力,将导致延迟飙升与应用卡顿。以下策略可缓解。

策略

  • off:不启用背压。仅适用于很小且不频繁的消息。
  • drop-moves:超过阈值时忽略新的 move 消息(输入瞬态,丢弃通常可接受)。
  • coalesce-moves:队列中仅保留每个对端的最新 move,替换旧的。
const multiP2PGame = new P2PGameLibrary({
  signaling,
  backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
});
  
推荐阈值
从 256–512 KB 开始。若经常触发阈值,应降低消息频率或减小载荷(如使用增量、binary‑min、向量量化)。

Events & API (selection)

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

Events overview

Event Signature Description
playerMove(playerId, position)Movement applied
inventoryUpdate(playerId, items)Inventory updated
objectTransfer(from, to, item)Object transferred
sharedPayload(from, payload, channel?)Generic payload received
stateSync(state)Full snapshot received
stateDelta(delta)State delta received
peerJoin(playerId)Peer connected
peerLeave(playerId)Peer disconnected
hostChange(hostId)New host
ping(playerId, ms)RTT to peer
maxCapacityReached(maxPlayers)Capacity reached; new connections refused

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.

Types Reference

GameLibOptions

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

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;
    channel: "reliable" | "unreliable";
    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;
  serialization?: SerializationStrategy;
  iceServers?: RTCIceServer[];
  cleanupOnPeerLeave?: boolean;
  debug?: DebugOptions;
  backpressure?: BackpressureOptions;
  timing?: { pendingOfferTimeoutMs?: number; pingIntervalMs?: number };
  pingOverlay?: { enabled?: boolean; position?: "top-left"|"top-right"|"bottom-left"|"bottom-right"; canvas?: HTMLCanvasElement | null };
  movement?: MovementOptions;
}

Events

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

增量路径规则

  • 路径使用点分隔的对象键(不支持数组索引)。
  • 尽量保持结构扁平且可键控,以便定向更新(如 objects.chest.42),避免深层数组。
// 推荐:对象映射
{ path: 'objects.chest.42', value: { id:'chest.42', kind:'chest', data:{ opened:true } } }

// 不支持:数组索引路径,如 'objects[3]' 或 'players.list.0'

P2PGameLibrary

Constructor

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

Lifecycle

await start(): Promise<void>
stop(): 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

Messages (transport)

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

// Serialization
// strategy: "json" (string frames) or "binary-min" (ArrayBuffer UTF-8 JSON)

Signaling Adapter

Abstraction used by the library to exchange SDP/ICE via any 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>;
}

Example: minimal custom adapter (WebSocket)

A tiny implementation of the interface using a plain WebSocket signaling server.

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<void> {
    await new Promise<void>((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<void> {
    this.ws.send(JSON.stringify({ kind: "desc", roomId: this.roomId, from: this.localId, to, payload: desc }));
  }
  async sendIceCandidate(candidate: RTCIceCandidateInit, to?: string): Promise<void> {
    this.ws.send(JSON.stringify({ kind: "ice", roomId: this.roomId, from: this.localId, to, payload: candidate }));
  }
}

// Usage with the library
const signaling = new SimpleWsSignaling("alice", "room-1", "wss://your-signal.example");
await signaling.register();
const game = new P2PGameLibrary({ signaling });
await game.start();

Example: REST + long‑polling adapter

For environments without WebSockets, use HTTP endpoints and a polling loop to receive messages.

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<void> {
    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 }),
    });
  }
}

// Usage
const restSignaling = new RestPollingSignaling("alice", "room-1", "https://your-signal.example");
await restSignaling.register();
const game2 = new P2PGameLibrary({ signaling: restSignaling });
await game2.start();

WebSocketSignaling

Reference implementation used in examples; protocol: { sys:'roster', roster:string[] } broadcasts; targeted messages via 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 */});

Message shapes

// Client → server (register)
{ roomId: string, from: string, announce: true, kind: 'register' }

// Server → clients (roster broadcast)
{ sys: 'roster', roomId: string, roster: string[] }

// Client → server (SDP/ICE, targeted or broadcast in-room)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

See examples/server/ws-server.mjs.

PeerManager (internal)

  • It maintains one RTCPeerConnection and one RTCDataChannel per peer, wiring the necessary callbacks.
  • For each pair of peers, the peer with the lexicographically smaller playerId initiates the connection by creating the offer; the other answers. This avoids simultaneous offers.
  • It emits peerJoin, peerLeave, hostChange, and ping events, and it forwards decoded network messages as netMessage.
  • Backpressure:
    • off: always send if channel is open.
    • drop-moves: if bufferedAmount exceeds threshold, drop new move messages.
    • coalesce-moves: replace the older queued move with the most recent one.
  • Capacity: enforces maxPlayers (no new inits; ignore extra offers) and emits maxCapacityReached(maxPlayers).

EventBus (internal)

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
}

You usually subscribe through P2PGameLibrary.on(), which delegates to the internal bus.

PingOverlay

The overlay renders a tiny dashboard on top of your page that tracks round‑trip times (RTT) to each connected peer. It listens to ping events emitted by the network layer and keeps a short rolling history (up to ~60 samples). Use it in development to spot spikes, verify TURN usage, and compare peers.

Options

{
  enabled?: boolean; // default false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // default 'top-right'
  canvas?: HTMLCanvasElement | null; // provide your own canvas, or let the overlay create one
}

Usage

const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// Toggle on/off at runtime
multiP2PGame.setPingOverlayEnabled(false);
Reading the chart
Each colored line is one peer. Flat low values are good; saw‑tooth patterns or sudden jumps suggest congestion or relay (TURN). If one peer is consistently higher, consider re‑balancing roles (e.g., avoid making them host).

Serialization

  • Strategies: json (string frames) or binary-min (ArrayBuffer UTF‑8 JSON).
  • Unknown strategies throw an error.
interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

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

Examples

Host-validated intents (application pattern)

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

Persisting ephemeral payloads into shared state

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

Selective delta updates

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

Event reference

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} gave ${item.id} to ${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})`));
  

Production notes

  • Provision ICE (TURN) and secure signaling (WSS).
  • Monitor RTCDataChannel.bufferedAmount and tune backpressure.

Reconnect & UX checklist

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

Debugging

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

Browser compatibility

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

Troubleshooting

WebRTC connection fails to establish

  • Mixed content: ensure your page and signaling use HTTPS/WSS (browsers block WS from HTTPS pages).
  • TURN missing: on enterprise/hotel networks, direct P2P is blocked. Provide TURN credentials (username/credential) in iceServers.
  • CORS/firewall: your signaling endpoint must accept the origin; verify reverse proxy rules and open ports (TLS 443).

DataChannel stalls (high latency, inputs delayed)

  • Backpressure: enable coalesce-moves or drop-moves and tune thresholdBytes (start at 256–512 KB).
  • Reduce message size: prefer deltas; compress payloads (binary‑min); quantize vectors (e.g., mm → cm).
  • Lower send rate: throttle movement broadcasts (e.g., 30–60 Hz) and rely on interpolation to fill frames.

Peers out of sync after host change

  • Ensure the new host broadcasts a state_full (the library triggers this automatically on host change).
  • Clients should apply the full snapshot and clear local caches (let interpolation settle for a few frames).

Safari specific issues

  • Requires HTTPS/WSS for WebRTC outside localhost.
  • Check that STUN/TURN URLs include transport parameters (e.g., ?transport=udp) if needed by your relay.

Game workflows

End‑to‑end patterns to wire networking, consistency and state for different game genres.

1) Real‑time arena (action/shooter)

  • Sync: deltas for steady‑state; occasional full snapshot on host migration.
  • Backpressure: coalesce-moves to keep only the latest movement.
// Setup
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();

// Local input → broadcast movement (client prediction handled by your renderer)
function onInput(vec){
  const pos = getPredictedPosition(vec);
  multiP2PGame.broadcastMove(localId, pos, vec);
}

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

2) Co‑op RPG (inventory, host applies intents)

  • Protocol: clients send intents (payloads); host mutates shared state and broadcasts deltas.
// Client sends intents to host only
function usePotion(){
  multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}

// Host handles intents and mutates state
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) Turn‑based tactics (deterministic, full snapshots)

  • Sync: broadcast a small delta per move; send a full snapshot every N turns for safety.
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; // reject illegal move
  applyMove(currentState, mv);
  multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});

// Every 10 turns, send a full snapshot
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);

4) Party game with lobby (capacity & migration)

  • 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) Open‑world sandbox (no bounds, Z axis)

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

词汇表

  • SDP:会话描述协议;描述 WebRTC 使用的媒体/数据会话参数。
  • ICE:交互式连接建立;发现对端间的网络路径(经由 STUN/TURN)。
  • STUN:帮助客户端获知其公网地址的服务器;用于 NAT 穿透。
  • TURN:在无法直连时转发流量的中继服务器。
  • DataChannel:WebRTC 的双向数据通道,用于游戏消息。
  • LWW:最后写入胜;按发送方序列判定最新更新为准。