Реализация синхронизации состояния мобильной игры
Синхронизация состояния — фундамент любого мультиплеерного опыта. Суть проблемы: два устройства должны видеть одинаковую картину игрового мира в один и тот же момент времени, несмотря на задержку сети, потери пакетов и разную вычислительную мощность. Решений несколько, и выбор зависит от жанра.
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 Unity 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 с серверным
→ Если расхождение > threshold: откат к серверному состоянию тика 100
→ Повторно применяю буфер вводов 101-112
Буфер вводов — circular array фиксированного размера (обычно 64-128 тиков). Каждый элемент: { tick, inputData, predictedState }. При reconciliation — итерация по буферу с повторным применением.
Threshold для reconciliation: не ноль. Если 0.001 юнита расхождения — откат → клиент постоянно подёргивается. Типичный порог: 0.1-0.5 юнита в зависимости от скорости персонажа.
Interpolation для других игроков
Собственный персонаж — 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: каждый N тиков все клиенты отправляют hash текущего состояния на сервер. Если хэши не совпадают — desync, сервер рассылает полный снэпшот для синхронизации.
Hash вычисляется от критичных полей состояния (позиции, hp, статусы) — не от всего, чтобы не включать несущественные различия (анимационные веса, UI состояния).
Пропускная способность и мобильные ограничения
Мобильный интернет нестабилен. Проектируй под худший сценарий: 150 мс RTT, 5% packet loss, бюджет трафика 50 КБ/с на игрока.
Практические меры:
- Позиции:
int16вместоfloat32с масштабированием (экономия 50%) - Ротация: quaternion → два угла
int8(экономия 75%) - Entities вне зоны видимости: не рассылать или снизить частоту обновлений
- Priority-based updates: быстро двигающиеся объекты обновляются чаще
BitPacking библиотеки: NetStack (C#), LiteNetLib BitWriter — упаковка нескольких малых значений в один байт.
Сроки
Snapshot-синхронизация с interpolation для 4-10 игроков: 2-3 недели. Client prediction, reconciliation, delta compression, desync detection: 1.5-3 месяца. Детерминированная симуляция с fixed-point математикой: добавляет 3-6 недель. Стоимость рассчитывается индивидуально.







