[Из песочницы] Мультиплеер в быстрых играх (части I, II)
Предлагаю вашему вниманию перевод статьи Fast-Paced Multiplayer (Part I): Introduction.
Разработка игры — само по себе непростое занятие. Но мультиплеерные игры создают совершенно новые проблемы, требующие разрешения. Забавно, что у наших проблем всего две причины: человеческая натура и законы физики. Законы физики привнесут проблемы из области теории относительности, а человеческая натура не даст нам доверять сообщениям с клиента.
Если вы уже знакомы с базовыми идеями, стоящими за многопользовательскими играми — смело пропускайте первую часть и переходите ко второй.
Проблема читерства
Вся наша головная боль начинается с читерства.
Если вы разрабатываете одиночную игру, вам наплевать если пользователь решит смухлевать — его действия влияют только на него. Да, возможно игрок не получит тот опыт, который вы хотели ему дать, но в конце концов он купил игру и может пользоваться ей как захочет.
А вот в мультиплеерных играх все совсем иначе. В любой соревновательной игре читер не просто упрощает себе игру, но и ухудшает чужой игровой опыт. Вам, как разработчику, стоит препятствовать этому, так как читеры отгоняют игроков от вашей игры.
Есть много вещей которые можно сделать чтобы предотвратить читерство. Но самый главный принцип (и наверное самый глубокий) очень прост: не доверяй игроку. Всегда ожидайте худшего — что игрок будет пытаться вас обмануть.
Авторитарный сервер и наивный клиент
Этот принцип ведет нас к простому, на первый взгляд, решению — вся игровая логика крутится на главном сервере, под вашим контролем, а клиент лишь демонстрирует текущее состояние сервера и отправляет ему команды (нажатия клавиш и т.д.). Обычно это называют авторитарным сервером, потому что он единственный, кто умеет моделировать мир.
Конечно, сервер может быть взломан, но эта тема выходит за рамки данной серии статей. Тем не менее, использование авторитарного сервера пердотвращает широкий спектр читов. Например, вы не можете доверять клиенту уровень жизней игрока. Взломанный клиент может изменить локальную информацию и сообщить что у игрока 100000% жизней, но сервер знает что жизней всего 10% и если игрока атакуют, он умрет вне зависимости от того, что об этом думает клиент.
Так же нельзя верить игроку, когда он сообщает о его позиции в мире. Если вы доверитесь, взломанный клиент может сообщить серверу:
— Я на (10, 10)
А секундой позже:
— Я на (20, 10)
При этом возможно он «прошел» через стену или двигается быстрее чем ему положено.
А вот правильная парадигма. Сервер знает что игрок находится в позиции (10, 10); клиент говорит: «Я хочу подвинуться на единицу вправо». Сервер обновляет позицию игрока на (11, 10), производя все необходимые проверки, а затем отвечает игроку: «Вы на (11, 10)»:
Подытожим: игровое состояние управляется только сервером. Клиенты отправляют свои действия на сервер, а сервер периодически обновляет свое состояние и отправляет его на клиенты, которые, в свою очередь отображают его пользователям.
Разбираемся с сетями
Наивный клиент отлично подходит для медленных пошаговых игр — стратегий или покера. Так же он неплохо сработает при локальном подключении, где информация передается практически мгновенно. Но он абсолютно непригоден для быстрых игр по интернету.
Давайте поговорим о физике. Предположим что вы находитесь в Сан-Франциско и подключаетесь к серверу в Нью-Йорке. Это примерно 4000 километров. Так как ничто не может передвигаться быстрее скорости света, в лучшем случае сигнал дойдет за 13 миллисекунд. Но весьма маловероятно, что у вас будет такая хорошая связь. В реальном мире информация не идет прямым путем, причем не со скоростью света.
Так что давайте предположим, что это занимает 50 мс. И это практически лучший сценарий. А что если вы подключаетесь к серверу в Токио? А что если линия связи перегружена? В таких случаях задержки доходят до половины секунды.
Вернемся к нашему примеру. Пусть клиент отправляет сообщение:
— Я нажал на стрелку вправо.
Сервер получает запрос через 50 мс и сразу отправляет обратно обновленное состояние.
— Вы на (11, 10)
Это сообщение дойдет до пользователя еще через 50 мс.
С точки зрения игрока, он нажал на стрелку, потом 0.1 секунды ничего не происходило, а затем персонаж наконец подвинулся на единицу вправо. Этот лаг между командой и её результатом может показаться незначительным, но он заметен. И уж конечно лаг в полсекунды был бы не просто заметным, а сделал бы игру абсолютно неиграбельной.
Резюмируя
Игры по сети невероятно веселы, но привносят совершенно новый класс проблем и препятствий. Авторитарная архитектура хороша против читеров, но наивная реализация сделает игру неотзывчивой для пользователей.
В дальнейшем мы исследуем возможность создания системы, базирующейся на авторитарном сервере, но с минимальными задержками для игроков, делая их неотличимыми от одиночных игр.
Введение
В первой части мы рассмотрели клиент-серверную модель с авторитарным сервером и наивным клиентом, который отправляет команды на сервер и отображает обновленное состояние пришедшее в ответе.
Наивная реализация этой концепции приводит к ощутимой задержке между командой и реакцией. Например, если игрок нажимает стрелку влево, персонаж начнет двигаться через полсекунды. Это происходит потому что команда должна дойти до сервера, а результат команды после этого должен дойти до клиента.
В интернете, где задержки могут составлять десятые доли секунды, геймплей в лучшем случае будет неотзывчивым, а в худшем — неиграбельным. В этой части мы найдем способы уменьшить эту проблему или избавиться от неё вовсе.
Предсказание на стороне клиента
Несмотря на то что некоторые игроки пытаются читерить, большую часть времени сервер получает корректные запросы. Это означает, что полученный ввод будет корректным и игра обновится так, как ожидается. То есть если персонаж находится на (10, 10) и отправляет команду на движение вправо, он окажется на (11, 10).
Мы можем использовать это если игра достаточно детермениртована (то есть результат определен командами и предыдущим состоянием).
Предположим что у нас лаг 100 мс и время перемещения персонажа составляет 100 мс. При использовании наивной реализации, время действия составит 200 мс.
Предполагая что команды будут исполнены, клиент может предсказывать состояние игрового мира и часто предсказание будет правильным, так как игровой мир детерминированный.
Так что вместо того чтобы отправлять команду и ждать пока придет новое игровое состояние чтобы отрендерить его, мы можем отправить команду и начать рендерить результат как если бы команда уже была выполнена. И, разумеется, надо ждать от сервера результата — «настоящего» состояния игры, которое по большей части будет совпадать с локальным состоянием.
Теперь у нас нет абсолютно никакой задержки между действием игрока и результатом на экране, а сервер все еще авторитарный (если взломаный клиент начнет отправлять некорректные команды, он может рендерить что угодно на экране, но это никак не повлияет на состояние игры на сервере, которое видят другие игроки).
Проблемы синхронизации
В предыдущем примере я аккуратно подобрал числа чтобы все отлично работало. Давайте немного изменим сценарий. Лаг будет составлять 250 мс, а анимация передвижения на одну единицу будет длиться 100 мс. А еще давайте игрок дважды быстро нажмет на стрелку вправо.
При использовании текущего подхода вот что произойдет:
Мы столкнулись с интересной проблемой на t = 250 мс, когда нам пришло новое состояние. Клиент предсказал x = 12, но сервер говорит что x = 11. Так как сервер авторитарный, клиент должен передвинуть персонажа обратно на x = 11. Но позже, на t = 350, сервер говорит что x = 12, так что персонаж опять прыгает, но на этот раз вперед.
С точки зрения игрока, он нажал на стрелку вправо дважды, так что персонаж переместился на две единицы вправо, постоял там 50 мс, прыгнул на единицу влево, постоял там 100 мс и прыгнул на единицу вправо. Конечно, это совершенно неприемлимо.
Согласование с сервером
Ключ к решению этой проблемы лежит в понимании того, что клиент видит игровой мир в настоящем времени, но из-за лага обновления с сервера приходят о состоянии мира в прошлом. К тому моменту, когда сервер отправил нам обновления, он еще не получил некоторые наши команды.
Но обойти эту проблему не так уж сложно. Давайте будем добавлять к каждому запросу от клиента его номер. А сервер при ответе будет добавлять номер последнего обработанного запроса. И давайте хранить на клиенте копию всех команд, отправляемых на сервер.
Итак, на t = 250 клиенту приходит «x = 11, последняя команда #1». Клиент удаляет все команды до #1 включительно, но оставляет копию #2, о которой еще не знает сервер. Он применяет полученное от сервера состояние (x = 11), а затем применяет ввод, который еще не виден серверу. В данном случае #2 «вправо на 1 единицу». Конечный результат x = 12, что соответствует истине.
Далее, на t=350, от сервера приходит новое состояние: «x = 12, последняя команда #2». Клиент удаляет все копии команд до #2 включительно, а затем применяет состояние x=12(ничего не изменилось). Так как более нет необработанных команд, на этом все заканчивается, с корректным результатом.
Итоги
Мы разобрали пример с движением, но этот же принцип применим почти ко всему. Например, при атаке вражеского персонажа, вы можете сразу показать кровь и число, отражающее нанесенный урон.
Но не стоит на самом деле обновлять жизни персонажа, пока сервер не пришлет обновленное состояние.
Из-за сложностей игрового состояния, которое не всегда легко откатить, возможно не стоит убивать персонажа даже если уровень его жизни упал ниже нуля. Что если что другой игрок вылечился прямо перед вашей атакой, но сервер еще об этом не сообщил?
Прим. перев. Я бы убивал персонажа сразу, но обеспечил персистентность (возможность откатывания состояний). Так будет проще писать переносимый код, выполняющийся и на сервере и на клиенте. В любом случае, как вы убедитесь в этом позже, это придется делать.
Это все нас приводит к интересному заключению — даже если мир абсолютно детерминированный и нет читерящих игроков, все равно есть вероятность того, что состояние предсказанное клиентом и состояние отправленное с сервера не совпадают. Хоть для одного игрока это невозможно, очень легко воспроизвести такую проблему при нескольких одновременно играющих игроках. Это будет темой следующей статьи.
Резюмируя
При использовании авторитарного сервера, вы должны предоставить игроку иллюзию отзывчивости, хотя на самом деле вы ждете пока сервер на самом деле обработает ввод. Для этого клиент симулирует результат всех команд. Когда приходит обновление от сервера, состояние обновляется исходя из текущего состояния сервера и необработанных им команд.