Документы
🌐 Язык:
Локальный статический 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("игрокА", "комната-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. Это предотвращает коллизии предложений.
  • После установления DataChannel игровые сообщения передаются «пир‑к‑пиру»; сервер сигнализации больше не ретранслирует прикладной трафик.
  • Выбор хоста детерминирован: наименьший playerId становится хостом. Когда хост покидает комнату, выбирается следующий и отправляет свежий полный снимок состояния.
Что такое сигнализация?
Браузеры не могут открыть соединение WebRTC без предварительного обмена метаданными (предложения/ответы SDP и кандидаты 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 Дельта
Полные снимки надёжны и просты, но тяжеловесны; дельта‑обновления компактны и эффективны, но требуют стабильной схемы состояния. На практике используйте дельты чаще всего, а полный снимок — при присоединении пиров и после миграции хоста.

Согласованность

  • Временная метка (по умолчанию): «последняя запись побеждает» (LWW) по последовательности каждого отправителя.
  • Авторитарный режим: принимать действия только от доверенной стороны (хоста или фиксированного id).
Согласованность в играх
В режиме временной метки самое позднее действие отправителя принимается по правилу 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 мс). По исчерпании бюджета останавливаем прогноз и ждём авторитетный апдейт.

Зачем лимит?
Слишком долгий прогноз даёт заметные ошибки (проход сквозь стены, «телепорт» при коррекции). Короткий лимит скрывает кратковременный джиттер и держит вид близко к истине.

2D vs 3D

Позиции и скорости по умолчанию 2D; добавьте z для простого 3D. Если задан 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[Рендер]

Разрешение столкновений (окружность/сфера)

flowchart TD A(Start) --> B(Вычислить расстояние между A и B) B --> C{Расстояние < 2 радиусов?} C -- Нет --> Z(Нет перекрытия) C -- Да --> D(Вычислить нормализованный вектор от A к B) D --> E(Вычислить overlap = 2*r - dist) E --> F(Сместить A против нормали на overlap/2) E --> G(Сместить B по нормали на overlap/2) F --> H(Готово) G --> H
Интерполяция vs Экстраполяция
Интерполяция сглаживает между известными позициями; экстраполяция кратко предсказывает движение по скорости при запоздании апдейтов. Держите окна экстраполяции короткими (extrapolationMs), чтобы избежать заметных ошибок.

Сеть: детали

  • Стратегии обратного давления: коалесценция/сбросы для насыщенных каналов.
  • Ёмкость: ограничение maxPlayers + событие maxCapacityReached.
  • STUN/TURN: предоставьте TURN для строгих сетей; используйте WSS для сигнализации.
О NAT и TURN
Во многих корпоративных/гостиничных сетях прямой P2P блокируется. Сервер 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 КБ. Если вы регулярно упираетесь в порог, уменьшите частоту сообщений или нагрузку (дельты, binary-min, квантизация векторов).

События и API (выдержка)

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

Обзор событий

Событие Сигнатура Описание
playerMove(playerId, position)Применено движение
inventoryUpdate(playerId, items)Инвентарь обновлён
objectTransfer(from, to, item)Передан объект
sharedPayload(from, payload, channel?)Получен произвольный payload
stateSync(state)Получен полный снимок
stateDelta(delta)Получена дельта состояния
peerJoin(playerId)Пир подключился
peerLeave(playerId)Пир отключился
hostChange(hostId)Новый хост
ping(playerId, ms)RTT до пира
maxCapacityReached(maxPlayers)Достигнута вместимость; новые подключения отклоняются

Жизненный цикл и присутствие

  • Присутствие: вызывайте announcePresence(playerId) рано, чтобы излучить первичное движение и отрисовать игрока сразу.
  • peerJoin/peerLeave: UI может показывать/скрывать сущности. На стороне хоста очистку можно автоматизировать опцией cleanupOnPeerLeave: true в настройках P2PGameLibrary: хост удаляет записи у покидающего игрока и рассылает дельту.
  • Лимит вместимости: установите maxPlayers. При достижении лимита библиотека не инициирует новые подключения и игнорирует входящие предложения; эмитит maxCapacityReached(maxPlayers) для UI.

Справочник типов

GameLibOptions

type SerializationStrategy = "json" | "binary-min";
type SyncStrategy = "full" | "delta"; // рекомендация: нет переключателя "hybrid"
type ConflictResolution = "timestamp" | "authoritative";

interface BackpressureOptions {
  strategy?: "off" | "drop-moves" | "coalesce-moves";
  thresholdBytes?: number; // по умолчанию ~256KB
}

interface DebugOptions {
  enabled?: boolean;
  onSend?: (info: {
    type: "broadcast" | "send";
    to: string | "all";
    payloadBytes: number;
    delivered: number;
    queued: number;
    serialization: SerializationStrategy;
    timestamp: number;
  }) => void;
}

interface MovementOptions {
  maxSpeed?: number;
  smoothing?: number; // 0..1
  extrapolationMs?: number;
  worldBounds?: { width: number; height: number; depth?: number };
  ignoreWorldBounds?: boolean;
  playerRadius?: number;
}

interface GameLibOptions {
  maxPlayers?: number;
  syncStrategy?: SyncStrategy; // рекомендация: вы решаете, когда слать full vs delta
  conflictResolution?: ConflictResolution;
  authoritativeClientId?: string;
  serialization?: SerializationStrategy;
  iceServers?: RTCIceServer[];
  cleanupOnPeerLeave?: boolean;
  debug?: DebugOptions;
  backpressure?: BackpressureOptions;
  pingOverlay?: { enabled?: boolean; position?: "top-left"|"top-right"|"bottom-left"|"bottom-right"; canvas?: HTMLCanvasElement | null };
  movement?: MovementOptions;
}

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;
  inventories: Record>;
  objects: Record }>;
  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

