Синхронізація симуляції в реальному часі через WebSocket
Додайте багатоклієнтську синхронізацію в реальному часі до браузерної фізичної симуляції за допомогою WebSocket API — без Socket.io, без фреймворків. Охоплює фреймінг повідомлень, heartbeat/перепідключення, дельта-стиснення та мінімальний сервер на основі кімнат у Node.js.
1. WebSocket проти HTTP
HTTP працює за схемою «запит-відповідь» — сервер може надсилати дані лише після того, як клієнт їх запросить. WebSocket встановлює постійний повнодуплексний TCP-канал за допомогою одноразового рукостискання оновлення HTTP. Після рукостискання обидві сторони можуть надсилати фрейми будь-коли з мінімальними накладними витратами (заголовок 2–10 байтів проти ~500 байтів для HTTP).
- Затримка: HTTP-опитування: щонайменше 200–500 мс на цикл туди-назад. WebSocket: обмежений лише мережевим RTT (~20–50 мс у локальній мережі).
- Пропускна здатність: фрейми WebSocket додають 2–10 байтів накладних витрат проти ~500 байтів HTTP-заголовків на повідомлення.
- Використовуйте WebSocket, коли: важлива низька затримка (фізика в реальному часі, мультиплеєр, спільне малювання, жива телеметрія).
- Використовуйте HTTP/SSE, коли: потрібен лише push із сервера (сповіщення, живий стрічка), простіше налаштування, краще кешування на CDN.
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)); } }
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}])); }
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 клієнту без
санітизації (як показано в коді сервера вище)