Реалізація синхронізації стану мобільної гри
Синхронізація стану—фундамент будь-якого мультиплеєрного досвіду. Суть проблеми: два пристрої повинні бачити однакову картину ігрового світу в один і той самий момент часу, незважаючи на затримку мережі, втрату пакетів та різну обчислювальну потужність. Існує кілька рішень, вибір залежить від жанру.
Snapshot vs State delta vs Event sourcing
Три основних підходи до розповсюджування стану:
Full snapshot: сервер кожні N мс відправляє повний стан світу. Просто, але розтратливо. При 20 сутностях по 50 байт кожна, 20 Hz = 20 КБ/с на клієнта. При 10 клієнтах сервер розсилає 200 КБ/с. Підходить для невеликих ігор.
Delta compression: відправляємо тільки зміни від попередньо підтвердженого снимка. Клієнт підтверджує ack: last_received_tick, сервер обчислює delta. Скорочує трафік у 3-10 раз на динамічних сценах. Реалізація складніша: зберігати історію стану для delta-обчислення, обробляти втрату пакета з delta (без базового снимка delta не застосунку).
Event sourcing: сервер розсилає події (PlayerMoved, BulletFired, EntityDied), клієнт їх відновлює на базовому стані. Добре для детерміністичних ігор: шахи, карти, стратегія. Погано для фізики: будь-яка float-погрішність викликає розходження через 30 секунд.
Детермінізм та Floating Point
Event sourcing критична вимога: обидві платформи (iOS ARM64, Android ARM64/x86) повинні давати однаковий результат одних і тих же обчислень. Стандартний float у C#—детерміністичний на одній платформі, але може різнитися на різних CPU.
Рішення—fixed-point математика: замість float 1.5f використовуємо FixedPoint 15000 (масштаб 1/10000). Додавання та множення цілих чисел детерміністичні скрізь. Бібліотека FixedMath.Net для Unity, libfixmath для нативного C++.
Godot використовує детерміністичну фізику через _physics_process—усі кроки фіксовані. Unity Physics (DOTS) підтримує детермінізм при однаковому порядку обробки об'єктів. Classic PhysX—не детерміністичний між платформами.
Client-side prediction та reconciliation
Деталізована розбивка паттерну для real-time ігор:
Client tick 100: застосуємо вхід локально, надішлемо InputPayload{tick:100, input}
Client tick 101-110: продовжуємо прогнозувати локально
Server: отримує InputPayload{tick:100}, симулює, відповідає StatePayload{tick:100, pos, vel}
Client tick 112: отримуємо відповідь сервера за тик 100
→ Порівнюємо передбачений стан на тик 100 з серверним
→ Якщо відхилення > поріг: відкатуємо до серверного стану на тик 100
→ Повторно застосовуємо буфер входів 101-112
Буфер входів—циклічний масив фіксованого розміру (звичайно 64-128 тиків). Кожен елемент: { tick, inputData, predictedState }. При reconciliation—ітерація по буферу та повторне застосування.
Поріг reconciliation: не нуль. Якщо 0.001 юнита відхилення—постійний відкат → клієнт підергується. Типовий поріг: 0.1-0.5 юнита залежно від швидкості персонажа.
Інтерполяція для інших гравців
Власний персонаж—client prediction. Інші гравці—interpolation:
Клієнт зберігає буфер снимків з міцками часу сервера:
[{time: 1000ms, pos: (10,0,5)}, {time: 1050ms, pos: (10.5,0,5)}, ...]
Рендеринг відбувається з затримкою interpolation_delay (звичайно 2-3 снимки = 100-150 мс при 20 Hz). Знаходимо два найближчих снимки та лінійно інтерполюємо позицію:
float t = (renderTime - fromState.time) / (toState.time - fromState.time);
renderPosition = Vector3.Lerp(fromState.position, toState.position, t);
Ротація—Quaternion.Slerp. Для швидкості—потрібна Hermite interpolation або Catmull-Rom по кількох точках—плавніше при зміні напрямку.
Проблема розходження (desync)
У детерміністичних іграх розходження проявляється не одразу. Стандартна діагностика—state hash comparison: усі клієнти відправляють hash поточного стану на сервер кожні N тиків. Якщо хеші не збігаються—desync, сервер розсилає повний снимок для ресинхронізації.
Hash обчислюється від критичних полів стану (позиції, hp, статусу)—не від всього, щоб не включати несуттєві різниці (анімаційні ваги, UI стан).
Пропускна спроможність та мобільні обмеження
Мобільний інтернет нестабільний. Проектуйте для гіршого сценарію: 150 мс RTT, 5% втрата пакетів, бюджет 50 КБ/с на гравця.
Практичні заходи:
- Позиції:
int16замістьfloat32з масштабуванням (50% економія) - Ротація: quaternion → два кути
int8(75% економія) - Сутності поза viewport: не розсилайте або зменшіть частоту оновлень
- Priority-based updates: швидкі об'єкти оновлюються частіше
BitPacking бібліотеки: NetStack (C#), LiteNetLib BitWriter—упакуйте кілька малих значень в один байт.
Графік
Snapshot-синхронізація з інтерполяцією для 4-10 гравців: 2-3 тижні. Client prediction, reconciliation, delta compression, desync detection: 1,5-3 місяці. Детерміністична симуляція з fixed-point математикою: додає 3-6 тижнів. Вартість розраховується індивідуально.