Конструктор

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

Жизненный цикл

await start(): Promise
on(name: N, handler: EventMap[N]): () => void
getState(): GlobalGameState
getHostId(): string | undefined
setPingOverlayEnabled(enabled: boolean): void
tick(now?: number): void // применить интерполяцию/столкновения один раз

Утилиты состояния

setStateAndBroadcast(selfId: string, changes: Array<{ path:string; value:unknown }>): string[]
broadcastFullState(selfId: string): void
broadcastDelta(selfId: string, paths: string[]): void

Игровые API

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 API

broadcastPayload(selfId: string, payload: unknown, channel?: string): void
sendPayload(selfId: string, to: string, payload: unknown, channel?: string): void

Сообщения (транспорт)

// NetMessage (избранные варианты)
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 };

// Сериализация
// strategy: "json" (строковые кадры) или "binary-min" (ArrayBuffer UTF-8 JSON)

Адаптер сигнализации

Абстракция, используемая библиотекой для обмена SDP/ICE через любой бэкенд (WebSocket, REST и др.).

interface SignalingAdapter {
  localId: string;
  roomId?: string;
  register(): Promise; // присоединиться к комнате и получить список
  announce(desc: RTCSessionDescriptionInit, to?: string): Promise;
  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;
}

Пример: минимальный собственный адаптер (WebSocket)

Крошечная реализация интерфейса на базе простого WebSocket‑сервера сигнализации.

class SimpleWsSignaling implements SignalingAdapter {
    constructor(public localId: string, public roomId: string, private url: string) {
      this.ws = new WebSocket(this.url);
    }
    private ws: WebSocket;
    private rosterCb?: (list: string[]) => void;
    private descCb?: (d: RTCSessionDescriptionInit, from: string) => void;
    private iceCb?: (c: RTCIceCandidateInit, from: string) => void;
  
    async register(): Promise {
      await new Promise((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 {
      this.ws.send(JSON.stringify({
        kind: 'desc',
        roomId: this.roomId,
        from: this.localId,
        to,
        payload: desc
      }));
    }
    async sendIceCandidate(candidate: RTCIceCandidateInit, to?:string): Promise {
      this.ws.send(JSON.stringify({
        kind: 'ice',
        roomId: this.roomId,
        from: this.localId,
        to,
        payload: candidate
      }));
    }
  }
  
  // Использование с библиотекой
  const signaling = new SimpleWsSignaling('alice', 'room-1', 'wss://your-signal.example');
  await signaling.register();
  const multiP2PGame = new P2PGameLibrary({ signaling });
  await multiP2PGame.start();

Пример: REST + long‑polling адаптер

Для сред без WebSocket используйте HTTP‑эндпоинты и цикл опроса для приёма сообщений.

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 {
      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 {
        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 {
      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 {
      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 })
      });
    }
  }
    
  // Использование
  const restSignaling = new RestPollingSignaling('alice','room-1','https://your-signal.example');
  await restSignaling.register();
  const multiP2PGame = new P2PGameLibrary({ signaling: restSignaling });
  await multiP2PGame.start();

WebSocketSignaling

Референсная реализация, используемая в примерах; протокол: { sys:'roster', roster:string[] } широковещательно; адресные сообщения через 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 */});

