Хабрахабр

Клиент-серверное взаимодействие в новом мобильном PvP-шутере и устройство игрового сервера: проблемы и решения

В предыдущих статьях цикла (все ссылки в конце статьи) о разработке нового fast paced шутера мы рассмотрели механизмы основной архитектуры игровой логики, базирующейся на ECS, и особенности работы с шутером на клиенте, в частности, реализация системы предсказания локальных действий игрока для повышения отзывчивости игры. В этот раз подробнее остановимся на вопросах клиент-серверного взаимодействия в условиях плохого соединения мобильных сетей и способы повышения качества игры для конечного пользователя. Также вкратце опишу архитектуру игрового сервера.

Во время разработки нового синхронного PvP для мобильных девайсов мы столкнулись с типичными для жанра проблемами:

  1. Качество соединения мобильных клиентов оставляет желать лучшего. Это и относительно высокий средний пинг в районе 200-250 мс, и нестабильное распределение пинга по времени с учётом смены точек доступа (хотя, вопреки расхожему мнению, процент потерь пакетов в мобильных сетях уровня 3G+ довольно низок — порядка 1%).
  2. Существующие технические решения — это монструозные фреймворки, которые загоняют разработчиков в жесткие рамки.

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

Наиболее популярные из них: Рассмотрим механизмы организации взаимодействия между клиентами в синхронных PvP-играх.

  • P2P или peer-to-peer. Вся логика матча хостится на одном из клиентов и не требует от нас практически никаких затрат на трафик. Но простор для читеров и высокие требования к хостящему матч клиенту, а также ограничения NAT не позволили взять это решение для мобильной игры.
  • Client-server. Выделенный сервер, наоборот, позволяет полностью контролировать всё происходящее в матче (прощайте, читеры), а его производительность — рассчитывать некоторые специфичные для нашего проекта вещи. Также многие крупные хостинг-провайдеры имеют свою структуру подсетей, которая обеспечивает минимальную задержку для конечного пользователя.

Было принято решение писать авторитарный сервер.


Сетевое взаимодействие при peer-to-peer (слева) и client-server (справа)

Передача данных между клиентом и сервером

Мы используем Photon Server — это позволило быстро развернуть необходимую инфраструктуру для проекта на основе уже отработанной годами схемы (в War Robots используем её же).

Что дает некоторое преимущество, так как библиотека передачи данных может быть заменена в любой момент. Photon Server для нас исключительно транспортное решение, без high-level конструкций, которые сильно завязаны на конкретный игровой движок.

На каждый матч создается отдельный поток, который инкапсулирует всю логику работы и предотвращает влияние одного матча на другой. Игровой сервер представляет из себя многопоточное приложение в контейнере Photon. Всеми подключениями сервера управляет Photon, а данные, пришедшие в него от клиентов, складываются в очередь, которая затем разбирается в ECS.


Общая схема потоков матчей в контейнере Photon Server

Каждый матч состоит из нескольких стадий:

  1. Игровой клиент встаёт в очередь в так называемый сервис матчмейкинга. Как только в нем набирается необходимое количество игроков, удовлетворяющих определённым условиям, он сообщает об этом игровому серверу с помощью gRPC. В этот же момент передаются все необходимые для создания игры данные.


    Общая схема создания матча

  2. На игровом сервере начинается инициализация матча. Обрабатываются и подготавливаются все параметры матча, включая данные о карте, а также все данные о клиентах, поступившие от сервиса создания матчей. Обработка и подготовка данных подразумевает, что мы парсим все необходимые данные и записываем их в специальное подмножество сущностей, которое мы называем RuleBook. Оно хранит статистические данные матча (которые не изменяются в его ходе) и будет передано всем клиентам в процессе подключения и авторизации на игровом сервере один раз или при переподключении после потери соединения. К статическим данным матча относятся конфигурация карты (представление карты компонентами ECS, связывающими их с физическим движком), данные о клиентах (ники, набор оружия, которое у них есть и не меняется в течение боя и т.п).
  3. Запуск матча. Начинают работать ECS-системы, составляющие игру на сервере. Все системы тикают 30 кадров в секунду.
  4. Каждый кадр происходит считывание и распаковка вводов игроков или копирование, если игроки не присылали свой ввод в пределах некоторого интервала.
  5. Затем в этом же кадре происходит обработка ввода в системе ECS, а именно: изменение состояния игрока; мира, на который он влияет своим вводом; и состояния других игроков.
  6. В конце кадра происходит упаковка результирующего состояния мира для игрока и отправка его по сети.
  7. В конце матча результаты отправляются на клиенты и в микросервис, обрабатывающий награды за бой с использованием gRPC, а также аналитика по матчу.
  8. После происходит клинап потока матча и поток закрывается.


