介绍
@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) => {/* 渲染 */});
演示
-
完整小游戏:examples/complete
- 基础信令测试器:examples/basic
- 选择性对等语音 (talk / listen):examples/voice — 每个对等端的 Talk/Listen/Stop、全局静音、远端音频播放(参见 语音)
架构
- 该库使用 WebSocket 信令服务器来管理房间、维护玩家标识列表,并将 SDP/ICE 消息路由到特定对端。
- 对端形成全互联网格:对于每一对对端,按字典序更小的
playerId负责创建 WebRTC offer,从而避免同时发起导致的冲突。 - 当 DataChannel 建立后,游戏消息改为点对点传输;信令服务器不再转发应用层流量。
- 主机选举是确定性的:最小的
playerId成为主机;主机离开时,由次小者继任并发送全量快照。
信令时序
全互联拓扑
状态同步
- 全量快照:加入/迁移、纠偏重同步。
- 增量更新:针对路径的局部变更(实践中多采用混合策略)。
一致性
- 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 轴(如提供)
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²)),小房间可接受。若场景拥挤,可在应用层加入空间划分(网格/象限树)。
流程:移动步骤
网络细节
- 背压策略:对饱和通道进行合并/丢弃。
- 容量:强制
maxPlayers并触发maxCapacityReached事件。 - STUN/TURN:在严格网络下提供 TURN;信令使用 WSS。
背压
背压用于保护 DataChannel 不被过载。当通道内部发送缓冲(RTCDataChannel.bufferedAmount)超阈值时,
可以暂时停止发送、丢弃低价值消息,或将多条更新折叠为最新一条。
send() 都会增加 bufferedAmount,直到浏览器将数据刷到网络。
若发送速度长期高于网络承载能力,将导致延迟飙升与应用卡顿。以下策略可缓解。
策略
- off:不启用背压。仅适用于很小且不频繁的消息。
- drop-moves:超过阈值时忽略新的
move消息(输入瞬态,丢弃通常可接受)。 - coalesce-moves:队列中仅保留每个对端的最新
move,替换旧的。
const multiP2PGame = new P2PGameLibrary({
signaling,
backpressure: { strategy: 'coalesce-moves', thresholdBytes: 256 * 1024 }
});
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: trueinP2PGameLibraryoptions: the host removes the leaving player's entries and broadcasts a delta accordingly. - Capacity limit: set
maxPlayersto 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.
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)。
链路模式
| 模式 | 本地(调用方) | 远端 | 用例 |
|---|---|---|---|
talk | sendrecv | sendrecv | 双向通话 |
listen | recvonly | sendonly | 单向收听(观察者、裁判……) |
快速开始
// 双向通话 —— 仅调用方调用 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 驱动)
设计决策
| 主题 | 行为 |
|---|---|
| 远端握手 | 由库自动处理:仅 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 服务器(音频带宽高于游戏数据)。 |
| 清理 | peerLeave、stop() 和 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 }
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
playerIdinitiates the connection by creating the offer; the other answers. This avoids simultaneous offers. - It emits
peerJoin,peerLeave,hostChange, andpingevents, and it forwards decoded network messages asnetMessage. - Backpressure:
off: always send if channel is open.drop-moves: ifbufferedAmountexceeds threshold, drop newmovemessages.coalesce-moves: replace the older queuedmovewith the most recent one.
- Capacity: enforces
maxPlayers(no new inits; ignore extra offers) and emitsmaxCapacityReached(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);
Serialization
- Strategies:
json(string frames) orbinary-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.bufferedAmountand tune backpressure.
Reconnect & UX checklist
- Show reconnecting UI when peers drop; rely on roster to detect returns.
- Host sends a fresh
state_fullafter migration to realign clients. - Optionally enable
cleanupOnPeerLeaveto 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-movesordrop-movesand tunethresholdBytes(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-movesto 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
maxPlayersto protect UX; handlemaxCapacityReachedto 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-minfor 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:最后写入胜;按发送方序列判定最新更新为准。
Links
- p2play-js : a p2p gaming lib - Peer-to-peer multiplayer js for games
- GitHub: aguiran/p2play-js
- npm: @p2play-js/p2p-game