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,
  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)。只有本地玩家会被重新定位:远端位置是网络的权威来源,本地永不修改。当本地玩家与远端玩家重叠时,计算两者之间的规范化向量,并将本地玩家沿该向量推开完整的重叠距离。这样既保持远端状态的权威性,又避免本地玩家穿入他人。

// 本地玩家 L 与远端玩家 R,半径均为 r
const dx = R.x - L.x, dy = R.y - L.y, dz = (R.z||0) - (L.z||0);
const dist = Math.max(1e-6, Math.hypot(dx, dy, dz));
const overlap = Math.max(0, 2*r - dist);
const nx = dx / dist, ny = dy / dist, nz = dz / dist;
// 仅移动 L,使其远离 R,移动完整的重叠距离:
L.x -= nx * overlap; L.y -= ny * overlap; L.z = (L.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 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;
  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 }> }

语音类型(自 v0.3.0 起)

type VoiceMode = "talk" | "listen";
type VoiceState = "connecting" | "connected" | "disconnected" | "failed";

interface VoiceOptions {
  mode: VoiceMode;
  /** 仅 talk — 自定义输出流;listen 时忽略。默认:getUserMedia({ audio: true })。 */
  localStream?: MediaStream;
}

type VoiceEventMap = {
  remoteStream: (peerId: string, stream: MediaStream) => void;
  remoteStreamRemoved: (peerId: string) => void;
  state: (peerId: string, state: VoiceState) => void;
  error: (peerId: string, error: Error) => void;
};

增量路径规则

  • 路径使用点分隔的对象键(不支持数组索引)。
  • 尽量保持结构扁平且可键控,以便定向更新(如 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

语音 API(自 v0.3.0 起)

readonly voice: VoiceManager // 参见“语音”章节
voice.start(remotePeerId: string, options: VoiceOptions): Promise<void>
voice.stop(remotePeerId: string): void
voice.stopAll(): void
voice.setMuted(muted: boolean): void
voice.isMuted(): boolean
voice.getActiveLinks(): Array<{ peerId: string; mode: VoiceMode; state: VoiceState }>
voice.on(event, handler): () => void // remoteStream | remoteStreamRemoved | state | error

选择性对等语音 (talk / listen)

v0.3.0 起,本库支持在同一个已为 DataChannel 打开的 RTCPeerConnection 上进行选择性的点对点音频,使用标准的 SDP 重协商。 无需每对连接建立第二条连接,无需媒体服务器(SFU),并且信令协议零改动: 重协商的 offer/answer 复用现有的 desc/ice 信封,因此自定义的 SignalingAdapter 实现和参考 WS 服务器无需修改即可工作(包括 STRICT_ENVELOPES=1)。

链路模式

模式本地(调用方)远端用例
talksendrecvsendrecv双向通话
listenrecvonlysendonly单向收听(观察者、裁判……)

快速开始

// 双向通话 —— 仅调用方调用 start();
// 远端库自动应答重协商(无需应用侧调用)。
await multiplayer.voice.start("playerB", { mode: "talk" });

// 单向:收听 playerB 而不发送音频
await multiplayer.voice.start("playerB", { mode: "listen" });

multiplayer.voice.on("remoteStream", (peerId, stream) => { audioEl.srcObject = stream; });
multiplayer.voice.on("remoteStreamRemoved", (peerId) => { /* 分离音频 */ });
multiplayer.voice.on("state", (peerId, state) => { /* connecting | connected | disconnected | failed */ });
multiplayer.voice.on("error", (peerId, error) => { /* 例如麦克风权限被拒绝 */ });

multiplayer.voice.setMuted(true);   // 切断所有外发音频,无需重协商
multiplayer.voice.stop("playerB");  // 远端收到 remoteStreamRemoved + state: disconnected
multiplayer.voice.stopAll();

工作原理(SDP 驱动)

sequenceDiagram participant App as App (调用方) participant VA as VoiceManager A participant PA as PeerManager A participant S as WS 信令 participant PB as PeerManager B participant VB as VoiceManager B App->>VA: voice.start(B, {mode}) VA->>VA: 若为 talk 则 getUserMedia(首次调用) VA->>PA: addTransceiver(direction) + renegotiate(B) PA->>S: kind: desc (offer), to: B S->>PB: 来自 A 的 offer(现有信封) note over PB: 对等端已连接 → 进入重协商分支 PB->>VB: onRemoteOfferApplied(读取 SDP 音频方向) VB->>VB: 若链路请求我方音频则挂载麦克风 PB->>S: kind: desc (answer), to: A S->>PA: 来自 B 的 answer PA->>VA: 协商完成(stable) VA->>App: start() 完成 PB-->>VA: 音频轨道 (RTP) VA->>App: remoteStream 事件 note over PA,PB: 全程 DataChannel 不受影响

设计决策

主题行为
远端握手由库自动处理:仅 voice.start() 的调用方发起。远端应答重协商,并在链路请求其音频时挂载麦克风(浏览器权限提示即为同意关卡)。若被拒绝,协商仍在无音频的情况下完成,并触发 error 事件。
start() 的完成时机当 SDP 重协商完成(信令回到 stable 且协商方向兼容)时完成。ICE 传输被复用——已连接。remoteStream 在远端轨道到达时单独触发。
stop() 的对称性在本地停用音频 transceiver(direction: "inactive",分离轨道)并重协商;远端通过 SDP 检测到变化并触发 remoteStreamRemoved + state: "disconnected"。transceiver 会在下一次 start 时复用(不增加 m-line)。
重复 start()对一个活动连接对再次调用 start() 会替换之前的模式(幂等,无错误)。
Glare(信令碰撞)Perfect negotiation:礼让性由与主机选举相同的确定性 id 顺序推导;polite 一方回滚、应答,然后重放自己的 offer。
麦克风生命周期在所有连接上共享单个 MediaStreamTrack(不克隆)。一旦没有链路发送音频,库拥有的麦克风即被停止(浏览器指示灯熄灭)。应用提供的 localStream 永远不会被库停止。setMuted 切换 track.enabled —— 无需重协商。
ICE / TURN语音复用 P2PGameLibrary 选项中的 iceServers。对于严格网络,建议使用 TURN 服务器(音频带宽高于游戏数据)。
清理peerLeavestop()P2PGameLibrary.stop() 会拆除语音状态且无泄漏。

收听场景(单向发起)

A 和 B 处于 talk 通话中。C —— 单独 —— 调用 voice.start(A, { mode: "listen" })voice.start(B, { mode: "listen" })。 A 和 B 自动以 sendonly 应答,复用它们已激活的麦克风轨道: C 听到两者而不被听到,A↔B 仍保持双向。A 或 B 侧无需任何应用调用。

故障排查

  • 没有声音:浏览器在播放音频前要求用户手势(autoplay 策略)。请在点击处理器中将远端流挂载到 <audio autoplay> 元素。
  • 麦克风权限被拒绝:start(talk) 拒绝并触发 error;远端侧协商在无其音频的情况下完成。DataChannel 永不受影响。
  • Safari:支持重协商,但请专门测试 autoplay 和麦克风权限流程;Safari 对用户手势更严格。
  • 严格 NAT 下的单向音频:iceServers 中配置 TURN 服务器。

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:最后写入胜;按发送方序列判定最新更新为准。