🔧 Урок · Мережі
📅 Березень 2026⏱ ~18 хв🟡 Середній🟢 Чистий JS · Без бібліотек

Синхронізація симуляції в реальному часі через WebSocket

Додайте багатоклієнтську синхронізацію в реальному часі до браузерної фізичної симуляції за допомогою WebSocket API — без Socket.io, без фреймворків. Охоплює фреймінг повідомлень, heartbeat/перепідключення, дельта-стиснення та мінімальний сервер на основі кімнат у Node.js.

1. WebSocket проти HTTP

HTTP працює за схемою «запит-відповідь» — сервер може надсилати дані лише після того, як клієнт їх запросить. WebSocket встановлює постійний повнодуплексний TCP-канал за допомогою одноразового рукостискання оновлення HTTP. Після рукостискання обидві сторони можуть надсилати фрейми будь-коли з мінімальними накладними витратами (заголовок 2–10 байтів проти ~500 байтів для HTTP).

2. З'єднання та рукостискання

// client.js — відкриваємо з'єднання
const ws = new WebSocket('wss://example.com/sim?room=abc123');

ws.addEventListener('open', () => {
  console.log('[WS] connected');
  send({ type: 'join', room: 'abc123', name: 'Player1' });
});

ws.addEventListener('message', (event) => {
  const msg = JSON.parse(event.data);
  handleMessage(msg);
});

ws.addEventListener('close', (event) => {
  console.log(`[WS] closed: ${event.code} ${event.reason}`);
  scheduleReconnect();
});

ws.addEventListener('error', (err) => {
  console.error('[WS] error', err);
  // за 'error' завжди йде 'close' — обробляйте перепідключення там
});

function send(obj) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify(obj));
  }
}
Значення readyState: 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED. Завжди перевіряйте readyState === WebSocket.OPEN перед надсиланням — виклик send() під час підключення або після закриття спричиняє виняток.

3. Фреймінг повідомлень

Визначте типізовану обгортку повідомлення. Навіть із текстовими JSON-фреймами дискримінатор типу спрощує маршрутизацію:

// Типи повідомлень — визначаємо як константи
const MSG = {
  JOIN:   'join',
  LEAVE:  'leave',
  STATE:  'state',     // повний знімок
  DELTA:  'delta',     // інкрементне оновлення
  PING:   'ping',
  PONG:   'pong',
  INPUT:  'input',     // клієнт → сервер: дія користувача
};

// Приклад корисного навантаження повідомлення STATE
{
  type: 'state',
  tick: 1234,            // монотонний тік симуляції
  t:    123456.78,       // час симуляції (мс)
  particles: [
    { id: 0, x: 1.23, y: 4.56, vx: 0.1, vy: -0.2 },
    // ... до N частинок
  ]
}

// Для бінарної ефективності використовуйте MessagePack або Protobuf
// Для симуляцій із <500 частинок JSON цілком підходить (~20 КБ/кадр)

Для високочастотних оновлень (>10 повідомлень/с із багатьма сутностями) переходьте на бінарні фрейми з повідомленням, упакованим у Float32Array. Параметр binaryType = 'arraybuffer' у WebSocket дозволяє це при частці попередньої пропускної здатності.

4. Heartbeat і перепідключення

// Heartbeat — виявляємо «тихі» розриви з'єднання
const PING_INTERVAL = 15_000;  // 15 с
const PONG_TIMEOUT  = 5_000;   // якщо немає pong за 5 с → перепідключення

let pingTimer = null, pongTimer = null;

function startHeartbeat() {
  pingTimer = setInterval(() => {
    send({ type: MSG.PING, t: Date.now() });
    pongTimer = setTimeout(() => {
      console.warn('[WS] pong timeout — reconnecting'); // таймаут pong — перепідключення
      ws.close();
    }, PONG_TIMEOUT);
  }, PING_INTERVAL);
}

function handleMessage(msg) {
  if (msg.type === MSG.PONG) {
    clearTimeout(pongTimer);
    latency = Date.now() - msg.t;
    return;
  }
  // ... обробляємо інші типи повідомлень
}

// Перепідключення з експоненційною затримкою
let reconnectDelay = 1000;

function scheduleReconnect() {
  clearInterval(pingTimer);
  console.log(`[WS] reconnecting in ${reconnectDelay}ms`); // перепідключення через ${reconnectDelay} мс
  setTimeout(() => {
    reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
    connect();
  }, reconnectDelay);
}

ws.addEventListener('open', () => {
  reconnectDelay = 1000;  // скидаємо після успішного підключення
  startHeartbeat();
});

5. Інтеграція з циклом симуляції

Не надсилайте новий стан кожного кадру анімації — браузери рендерять із частотою 60+ fps, але мережевих повідомлень із частотою 10–20 Гц достатньо для більшості симуляцій. Відокремте цикл рендерингу від частоти надсилання по мережі:

const SEND_RATE = 20;                     // Гц
const SEND_INTERVAL = 1000 / SEND_RATE;   // 50 мс
let   lastSendTime = 0;