Форматы сообщений

// Клиент → сервер (register)
{ roomId: string, from: string, announce: true, kind: 'register' }

// Сервер → клиентам (roster broadcast)
{ sys: 'roster', roomId: string, roster: string[] }

// Клиент → сервер (SDP/ICE, адресные или широковещание в комнате)
{ kind: 'desc'|'ice', roomId: string, from: string, to?: string, payload: any, announce?: true }

См. examples/server/ws-server.mjs.

PeerManager (внутренний)

  • Поддерживает по одному RTCPeerConnection и одному RTCDataChannel на каждого пира, настраивая обратные вызовы.
  • Для каждой пары пиров соединение инициирует тот, чей playerId лексикографически меньше; второй отвечает. Это исключает одновременные офферы.
  • Эмитит peerJoin, peerLeave, hostChange, ping и форвардит декодированные сетевые сообщения как netMessage.
  • Обратное давление:
    • off: всегда отправлять, если канал открыт.
    • drop-moves: если bufferedAmount больше порога — отбрасывать новые move.
    • coalesce-moves: заменять более старый move самым новым.
  • Вместимость: применяет maxPlayers (не инициировать новые; игнорировать лишние офферы) и эмитит maxCapacityReached(maxPlayers).

EventBus (внутренний)

class EventBus {
  on(name: N, fn: EventMap[N]): () => void
  off(name: N, fn: EventMap[N]): void
  emit(name: N, ...args: Parameters): void
}

Обычно вы подписываетесь через P2PGameLibrary.on(), который делегирует во внутреннюю шину.

PingOverlay

Накладка рисует мини‑дашборд поверх страницы и отслеживает время туда‑обратно (RTT) до каждого подключённого пира. Слушает события ping от сетевого слоя и хранит короткую историю (~60 сэмплов). Удобно в разработке для поиска пиков, проверки TURN и сравнения пиров.

Опции

{
  enabled?: boolean; // по умолчанию false
  position?: 'top-left'|'top-right'|'bottom-left'|'bottom-right'; // по умолчанию 'top-right'
  canvas?: HTMLCanvasElement | null; // можно передать свой canvas или дать оверлею создать
}

Использование

const multiP2PGame = new P2PGameLibrary({ signaling, pingOverlay: { enabled: true, position: 'top-right' } });
// Переключение во время работы
multiP2PGame.setPingOverlayEnabled(false);
Чтение графика
Каждая цветная линия — отдельный пир. Ровные низкие значения — хорошо; «пилы» или скачки намекают на перегрузку или ретрансляцию (TURN). Если один пир стабильно хуже, подумайте о перераспределении ролей (например, не делать его хостом).

Сериализация

  • Стратегии: json (строковые кадры) или binary-min (ArrayBuffer UTF‑8 JSON).
  • Неизвестные стратегии приводят к ошибке.
interface Serializer {
  encode(msg: NetMessage): string | ArrayBuffer;
  decode(data: string | ArrayBuffer): NetMessage;
}

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

Примеры

Авторитарный хост применяет намерения

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

Сохранение эфемерных payload в общее состояние

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

Выборочные дельта‑обновления

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

Справочник событий

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

Заметки о производстве

  • Настройте ICE (TURN) и защищённую сигнализацию (WSS).
  • Рассмотрите режим authoritative с доверенным/безголовым хостом для честности.
  • Мониторьте RTCDataChannel.bufferedAmount и настраивайте обратное давление.

Повторные подключения и UX‑чеклист

  • Показывайте UI «переподключение», когда пиры пропадают; опирайтесь на roster для обнаружения возвратов.
  • Хост после миграции отправляет свежий state_full для выравнивания клиентов.
  • Опционально включите cleanupOnPeerLeave для очистки состояния при выходе (только у хоста).

Отладка

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

Совместимость браузеров

  • Актуальные Chrome/Firefox/Edge/Safari поддерживают DataChannel; Safari требует HTTPS/WSS в продакшне.
  • Развёртывайте TURN для корпоративных/гостиничных сетей; ожидайте большую задержку при ретрансляции.

Устранение неполадок

WebRTC‑соединение не устанавливается

  • Смешанный контент: страница и сигнализация должны быть HTTPS/WSS (браузеры блокируют WS со страниц HTTPS).
  • Нет TURN: в корпоративных/гостиничных сетях прямой P2P блокируется. Предоставьте учётные данные TURN в iceServers.
  • CORS/фаервол: точка сигнализации должна принимать ваш origin; проверьте правила реверс‑прокси и откройте порты (TLS 443).