Последовательность действий на сервере внутри одного кадра

Со стороны клиента процесс подключения к матчу выглядит следующим образом:

  1. Сперва осуществляется запрос на постановку в очередь в сервис создания матчей посредством websocket с сериализацией через protobuf.
  2. При создании матча этот сервис сообщает клиенту адрес игрового сервера и передает дополнительный пейлоад, необходимый клиенту перед началом матча. Теперь клиент готов начать процесс авторизации на игровом сервере.
  3. Клиент создает UDP-сокет и начинает отправлять игровому серверу запрос на подключение к матчу вместе с некоторыми идентификационными данными. Сервер уже ожидает этот клиент. При подключении он передает ему все необходимые данные для начала игры и первичного отображения мира. Сюда входят: RuleBook (список статических данных для матча), а также именуемый нами StringIntMap (данные об использованных в геймплее строках, которые будут идентифицироваться целыми числами в процессе матча). Это нужно для экономии трафика, т.к. передача строк каждый кадр создает существенную нагрузку на сеть. Например, все имена игроков, названия классов, идентификаторы оружия, аккаунтов и тому подобная информация вся записывается в StringIntMap, где кодируется с помощью простых целочисленных данных.

Когда игрок непосредственно влияет на других пользователей (наносит урон, накладывает эффекты и т.д.) — на сервере происходит поиск истории состояний, чтобы сравнить реально видимый клиентом игровой мир в конкретный тик симуляции с тем, что в этот момент на сервере происходило с другими игровыми сущностями.

Для вас это происходит моментально, но клиент уже «убежал» на некоторое время вперёд по сравнению с окружающим миром, который он отображает. Например, вы стреляете у себя на клиенте. Сервер проверяет все факторы и выносит свой вердикт по нанесенному урону. Поэтому из-за локального предсказания поведения игрока, серверу необходимо понять, где и в каком состоянии находились противники в момент выстрела (возможно они были уже мертвы или, наоборот, неуязвимы).


Схема запроса на создание матча, подключения к игровому серверу и авторизации

Сериализация и десериализация, упаковка и распаковка первых байт матча

У нас самописная бинарная сериализация данных, а для передачи данных мы используем UDP.

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

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

Это свойство может быть сконфигурировано на каждом хопе сети и часто бывает даже ниже 1500 байт. Ограничение передачи данных в 1500 байт (он же MTU) — это, на самом деле, максимальный размер пакета, который можно передать поверх Ethernet. Начинается фрагментация пакетов. Что будет, если послать пакет больше 1500 байт? каждый пакет будет принудительно разбит на несколько фрагментов, которые будут отдельно отправлены с одного интерфейса на другой. Т.е. Они могут быть отправлены совершенно разными маршрутами и время получения таких пакетов может существенно увеличиться, прежде чем сетевой уровень выдаст вашему приложению склеенный пакет.

Т.е. В случае с Photon — библиотека принудительно начинает слать такие пакеты в режиме reliable UDP. Но такая работа сетевой части недопустима в играх, где необходима минимальная задержка сети. Photon будет дожидаться каждого фрагмента пакета, а также пересылать недостающие фрагменты, если они затерялись при пересылке. Поэтому рекомендовано уменьшать размеры пересылаемых пакетов до минимума и не превышать рекомендуемых 1500 байт (в нашей игре размер одного полного состояния мира не превышает 1000 байт; размер пакета с дельта-компрессией — 200 байт).

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

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

Контекстно зависимая оптимизация сетевого трафика. Дельта-компрессия

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