function simLoop(now) {
  requestAnimationFrame(simLoop);

  // 1. Крок фізики
  world.step(dt);

  // 2. Рендеринг
  renderer.render(scene, camera);

  // 3. Надсилання по мережі (обмежене за частотою)
  if (isAuthority && now - lastSendTime >= SEND_INTERVAL) {
    lastSendTime = now;
    sendState();
  }
}

requestAnimationFrame(simLoop);

// На клієнтах без авторитету: отримуємо стан та інтерполюємо
let remoteState = null, prevState = null;

function handleMessage(msg) {
  if (msg.type === MSG.STATE || msg.type === MSG.DELTA) {
    prevState = remoteState;
    remoteState = msg;
    remoteTime = performance.now();
  }
}

// Інтерполюємо між prevState і remoteState залежно від «віку» повідомлення
function interpolateParticles() {
  const age = performance.now() - remoteTime;
  const alpha = Math.min(age / SEND_INTERVAL, 1);
  // lerp(prevState.particles[i].x, remoteState.particles[i].x, alpha)
}

6. Дельта-стиснення

Замість надсилання повного стану кожні 50 мс надсилайте лише змінені значення. У симуляції частинок більшість частинок рухається кожен тік, але багато властивостей (маса, тип, колір) є статичними:

let prevSentState = null;
const EPSILON = 0.001;

function sendState() {
  const deltas = [];
  for (const p of particles) {
    const prev = prevSentState?.[p.id];
    if (!prev ||
        Math.abs(p.x - prev.x) > EPSILON ||
        Math.abs(p.y - prev.y) > EPSILON) {
      deltas.push({ id: p.id, x: +p.x.toFixed(3), y: +p.y.toFixed(3),
                    vx: +p.vx.toFixed(3), vy: +p.vy.toFixed(3) });
    }
  }
  if (deltas.length > 0) {
    send({ type: MSG.DELTA, tick: simTick, d: deltas });
  }
  // Надсилаємо повний знімок кожні 2 с для нових учасників / корекції
  if (simTick % (20 * 2) === 0) sendFullSnapshot();
  prevSentState = Object.fromEntries(particles.map(p => [p.id, {...p}]));
}
Бінарний фреймінг: для щільних даних частинок (>1000 частинок) пакуйте Float32Array напряму та використовуйте ws.send(buffer.buffer). Це у 3–4 рази менше за JSON для тих самих даних і працює без копіювання (zero-copy) на більшості рушіїв.

7. Мінімальний сервер на Node.js

Використовуємо npm-пакет ws — найлегшу продакшн-бібліотеку WebSocket (npm install ws):

// server.js (Node.js)
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });

const rooms = new Map();  // roomId → Set<WebSocket>

wss.on('connection', (ws) => {
  let clientRoom = null;

  ws.on('message', (raw) => {
    let msg;
    try { msg = JSON.parse(raw); } catch { ws.close(1008, 'bad json'); return; } // некоректний json

    if (msg.type === 'join') {
      const room = sanitiseRoomId(msg.room);
      if (!rooms.has(room)) rooms.set(room, new Set());
      rooms.get(room).add(ws);
      clientRoom = room;
      ws.send(JSON.stringify({ type: 'joined', room, peers: rooms.get(room).size }));
      return;
    }

    if (msg.type === 'ping') {
      ws.send(JSON.stringify({ type: 'pong', t: msg.t }));
      return;
    }

    // Розсилаємо всім у кімнаті (крім відправника)
    if (clientRoom && rooms.has(clientRoom)) {
      const peers = rooms.get(clientRoom);
      for (const peer of peers) {
        if (peer !== ws && peer.readyState === 1) peer.send(raw);
      }
    }
  });

  ws.on('close', () => {
    if (clientRoom) rooms.get(clientRoom)?.delete(ws);
  });
});

function sanitiseRoomId(id) {
  // Дозволяємо лише буквено-цифрові імена кімнат (запобігаємо ін'єкції шляху)
  return String(id).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32);
}

Цей сервер — чистий ретранслятор: він розсилає всі повідомлення від одного клієнта всім іншим у тій самій кімнаті. Авторитет симуляції залишається на одному клієнті (або виконується авторитетно на сервері для змагальних сценаріїв).

8. Кімнати та авторитет

У браузерній симуляції один клієнт є авторитетом — він виконує фізику та розсилає стан. Інші отримують і інтерполюють. Коли авторитет від'єднується, оберіть нового:

// Клієнт — вибори авторитету при від'єднанні хоста
function handleMessage(msg) {
  if (msg.type === 'leave' && msg.id === authorityId) {
    // Клієнт із найменшим id стає новим авторитетом
    isAuthority = (myId === Math.min(...peerIds));
    if (isAuthority) console.log('[WS] I am now the authority'); // тепер я авторитет
  }
}
Чек-лист для використання в продакшні:
✅ Перевіряйте всі поля вхідних повідомлень (type, room, particles) перед використанням — ніколи не довіряйте даним клієнта
✅ Обмежуйте частоту надсилання на одне з'єднання на сервері (наприклад, макс. 30 повідомлень/с)
✅ Використовуйте wss:// (TLS) у продакшні — звичайний ws:// блокується на HTTPS-сторінках
✅ Реалізуйте максимальний розмір кімнати, щоб запобігти DoS
✅ Ніколи не повертайте msg.room клієнту без санітизації (як показано в коді сервера вище)