Особенности протокола в IO играх
Механика таких игр обычно относительно проста и интерес достигается за счет эпических битв между большим количеством реальных игроков.
Родоначальником жанра является agar.io
И ничего сложного нет в том, чтобы создать свой протокол над ним.
Я так думал, когда примерно год назад мы начинали проект космического шутера в этом стиле.
Сейчас я так не думаю.
В чем проблема
Основная проблема, которую приходится решать всем разработчикам многопользовательских игр — достижение «одновременности» происходящего.
Наивный подход:, а давайте я буду передвигать своего героя, отсылать эти передвижения на сервер, а сервер станет рассылать их моим соперникам — не работает. Если поступить так, то ваш герой будет жить в реальном времени, а его соперники — в прошлом. Их движения будут отставать примерно на два пинга до сервера. Скажем, если пинг 100 мс, то вы увидите где были соперники 200 мс назад. Соответственно, играть будет невозможно.
Постановка задачи
Есть двухмерные комнаты в которых летают, следуя за указателем мыши, космические корабли.
Нужно создать протокол так, чтобы
- перемещения были плавными
- у всех «пилотов» картинка была одинаковая и отображала все корабли в какой-то один момент времени
Disclaimer:
Итоговый результат — очень простой.
Но попробуйте, для чистоты эксперимента придумать свой вариант до прочтения статьи, чтобы при написании комментариев избежать эффекта знания задним числом.
Первое решение
Пусть каждый клиент при отрисовке каждого кадра посылает на сервер положение курсора мыши.
А сервер, получив его, немедленно считает текущие координаты корабля и рассылает их всем клиентам (включая и того, кто передал координаты мыши).
Все просто. Одновременность здесь достигается.
И это даже работает, пока у нас сервер стоит локально, а клиентов совсем немного.
В условиях приближенных к реальным такой подход дал три вещи: бешеный трафик (который на порядок превосходил трафик того-же agar.io), периодические «прыжки» кораблей (вместо плавного полета) и дикие лаги, которые обычно начинались через 3–5 минут, а потом не проходили уже никогда и «лечились» только рестартом браузера.
Вторая версия протокола
Первая правка была очевидной: неправильно отсылать перемещения корабля сразу после получения управляющей команды от клиента.
Если делать так, то периодичность поступления команд клиентам от сервера зависит от трех вещей:
- частоты их отправки корабликом на сервер
- загруженности канала от кораблика до сервера
- и загруженности каналов от сервера до остальных клиентов
Слишком много переменных.
Измерения показывали, что команды, которые по-идее отправляются раз в 20 мс приходят со случайным интервалом от 5 до 90 мс.
Решение этой проблемы было простым — вместо немедленной отправки команды на клиент сделали отправку по таймеру — одна команда в 20 мс.
«Прыжки» корабликов заметно уменьшились…
Третья версия
…, но не исчезли.
В какой-то момент, повинуясь сетевым лагам, очередной пакет приходил с большой задержкой. Не через 20 мс, а через 100, например.
Соответственно, кораблик сначала тормозил, а потом резко срывался с места и «телепортировался» на несколько пикселей.
И тут возникла странноватая идея:, а давайте в случае таких вот задержек все-таки будем двигать кораблик в ту же сторону, в которую он летел ранее.
Может картинка станет более плавной? Идея эта не сработала, но она оказалась шагом в сторону правильного решения.
И так, кораблик на клиенте теперь все время (то есть каждый кадр) летит со своей постоянной скоростью.
С сервера приходят команды на изменение этой скорости, а также «поправки» для координат корабля.
Результат? Вместо редких прыжков на длинные дистанции наши кораблики стали совершать серии прыжочков на короткие расстояния.
Получалось, что с точки зрения клиента корабль находится в одном месте, а с точки зрения сервера — в другом.
Присылаемые с сервера «поправки» и приводили к микропрыжкам.
Дальнейшее может служить примером, как две дебильные не совсем удачные идеи, реализованные последовательно, могут давать неожиданный результат:
А давайте будем слать поправку пореже и посмотрим, что из этого выйдет!
Ничего хорошего, конечно, не вышло — прыжки снова стали более редкими и длинными.
Но зато я обратил внимание, что управление корабликом не становится менее отзывчивым даже при кратном увеличении интервала между отсылкой поправки.
Четвертая версия
А ведь это означает, что я совершенно зря смешал две характерных величины: FPS — необходимость перемещать кораблик по экрану 60 раз в секунду, и отправку управляющего сигнала от мыши.
Управляющий сигнал можно отсылать гораздо реже, поскольку он коррелирует не с особенностями человеческого зрения, а со скоростью человеческой реакции.
А она — примерно 200–250 мс!
С учетом пинга до сервера (примерно 100 мс) это означало, что управляющие сигналы можно посылать примерно раз в 100–150 мс, а плавность движения уже обеспечивать на клиенте.
В итоге четвертая версия протокола выглядела так:
- клиент отсылал на сервер положение мыши раз в 120 мс
- получив эту управляющую команду, сервер пересчитывал координаты корабля
- асинхронно, на сервере работала нитка, которая ровно один раз в те же 120 мс отправляла координаты и скорость корабля на клиенты
- получив координаты, клиенты передвигали корабли в эту новую точку, а скорость использовали для плавного движения корабля с целью достижения приемлемого FPS
Бонус от этой версии — резкое уменьшение трафика. Теперь он стал таким же, как на agar (кажется Matheus Valadares что-то знал!)
Но самое главное — автоматически решилась проблема тяжелых лагов, наступающих через 3–5 минут и неизлечимых ничем.
(Через несколько дней мне попалась вот эта статья и стало понятно, что именно там происходило.)
Теперь оставалась только одна проблема — мелкие прыжочки кораблей во время синхронизации.
Версия пятая — конечная
Если синхронизацию не делать или делать её редко, то местоположение кораблей на сервере и на клиентах оказывалось разным, что естественно было неприемлемо.
Но терпеть прыжочки, которые превращались в прямо-таки полноценные прыжки при любой сетевой задержке, мы тоже не хотели.
Первоначальная мысль выглядела так: А что если мы «размажем» синхронизацию по времени? Вот не будем сразу перемещать кораблик в нужную точку, а скажем ему переместиться туда кадров за 7–8?
— Так, но ведь это — дополнительная задержка…
— А кстати, мы ведь меняем скорость кораблика только раз в 120 мс. Значит мы заранее знаем где он будет через 120 мс!
— С этого места подробнее!
Так родилась простейшая (при её рассмотрении задним числом) идея: вместо синхронизации, сервер отправляет клиенту координаты корабля, на которых он должен быть через 120 мс.
И весь протокол стал таким:
- клиент отсылает на сервер положение мыши раз в 120 мс
- получив эту управляющую команду, сервер пересчитывает координаты корабля
- асинхронно, на сервере работает нитка, которая ровно один раз в те же 120 мс отправляет на клиенты координаты, в которых корабль должен быть к концу следующих 120 мс
- получив координаты, клиенты делают простой расчет: если сейчас кораблик находится в точке А, то с какой скоростью надо передвигать его в течение следующих 120 мс, чтобы он оказался в пришедшей с сервера точке B
Этот протокол решил все проблемы. Он устойчив к задержкам пакетов.
Даже если по какой-то причине в момент получения новой цели кораблик не достиг (или перелетел) прежнюю, это никак не скажется на плавности хода — лишь немного подкорректируется скорость.
Сейчас мы довольны протоколом. Результат можно посмотреть здесь: https://spacewar.io