Обычно дельта-компрессия заключается в том, чтобы сперва послать полный стейт со всеми необходимыми данными клиенту, а затем слать только изменения этих данных. Наша игра каждый тик производит новое состояние мира, которое необходимо упаковать и передать клиентам. Это можно представить так:

deltaGameState = newGameState — prevGameState

Но для каждого клиента посылаются разные данные и потеря всего лишь одного пакета может привести к тому, что придется пересылать полное состояние мира.

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


Иллюстрация частоты клиент-серверного взаимодействия в проекте

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

public sealed class InputSample
{ // Время сервера, которое видит игрок в данный момент времени public uint WorldTick; // Время, в котором видит себя клиент локально, на основе системы предсказания public uint PlayerSimulationTick; // Ввод джойстика движения. Тип (idle, ходьба, бег) public MovementMagnitude MovementMagnitude; // Направление джойстика, управляющего движением public float MovementAngle; // Состояния джойстика прицеливания public AimMagnitude AimMagnitude; // Угол джойстика прицеливания public float AimAngle; // Цель для выстрелов, в которую хотел бы попасть клиент public uint ShotTarget; // Данные с джойстика прицеливания, сжатые для более экономной передачи по сети public float AimMagnitudeCompressed;
}

Во-первых, клиент сообщает серверу, в каком тике он видит все окружающие его объекты игрового мира, которые он не способен предсказать (WorldTick). Тут есть несколько интересных моментов. Это не так. Может показаться, что клиент способен «остановить» время для мира, а сам бегать и расстреливать всех из-за локального предсказания. Также поле WorldTick используется в качестве acknowledgment-пакета, на основе которого строится дельта-компрессия. Мы доверяем только ограниченному набору значений от клиента и не даём ему стрелять в прошлое на более чем 1 секунду.

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

if (Math.Abs(s.AimMagnitudeCompressed) < float.Epsilon)
{ packer.PackByte(0, 1);
}
else
{ packer.PackByte(1, 1); float min = 0; float max = 1; float step = 0.001f; // Разбиваем величину ввода на 1000 и округляем до целого, // которое будет преобразовано в необходимое число бит и упаковано // для передачи по сети packer.PackUInt32((uint)((s.AimMagnitudeCompressed - min)/step), CalcFloatRangeBits(min, max, step));
}

Очень часто нас спрашивают, что делать, если человек нажал ультимативную способность, а пакет с её вводом потерялся? Ещё одна интересная особенность при посылке ввода это то, что некоторые команды могут посылаться несколько раз. Это похоже на работу гарантированной доставки, но более гибкой и быстрой. Мы просто посылаем этот ввод несколько раз. размер пакета ввода очень маленький, мы можем упаковать в результирующий пакет несколько смежных вводов игрока. Т.к. В данный момент размер окна, определяющий их количество равен пяти.


Пакеты ввода, формируемые на клиенте в каждый тик и отправляемые на сервер

Мы исходим из того, что вероятность потерять такое количество пакетов подряд весьма низка и является индикатором показателем серьезной деградации качества сети в целом. Передача такого рода данных является наиболее быстрой и достаточно надежной для решения наших задач без использования reliable UDP. Если такое случается, сервер просто копирует последний полученный ввод от игрока и применяет его, надеясь, что он остался неизменным.

Сервер со своей стороны отслеживает, чтобы очередь вводов от игрока была полной. Если же клиент понимает, что он очень долго не получал пакеты по сети, то запускается процесс переподключения к серверу.

Вместо заключения и ссылки

На игровом сервере существует много и других систем, отвечающих за обнаружение, отладку и правку матчей «наживую», обновление конфигурации геймдизайнерами без перезапуска, логирование и отслеживание состояния серверов. Об этом тоже хотим написать подробнее, но отдельно.

И необходимо четко укладываться в лимит пакета в 1500 байт для избежания фрагментации и задержек трафика. В первую очередь при разработке сетевой игры на мобильные платформы следует обращать внимание на корректность работы вашего клиента с высокими пингами (около 200 мс), чуть более частыми потерями данных, а также с размерами посылаемых данных.

Полезные ссылки:

Предыдущие статьи по проекту:

  1. «Как мы замахнулись на мобильный fast paced шутер: технологии и подходы».
  2. «Как и почему мы написали свой ECS».
  3. «Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте».
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть