В марте 2017 года мы собрали небольшую команду и взялись за разработку нового перспективного проекта. Без особых деталей могу сказать, что задача стояла интересная и соблазнительная — мобильный, синхронный, командный PvP. Спустя 7 месяцев активной разработки мне захотелось рассказать коллегам из других проектов и отделов Pixonic технические детали и я подготовил для них презентацию, которая в дальнейшем превратилась в эту статью.
Как техлид команды, я расскажу, с какими задачами и проблемами мы успели столкнуться, как их решаем и почему. Мы используем итеративный подход добавления функционала в проект и в данный момент у нас реализованы: PvP на iOS и Android (обе платформы играют на одних серверах); набор персонажей, три десятка игровых механик, боты; матчмейкинг; набор мета-фич (кастомизация персонажей, прокачка и другие); решена задача масштабируемости на весь мир.
Итак, поехали.
Disclaimer
Но должен сразу оговориться, что решения, описанные в статье — это уже исторические явления и факты, сложенные из множества обстоятельств: бизнес- и геймдизайн-требований к продукту, поставленных сроков, потенциала команды и неизвестности некоторых проблем на старте. Это не best practice, но опыт, который не бывает лишним.
Больно(,) весело
Еще до начала разработки мы уже представляли некоторые сложности, с которыми однозначно придется столкнуться. А именно:
- Синхронный PvP. Это никогда не бывает просто, вам придется выбирать и реализовывать целый набор технологий, в любой из которых у вашей команды может не оказаться опыта. Существует большое количество комбинаций технологий для того, чтобы решить проблемы: плавной картинки, скрытия задержки, читерства, производительности симуляции (сервер или мастер-клиент), проблема влезания в MTU и стоимости трафика. Лаг доставки сообщений не отменить, при этом сообщения нужно доставлять максимально быстро и их доставки не должны зависеть друг от друга. По этим причинам мы не могли использовать протокол TCP, а использование UDP добавляет набор кейсов, которые также придется обрабатывать.
- Мобильные платформы. Дополнительная работа над производительностью и ограничениями (пример: максимум использования оперативной памяти). Также нужно иметь в виду, что у вас всегда будут игроки с нестабильным интернетом и плохим пингом — их нельзя игнорировать.
- Доступность из любой точки мира. В идеале игрок не должен заморачиваться выбором сервера, приложение должно автоматически понимать местонахождение девайса и находить оптимальную точку подключения.
- Специфика жанра. Морально мы были готовы, что обязательно будут механики, которые еще никто не реализовывал (особенно спроецированные на наши технические ограничения) и придется стать первопроходцем в комплекте со всеми шишками.
- Ширина технологического стека. Посмотрев на функциональные требования, можно точно сказать, что в одном проекте объединены как минимум 3 подпроекта: игровой клиент, игровой сервер и набор мета-микро-сервисов. Оказалась непростой задача синхронизации команды для синхронного выпуска фичей. Отдельно стояла проблема, как хранить код, как его шарить и переиспользовать.
Дальше я постарался описать нашу ситуацию в виде «проблема — решение».
Хранение и шаринг кода
Как уже было сказано, проект состоит из трех подпроектов:
- Unity-клиент;
- игровой сервер для Windows, использующий транспорт Photon (не PUN);
- набор сервисов для мета игры (Java).
Я посчитал, что в хранении их всех в одном репозитории git имеется больше минусов чем плюсов, так как все CI процессы становятся дороже и занимают больше времени. В результате у нас три репозитория.
Мы используем protocol buffers для обмена сообщениями между всеми тремя подпроектами. Из этого следует, что мы должны где-то хранить файлы .proto и сгенерированные файлы кода для этих сообщений (к слову, коммитить сгенерированные файлы — не очень хороший тон, но именно для Unity это уменьшает количество компиляций при открытии, что экономит время). Более того, для разных протоколов они должны быть разными, переиспользовать пакеты нет смысла, так как серверу и клиенту нужны разные аргументы. Возникла задача, как эти файлы получать всем проектам. Решили мы ее с помощью git submodules. Между каждой из 3-х пар основных проектов мы завели по дополнительному репозиторию и добавили их как субмодули в основные проекты. Теперь репозиториев уже шесть.
Для ускорения отладки симуляции и возможности запуска игровой симуляции без привязки к серверу мы отделили код симуляции. Это дало нам массу возможностей — профилирование запуска сотни игр как консольного приложения с ускорением времени, или, например, использование симуляции в Unity-клиенте для локальной работы обучения бою. Для самого производства это тоже очень удобно: программист, создавая новую игровую фичу, может поиграть сразу в Unity, ему даже не нужно разворачивать локальный сервер. Чтобы код симуляции мог находиться и в клиенте и в игровом сервере, его тоже пришлось вынести в отдельный сабмодуль.
Через какое-то время нам понадобилось хранить и отслеживать изменения игровых конфигов, необходимые части которых потом расходились по подпроектам, и мы сделали отдельный субмодуль, содержащий proto-схему его десериализации.
О плюсах я уже рассказал, теперь о минусах:
- Пришлось обучать команду работать с субмодулями, многие не имели опыта работы с ними.
- Необходимо поддерживать единый вариант аутентификации в git. Например, для сборки клиента в teamcity хочется выкачивать репозиторий клиента с субмодулями через ssh. Но в контрпримере, весьма расточительно работать с персоналом (например, художниками), у которого небольшой опыт git’а, объясняя им, что такое публичный и приватный ключи. Данная проблема в итоге «разрешилась» регистрацией модулей в .gitmodules через относительные ссылки:
[submodule "Assets/shared-code"] path = Assets/shared-code url = ../shared-code.git
Но не факт, что SourceTree вашей версии сможет такое понять, да и хранить придется все репо на одном хосте гита.
- Возможно стоит объединить репозитории протоколов в один и настроить stripping при сборке клиента для уменьшения количества субмодулей и в целом количества операций с git, но это может дать другие трудности, так как все 3 команды подпроектов будут коммитить туда изменения.
- Еще один важный минус — если вы работаете по git-flow, то вам придется поддерживать ветки фич во всех затрагиваемых репозиториях, иначе будет копиться технический долг интеграций модулей глубокого уровня в репо верхнего уровня.
Пример: программист делает фичу в своей ветке основного репо и в ветке фичи субмодуля, а в это время в ветку develop субмодуля заливается новый функционал, который не позволит просто обновиться до последней версии. Необходимо менять код основного модуля для поддержки этих изменений. В итоге программист не сможет влить свою готовую фичу в develop, пока не напишет адаптацию под последнюю версию субмодуля, которая с его фичей зачастую и не связана. Это замедляло интеграцию и лишний раз переключало программистов из контекстов их задач. Как было сказано выше, приходится сначала писать изменения субмодуля в ветке фичи, затем писать адаптацию основного репозитория тоже в ветке этой фичи и только после этого, пройдя ревью и тесты, эти ветки вливаются одновременно в develop своих репозиториев.
Потеря ввода игрока
Теперь перейдем к проблеме намного теснее связанной непосредственно с продуктом. Напомню, между игровым сервером и мобильным клиентом мы используем non-reliable UDP, что не гарантирует доставки сообщений или правильности их порядка. Это, конечно же, накладывает ряд проблем, критичных для самого игрока. Хороший пример — дорогостоящая, мощная ракета и кнопка для её запуска. Игрок ждет подходящей ситуации для использования этой способности и нажимает на кнопку 1 раз, в самый благоприятный, по его мнению, момент. Мы должны гарантированно и максимально быстро доставить на сервер эту информацию, чтобы у игрока этот момент не успел пройти. Но если этот пакет пропадет или придет через 2 секунды, то наша цель не достигнута.
Сначала мы рассматривали стратегию переотправки данных при отсутствии подтверждения о приеме, но нам хотелось максимально приблизить потерю времени к периоду отправки данных клиента. Дополнительной задачей было сделать так, чтобы нажатая во время оглушения кнопка выстрела срабатывала после выхода персонажа из оглушения в симуляции на сервере.
Решение оказалось недорогим, но действенным:
- Каждый кадр работы клиента мы записываем ввод и нумеруем его.
- Складируем их в коллекцию на клиенте и отправляем на сервер сразу несколько последних записей (например, последние десять). Размер таких сообщений очень мал (в среднем от клиента — около 60 байт) и мы можем себе это позволить.
- Сервер получает сообщения, забирает из них только ту часть, которую еще не получил и складывает их в свою очередь обработки. Тем самым, если какой-то пакет от клиента до сервера не дойдет, любой следующий дошедший пакет всегда будет содержать всю новейшую историю ввода.
- Для решении задачи отложенного использования умения при готовности персонажа (выхода из стана) и так все данные есть. Логика обработки знает, какой по счету кадр ввода был обработан для конкретного игрока, и в определенных геймплейных ситуациях продолжит его обрабатывать при первой возможности. Преимуществом нашего подхода в данном кейсе будет являться то, что мы реже будем пропускать «дырки» в очереди ввода.
Проблема плавности изображения на клиенте
Используя негарантированные способы доставки, мы так же сталкиваемся с проблемой получения состояния мира обратно от сервера к клиенту. Но об этом чуть позже. Для начала я бы хотел описать обязательную проблему, которую решает команда любого проекта-игры, передающего состояния по сети.
Представим игровой сервер — это приложение, которое определенное количество раз в секунду делает одно и то же: получает ввод по сети, принимает решения, и отправляет состояние по сети назад. А теперь представим клиент — это приложение, которое (помимо всего прочего) отображает игровое состояние за определенное количество кадров в секунду (например, 60). Если просто позволить отображать то, что пришло из сети, то каждые 2-3 кадра клиент будет отображать одно и то же пришедшее состояние, и отображение будет происходить рывками, а в случае с неравномерной доставкой — еще и с ускорением/замедлением времени. Для того чтобы сделать отображение плавным, необходимо использовать интерполяцию между двумя состояниями от сервера и отобразить рассчитанные промежуточные значения за несколько необходимых кадров.
Уходим на клиенте в прошлое...
Но у нас есть только одно состояние для данного момента и нет второго, чтобы нарисовать промежуточные кадры. Что делать? Решение: мы смещаем время событий клиента немного в прошлое так, чтобы на момент отрисовки у нас уже была теоретическая возможность для прихода следующего состояния мира.
На практике получается не так радужно: UDP не гарантирует доставку, и если состояние мира не придет на клиент, то вам на несколько кадров не будет данных для отображения — вы получите так называемый «фриз». Балансируя между лагом ввода и процентом потерь пакетов, мы используем отход в прошлое на 2 периода отправки + половина RTT. Таким образом, даже если один пакет потеряется, у вас будет время для приема следующего. В то же время если прием пакетов прервался на 2 и более периодов, то весьма вероятно, что произойдет дальнейший дисконнект, что для игрока намного понятнее, чем спонтанные лаги. Игрок увидит окно реконнекта и оно не так сильно испортит игровой опыт.
Проблема непостоянного пинга
На практике схема с интерполяцией и уходом в прошлое работает не всегда хорошо. Игрок мог начать партию, играя по Wi-Fi у себя дома с пингом 10 ms, а затем выйти на улицу, сесть в такси и кататься по городу со включенным мобильным интернетом с пингом уже 100 ms. В этом случае, запомнив на старте игры RTT, у игрока может постоянно не хватать запаса времени для интерполяции, даже если пакеты будут доставляться идеально, равномерно и без потерь.
В нашем случае эту проблему мы решили следующим образом:
- Каждый раз анализируем время прихода пакета и какое время сервера он подразумевает.
- Если состояние сети ухудшилось, то мы плавно уходим в еще большее прошлое ровно на необходимый нам запас, чтобы сохранялось правило:
<i>2 * Send Rate + RTT/2</i>
- На клиенте увеличится лаг ввода, но картинка остается плавной.
Визуально проблема остается в том, что когда мы это обнаружили, клиент уже начал лагать. Мы сдвигаем его в прошлое не мгновенно, а в течении короткого времени (0,5 сек), в этом случае на 1-2 кадра данных у него всё же не будет. В случае перепадов пинга более чем на 1 Send Rate игрок заметит маленькое (130 секунды) единовременное дерганье.
Точно так же и в обратную сторону, если пинг уменьшается, клиент определяет это и приближает отображение ближе к настоящему, чтобы достичь оптимального баланса между плавной картинкой и наименьшей задержкой ввода.
Ответы на вопросы
Хотелось бы в заключении ответить на несколько вопросов, которые могли у вас возникнуть.
Почему вместо борьбы с инпут лагом мы не предсказываем локально поведение клиента в симуляции?
Это решение происходит из жанра и механик игры: если вы делаете шутер от первого лица, с мгновенными явлениями и отсутствием отмены влияний игроков, то вам отлично подойдет local prediction + lag compensation. В случае, если у вас геймплей подразумевает большое количество заморозок, толканий и других механик, воздействующих на игроков и изменение их поведений, то проявление сетевых артефактов будет приближаться к 100%. Самыми отчаянными в этом плане я считаю команду проекта Overwatch от Blizzard, которые нашли оптимальный баланс между минимальными артефактами и необходимостью локального предсказания. Но это на PC, где средний пинг игроков это теоретически позволяет. В нашем случае у локального игрока в 100% случаев рывок вперед заканчивался бы «телепортом» на исходное состояние при любом оглушении.
Как будут играть игроки из разных стран с разным пингом?
У кого пинг лучше, тот, естественно, будет иметь преимущество, так как у него есть больше времени на реакцию. Пример: противник хочет бросить снаряд в игрока. Игрок с меньшим пингом чуть раньше заметит начало анимации противника и у него будет больше шансов произвести защитное действие. Более того, защитное действие быстрее дойдет до сервера и вероятность успеть уклониться повысится.
Кто выстрелит первым, если оба нажали одновременно, а пинг одного игрока больше?
Сервер не учитывает время нажатия, лишь время прихода ввода и его порядок, так что работает принцип из ответа выше.
One more thing
Надеюсь, что написанный мною материал будет полезным другим разработчикам, встающим на схожий путь. Вообще, проблем и решений за это время работы над проектом накопилось так много, что их хватит еще не на одну статью.
Удачи!
Автор: HexGrimm