Затыки DataChannel (высокая задержка, запоздалый ввод)

  • Обратное давление: включите coalesce-moves или drop-moves и настройте thresholdBytes (начните с 256–512 КБ).
  • Уменьшите размер сообщений: предпочитайте дельты; сжимайте (binary‑min); квантуйте векторы.
  • Снизьте частоту отправки: ограничьте рассылку движения (30–60 Гц) и полагайтесь на интерполяцию.

Десинхронизация после смены хоста

  • Убедитесь, что новый хост рассылает state_full (библиотека делает это автоматически при смене хоста).
  • Клиенты должны применить полный снимок и очистить локальные кэши (дайте интерполяции стабилизироваться).

Особенности Safari

  • Требует HTTPS/WSS для WebRTC вне localhost.
  • Проверьте, что URL STUN/TURN содержат параметры транспорта (например, ?transport=udp), если ваш ретранслятор этого требует.

Игровые рабочие процессы

Сквозные шаблоны для связки сети, согласованности и состояния под разные жанры.

1) Реал‑тайм арена (action/shooter)

  • Согласованность: начните с timestamp; опционально переходите на authoritative при доверенном хосте.
  • Синхронизация: дельты в штатном режиме; полный снимок при миграции хоста.
  • Обратное давление: coalesce-moves, чтобы хранить только последнее движение.
// Настройка
const multiP2PGame = new P2PGameLibrary({ signaling, conflictResolution: 'timestamp', backpressure: { strategy: 'coalesce-moves' }, movement: { smoothing: 0.2, extrapolationMs: 120 } });
await multiP2PGame.start();

// Локальный ввод → рассылка движения (клиентское предсказание в вашем рендерере)
function onInput(vec){
  const pos = getPredictedPosition(vec);
  multiP2PGame.broadcastMove(localId, pos, vec);
}

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

2) Кооперативный RPG (инвентарь, хост применяет намерения)

  • Согласованность: authoritative (хост валидирует предметы, двери, сундуки).
  • Протокол: клиенты отправляют намерения (payload); хост мутирует общее состояние и шлёт дельты.
// Клиент шлёт намерения только хосту
function usePotion(){
  multiP2PGame.sendPayload(localId, multiP2PGame.getHostId()!, { action: 'use-item', itemId: 'potion' }, 'intent');
}

// Хост обрабатывает намерения и меняет состояние
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) Пошаговая тактика (детерминированно, полные снимки)

  • Согласованность: авторитарный хост обеспечивает правила и очередность ходов.
  • Синхронизация: маленькая дельта на ход; полный снимок каждые N ходов для надёжности.
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; // отклонить недопустимый ход
  applyMove(currentState, mv);
  multiP2PGame.setStateAndBroadcast(localId, [ { path: `players.${from}.lastMove`, value: mv } ]);
});

// Каждые 10 ходов шлём полный снимок
if (currentState.tick % 10 === 0) multiP2PGame.broadcastFullState(localId);

4) Party‑игра с лобби (вместимость и миграция)

  • Задайте maxPlayers для защиты UX; обрабатывайте maxCapacityReached для информирования пользователя.
  • Используйте roster для отображения лобби; автоматически мигрируйте хоста при выходе.
const multiP2PGame = new P2PGameLibrary({ signaling, maxPlayers: 8 });

multiP2PGame.on('maxCapacityReached', (max) => showToast(`Room is full (${max})`));

multiP2PGame.on('hostChange', (host) => updateLobbyHost(host));

5) Открытый мир (без границ, ось Z)

  • Отключите границы мира; столкновения только «игрок‑против‑игрока».
  • Для частых апдейтов используйте binary-min ради экономии байтов.
const multiP2PGame = new P2PGameLibrary({
  signaling,
  serialization: 'binary-min',
  movement: { ignoreWorldBounds: true, playerRadius: 20, smoothing: 0.25, extrapolationMs: 140 }
});

Глоссарий

  • SDP: Session Description Protocol; описывает параметры медиа/данных, используемые WebRTC.
  • ICE: Interactive Connectivity Establishment; обнаруживает сетевые маршруты между пирами (через STUN/TURN).
  • STUN: сервер, помогающий клиенту узнать свой публичный адрес; используется для обхода NAT.
  • TURN: ретранслятор, пересылающий трафик, когда прямой P2P невозможен.
  • DataChannel: двунаправленный транспорт WebRTC для игровых сообщений.
  • LWW: Last‑Writer‑Wins; разрешение конфликтов, где побеждает последнее обновление по последовательности отправителя.