ГлавнаяБлогСетевая синхронизация в стратегиях: как избежать рассинхрона
Python

Сетевая синхронизация в стратегиях: как избежать рассинхрона

Узнайте, как синхронизировать игровой мир в медленных стратегиях. Реальные кейсы и код на Python для предотвращения рассинхрона. Начните писать надёжный netcode!

Al
Редакция Algolitalgolit.ru
8 мин чтения23 июля 2026 г.

Почему медленные игры требуют другого подхода к сети

Почти всё, что написано о сетевом коде в играх, посвящено быстрым жанрам: шутерам, файтингам, гонкам. Классические материалы рассказывают про UDP, клиент-сайд предикшн и интерполяцию — всё ради того, чтобы выжать 60 снапшотов в секунду через домашнее подключение. Но если ваша игра — браузерная стратегия, где флот пересекает галактику часами, а экономика растёт даже при закрытой вкладке, эти рецепты почти бесполезны. Здесь главная проблема — сохранить корректность состояния на протяжении длительного времени для тысяч игроков одновременно. В этой статье мы разберём, как построить надёжную синхронизацию без избыточных механизмов, на примере реального проекта.

Один снапшот, затем дельты

Клиент открывает WebSocket. Сервер отвечает одним сообщением world.init: всё, что игроку разрешено видеть, плюс текущее серверное время. После этого каждое изменение приходит как world.delta — маленький патч, который клиент применяет.

socket.on('world.init', (payload) => {
    state = new GameState(payload.world);
    clockOffset = payload.serverNow - Date.now();
});

socket.on('world.delta', (delta) =>
    state.applyDelta(delta)
);

Эти два обработчика — весь протокол. Никаких порядковых номеров или буферов интерполяции не нужно.

Сложность — в границе между ними. Дельта может прийти, пока снапшот ещё собирается. Сервер решает это тем, что строит world.init до того, как подключит сокет к комнате вещания, — так дельта, созданная во время сборки, никогда не достигнет нового сокета, а сам снапшот уже включает это изменение. Клиент, в свою очередь, буферизирует дельты, пришедшие до инициализации состояния, и применяет их после старта. Если пропустить хотя бы одну из этих мер, игрок, подключившийся в неудачную миллисекунду, увидит мир, несовместимый с серверным.

Ещё одна деталь в сниппете: clockOffset. Сервер ставит метку времени в каждый пакет, а клиент однократно измеряет разницу при старте. После этого всё, что зависит от времени, работает по серверному таймеру — ведь часы игрока могут уходить на минуты.

Ошибка, когда обе стороны считают время

Ресурсы в стратегии не тикают по таймеру. Сервер вычисляет их по требованию: последний сохранённый баланс плюс скорость, умноженная на прошедшее время. Клиент использует ту же формулу, чтобы счётчик плавно рос между сообщениями.

Значение ресурса на проводе содержит число и якорную метку времени для экстраполяции. Ловушка — в этой метке. Когда сервер отвечает на запрос, он проецирует сохранённый баланс на текущий момент. Но запись в базе данных всё ещё хранит метку последнего фактического сохранения — возможно, часовой давности. Отправив свежее число со старой меткой, клиент спроецирует снова:

# Неправильно: свежий баланс, старая метка
return {
    'balance': projected_to_now,
    'settled_at': row.settled_at
}

# Клиент, мгновение спустя:
display = balance + rate * minutes_since(settled_at)
# Повторно добавляет часы дохода

Одни и те же часы дохода учитываются дважды. Никакой ошибки не возникает. Счётчик бежит впереди истины и сбрасывается, когда приходит реальное серверное значение. Исправление — жёсткое правило: метка спроецированного значения — это момент проекции, а не метка записи. Регрессионный тест проверяет, что settled_at == now.

В шутере эта ошибка была бы видна в течение кадра. Здесь она проявляется, только если сравнить счётчик с ручным расчётом спустя часы, — поэтому она попадает в релиз незамеченной и приходит как баг-репорт «мои ресурсы скакнули».

Сервер играет, даже когда вас нет

Распространённый упрощённый подход в этом жанре — позволить зданию «достроиться» в момент, когда какой-нибудь запрос случайно приходит после истечения таймера, обычно при следующем входе игрока. В нашем проекте постройка завершается в секунду, когда она должна, независимо от того, подключён ли кто-то.

Поставив здание в очередь, сервер устанавливает таймер в памяти. Когда он срабатывает, результат записывается в базу и транслируется в комнату сокета. Если игрок офлайн, трансляция уходит в пустую комнату и исчезает. Запись в базе всё равно происходит, так что соперник, прощупывающий систему часом позже, увидит обновлённую реальность.

Таймеры в памяти умирают вместе с процессом, поэтому каждый запуск сервера перезапускает отложенные задачи и обрабатывает те, что завершились, пока сервер был выключен, — с их исходным моментом завершения. Основной путь чтения также обрабатывает просроченные задачи перед ответом. Даже пропущенный таймер не может отдать устаревшее состояние.

Сколько может стоить широковещательная рассылка

Поскольку нет фиксированной частоты кадров, почти все сетевые затраты приходятся на широковещательные сообщения, поэтому у каждой рассылки есть бюджет: одно испускание на действие игрока. Когда кто-то регистрируется, сервер порождает его империю и две AI-империи вокруг. Все три объявления передаются как одна дельта, а не три раздачи.

Комнаты выполняют разграничение. Публичные изменения, например смена владельца звезды, идут в галактическую комнату, к которой подключаются все сокеты. Приватное состояние, такое как ваш доход и очередь построек, идёт только в player:<id>.

Построение этого приватного представления — самая дорогая часть, поэтому сервер сначала проверяет комнату и пропускает работу, если никто не подключён. AI-империи постоянно завершают постройки, и к ним никогда никто не подключён, так что без проверки большая часть обогащения данных выполнялась бы для пустых комнат.

Сетевой код, который я никогда не писал

В проекте нет клиент-сайд предикшна и реконсилиации. Клиент — это зритель. Каждое изменение — запрос, который сервер может отклонить, и клиент ждёт полного оборота, чтобы увидеть результат. Реальный цикл обратной связи действия — минуты или часы, поэтому лишние 300 мс до появления записи в очереди незаметны.

Это устраняет самые сложные проблемы из литературы по жанру. Нет состояния для отката, так как клиент никогда не строил предположений, и ничего не нужно прицеливать, поэтому компенсация задержки не нужна. Обычных TCP WebSocket (socket.io в нашем случае) достаточно; задержанный пакет задерживает обновление UI, а не уклонение.

Остаётся только бухгалтерия. Каждое значение на проводе — запись в журнале, а оставшиеся баги — канцелярские: метка, поставленная не в тот момент, или таймер, который никто не перезапустил после рестарта. Такие ошибки никогда не проявляются в следующем кадре. Они всплывают через десять часов, когда игрок открывает вкладку и число не совпадает с его собственными расчётами.

Практический вывод: что делать прямо сейчас

Если вы пишете многопользовательскую стратегию, начните с простого протокола: один снапшот и дельты. Убедитесь, что метки времени при проецировании всегда указывают на момент проекции, а не на момент сохранения. Всегда перезапускайте таймеры после рестарта сервера. И главное — не усложняйте: если задержка в 300 мс незаметна, не тратьте время на UDP и предикшн. Сосредоточьтесь на корректности данных на длинных дистанциях — это окупится стабильностью и доверием игроков.

#сетевая синхронизация#стратегии#Python#WebSocket#игровой сервер
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →