В мае этого года я участвовал в качестве игрока в MMO-мероприятии KatherineOfSky. Я заметил, что когда количество игроков достигает определённого числа, через каждые несколько минут часть из них «отваливается». К счастью для вас (но не для меня), я был одним из тех игроков, которые отключались каждый раз, даже при наличии хорошего подключения. Я воспринял это как личный вызов и начал искать причины проблемы. Спустя три недели отладки, тестирования и исправлений ошибка наконец устранена, но это путешествие было не таким уж простым.
Проблемы многопользовательских игр очень трудно отследить. Обычно они возникают в очень конкретных условиях параметров сетей и при очень специфичных состояниях игры (в данном случае — наличие более 200 игроков). И даже когда удаётся воспроизвести проблему, её невозможно должным образом отлаживать, потому что вставка контрольных точек останавливает игру, путает таймеры и обычно приводит к завершению соединения из-за превышения срока ожидания. Но благодаря упорности и замечательному инструменту под названием clumsy мне удалось выяснить, что же происходит.
Если вкратце: из-за ошибки и неполной реализации симуляции состояния задержки клиент иногда оказывался в ситуации, когда ему приходится за один такт отправлять сетевой пакет, состоящий из вводимых игроком действий выбора примерно 400 игровых сущностей (мы называем его «мегапакетом»). После этого сервер не только должен правильно получить все эти действия ввода, но и отправить их всем остальным клиентам. Если у тебя 200 клиентов, это быстро становится проблемой. Канал к серверу быстро забивается, что приводит к утере пакетов и каскаду повторно запрошенных пакетов. Откладывание действий ввода затем приводит к тому, что ещё больше клиентов начинает отправлять мегапакеты, и их лавина становится ещё сильнее. Удачливым клиентам удаётся восстановиться, все остальные «отваливаются».
Проблема была достаточно фундаментальной, и у меня ушло 2 недели на её устранение. Она довольно техническая, поэтому ниже я объясню сочные технические подробности. Но для начала вам нужно знать, что с версии 0.17.54, выпущенной 4 июня, в условиях временных проблем с подключением мультиплеер стал более стабильным, а сокрытие задержек — гораздо менее глючным (меньше торможений и телепортирования). Кроме того, я изменил способ сокрытия задержек в бою и надеюсь, что благодаря этому они будут немного более плавными.
Многопользовательский мегапакет — технические подробности
Если объяснять упрощённо, то мультиплеер в игре работает следующим образом: все клиенты симулируют состояние игры, получая и отправляя только ввод игрока (называемый «действиями ввода», Input Actions). Основная задача сервера — передача Input Actions и контроль того, что все клиенты выполняют одинаковые действия в одном такте. Подробнее об этом можно прочитать в посте FFF-149.
Так как сервер должен принимать решения о том, какие действия нужно выполнять, действия игрока движутся примерно по такому пути: действие игрока -> клиент игры -> сеть -> сервер -> сеть -> клиент игры. Это значит, что каждое действие игрока выполняется только после того, как совершит путь туда-обратно по сети. Из-за этого игра бы казалась ужасно тормозной, поэтому почти сразу же после появления в игре мультиплеера был введён механизм сокрытия задержек. Сокрытие задержке имитирует ввод игрока без учёта действий других игроков и принятия решений сервером.
В Factorio есть игровое состояние Game State — это полное состояние карты, игрока, сущностей и всего остального. Оно детерминированно симулируется во всех клиентах на основании действий, полученных от сервера. Игровое состояние священно, и если оно когда-нибудь начинает отличаться от сервера или любого другого клиента, то возникает рассинхронизация.
Кроме Game State у нас есть состояние задержек Latency State. Оно содержит небольшое подмножество основного состояния. Latency State не священно и просто представляет картину того, как будет выглядеть состояние игры в будущем на основании введённых игроком Input Actions.
Для этого мы храним копию создаваемых Input Actions в очереди задержек.
То есть в конце процесса на стороне клиента картина выглядит примерно так:
- Применяем Input Actions всех игроков к Game State так, как эти действия ввода были получены от сервера.
- Удаляем из очереди задержек все Input Actions, которые, по данным сервера, уже были применены к Game State.
- Удаляем Latency State и сбрасываем его, чтобы оно выглядело точно так же, как и Game State.
- Применяем все действия из очереди задержек к Latency State.
- На основании данных Game State и Latency State рендерим игру игроку.
Всё это повторяется в каждом такте.
Слишком сложно? Не расслабляйтесь, это ещё не всё. Чтобы компенсировать ненадёжность Интернет-соединений, мы создали два механизма:
- Пропущенные такты: когда сервер решает, что Input Actions будут выполнены в такте игры, то если он не получил Input Actions какого-то игрока (например, из-за увеличившейся задержки), он не будет ждать, а сообщит этому клиенту «я не учёл твои Input Actions, постараюсь добавить их в следующий такт». Так сделано для того, чтобы из-за проблем с соединением (или с компьютером) одного игрока обновление карты не замедлялось у всех остальных. Стоит заметить, что Input Actions не игнорируются, а просто откладываются.
- Задержка полного пути туда-обратно: сервер пытается предположить, какова задержка передачи данных туда-обратно между клиентом и сервером для каждого клиента. Каждые 5 секунд он при необходимости обсуждает с клиентом новую задержку (в зависимости от того, как вело себя подключение в прошлом), и соответствующим образом увеличивает или уменьшает задержку передачи данных туда-обратно.
Сами по себе эти механизмы довольно просты, но когда они используются совместно (что часто случается при проблемах с соединением), логика кода становится трудноуправляемой и с кучей пограничных случаев. Кроме того, когда в дело вступают эти механизмы, сервер и очередь задержек должны правильно внедрять особое Input Action под названием StopMovementInTheNextTick. Благодаря этому при проблемах с соединением персонаж не будет бежать сам по себе (например, под поезд).
Теперь нужно объяснить вам, как работает выбор сущностей. Один из передаваемых типов Input Action — это изменение состояния выбора сущности. Оно сообщает всем, на какую сущность игрок навёл курсор мыши. Как можно понять, это одно из самых частых действий ввода, отправляемых клиентами, поэтому для экономии пропускной способности канала мы оптимизировали его так, чтобы оно занимало как можно меньше места. Это реализовано так: при выборе каждой сущности вместо сохранения абсолютных, высокоточных координат карты игра сохраняет низкоточное относительное смещение от предыдущего выбора. Это хорошо работает, потому что выделение мышью обычно происходит очень близко к предыдущему выделению. Из-за этого возникают два важных требования: Input Actions никогда нельзя пропускать и необходимо выполнять их в верном порядке. Эти требования удовлетворяются для Game State. Но поскольку задача Latency state в том, чтобы «выглядеть достаточно хорошо» для игрока, в состоянии задержек они не удовлетворяются. Latency State не учитывает многие пограничные случаи, связанные с пропуском тактов и изменением задержек передачи туда-обратно.
Вы уже можете догадаться, к чему всё идёт. Наконец мы начинаем видеть причины проблемы мегапакета. Корень проблемы заключается в том, что в принятии решения о том, нужно ли передавать действие изменения выбора, логика выбора сущностей полагается на Latency State, а это состояние не всегда содержит верную информацию. Поэтому мегапакет генерируется примерно так:
- У игрока появились проблемы с соединением.
- В дело вступают механизмы пропуска тактов и регулирования задержки передачи туда-обратно.
- Очеред состояния задержек не учитывает эти механизмы. Это приводит к тому, что некоторые действия удаляются преждевременно или выполняются в неверном порядке, что приводит к неправильному Latency State.
- У игрока пропадает проблема с соединением и он, чтобы догнать сервер, симулирует до 400 тактов.
- В каждом такте генерируется и подготавливается к отправке на сервер новое действие изменение выбора сущности.
- Клиент отправляет серверу мегапакет из 400 с лишним изменений выбора сущностей (и с другими действиями: состояние стрельбы, ходьбы и т.п. тоже страдали от этой проблемы).
- Сервер получает 400 действий ввода. Так как ему не разрешено пропускать ни единого действия ввода, он приказывает всем клиентам выполнять эти действия и отправляет их по сети.
Ирония заключается в том, что механизм, предназначенный для экономии пропускной способности канала, в результате создавал огромные сетевые пакеты.
Мы решили эту проблему, исправив все пограничные случаи обновления и поддержки очереди задержек. Хоть это и заняло довольно много времени, в конечном итоге стоило реализовать всё правильно, а не полагаться на быстрые хаки.
Автор: PatientZero