مقدمة
@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("اللاعب-أ", "الغرفة-42", "wss://مثالك-ws");
const multiP2PGame = new P2PGameLibrary({
signaling,
maxPlayers: 4,
syncStrategy: "delta",
conflictResolution: "timestamp",
});
await multiP2PGame.start();
multiP2PGame.on("playerMove", (id, pos) => {/* العرض */});
العروض التوضيحية
-
لعبة مصغرة كاملة: examples/complete
- مختبر إشارة أساسي: examples/basic
البنية المعمارية
- تستخدم المكتبة خادم إشارة WebSocket لإدارة الغرف، والحفاظ على قائمة المعرفات، وتوجيه رسائل SDP/ICE إلى نظراء محددين.
- يشكل النظراء شبكة كاملة: لكل زوج من النظراء، من يكون
playerIdالخاص به أصغر ترتيبًا أبجديًا ينشئ عرض WebRTC. هذا يمنع تضارب العروض. - بعد إنشاء قنوات البيانات، تتدفق رسائل اللعب نظيرًا إلى نظير؛ ولم يعد خادم الإشارة يمرر حركة تطبيقية.
- اختيار المضيف حتمي: يصبح أصغر
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–140 مللي ثانية). بعد تجاوز الميزانية نتوقف عن الإسقاط وننتظر التحديث الشبكي التالي.
ثنائي الأبعاد مقابل ثلاثي الأبعاد
المواضع والسرعات ثنائية البعد افتراضيًا؛ أضف z لثلاثي أبعاد بسيط. إذا عرّفت worldBounds.depth فسيُقيد محور Z أيضًا.
حدود العالم مقابل عالم مفتوح
باستخدام worldBounds نقيّد المواضع إلى [0..width] و[0..height] (وZ إلى [0..depth] إن توفر).
لبيئات العالم المفتوح، عيّن ignoreWorldBounds: true لتعطيل كل القيود (تبقى التصادمات لاعب‑مقابل‑لاعب فقط).
التصادمات (دوائر/كرات)
تُعالج التصادمات كفصل متماثل بين دوائر (ثنائي الأبعاد) أو كرات (ثلاثي الأبعاد) ذات نصف قطر متساوٍ. عند تداخل لاعبين، نحسب المتجه الموحّد بينهما وندفع الاثنين للابتعاد بنصف مسافة التداخل. هذا بسيط ومستقر للألعاب غير الرسمية.
// لاعبان 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²)). هذا مناسب للغرف الصغيرة. للمشاهد المزدحمة يمكن إضافة تقطيع مكاني (شبكات/أشجار رباعية) على مستوى التطبيق.
التدفق: خطوة الحركة
حل التصادم (دائرة/كرة)
extrapolationMs) لتفادي الخطأ المرئي.
تفاصيل الشبكة
- استراتيجيات الضغط العكسي: دمج/إسقاطات للقنوات المشبعة.
- السعة: فرض
maxPlayers+ حدثmaxCapacityReached. - STUN/TURN: وفر TURN للشبكات الصارمة؛ واستخدم WSS للإشارة.
الضغط العكسي
يحمي الضغط العكسي قناة البيانات من الحمل الزائد. عندما ينمو مخزن الإرسال الداخلي للقناة (المعروض كـ 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 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 }
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: بروتوكول وصف الجلسة.
- ICE: إنشاء اتصال تفاعلي.
- STUN: خادم يساعد العميل على معرفة عنوانه العام.
- TURN: خادم ترحيل عند تعذر P2P المباشر.
- 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