[Перевод] Работа сети в пошаговой игре
Три года назад я приступил к разработке Swords & Ravens — многопользовательской онлайн-адаптации в open source моей любимой стратегической игры A Game of Thrones: The Board Game (Second Edition), разработанной Кристианом Питерсеном и изданной Fantasy Flight Games. На февраль 2022 года на платформе ежедневно собирается примерно 500 игроков и с момента её выпуска было сыграно больше 2000 партий. Хотя я перестал активно разрабатывать S&R, благодаря сообществу open source на платформе всё равно появляются новые функции.
Напряжённая партия в A Game of Thrones: The Board Game на Swords & Ravens
В процессе разработки S&R я многому научился и мне бы хотелось поделиться своими знаниями с людьми, которых может заинтересовать создание похожего проекта. О его работе можно сказать многое, но в этой статье я сосредоточусь на том, как проектировалась сетевая часть игры. Сначала я опишу задачу формально. Затем объясню, как она решается в S&R, а также опишу другие возможные решения, придуманные или найденные мной. Я подробно расскажу о достоинствах и недостатках каждого способа, а в конце скажу, какой из них считаю лучшим.
Формулирование задачи
В однопользовательской игре вся информация находится внутри одного компьютера. Действия игрока применяются к состоянию игры, и изменения в этом состоянии игры отражаются на экране игрока. В многопользовательской онлайн-среде всё иначе. Каждый игрок играет на своём компьютере, хранящем собственную текущую информацию об игре и имеющем собственный UI для отображения текущего состояния игры.
Общую архитектуру онлайн-игры можно вкратце описать следующей схемой:
UI
отображает игроку текущее состояние игры на основании локальной копии состояния игры. Клиенты
отвечают за взаимодействие с сервером, и отправляя действия игрока, и получая новую информацию о состоянии игры.
Нам нужно решить задачу синхронизации различных локальных состояний игры клиентов с состоянием игры сервера. Если конкретнее, нам нужно решить, как сервер должен передать изменения в состоянии игры клиентам, когда он применяет действия игрока к его состоянию игры.
Методика Update propagation
Самое очевидное решение заключается в том, чтобы применять к игре любые действия, полученные сервером, и передавать различные обновления состояния игры клиентам. Этот способ показан на схеме ниже.
Именно этот способ используется в Swords & Ravens. Он прост, интуитивно понятен и легко даёт понять, какие данные нужно и не нужно отправлять разным клиентам. Также он тривиально позволяет хранить секретные данные (т. е. данные, которые должны быть известны только подмножеству игроков). Если игрок тянет карту и берёт её в свою руку (в закрытую), то можно передать, какая карта вытянута, только этому игроку, чтобы ни один другой игрок не знал, что это за карта.
Первый недостаток этого способа заключается в том, что нужно прописать в коде все возможные обновления состояния игры. Хотя и существуют способы автоматизирования этого в JS, например, при помощи декораторов, чтобы управлять доступом к переменным состояния игры, это может снизить читаемость кода.
Второй недостаток заключается в том, что поскольку мы можем отправлять несколько обновлений для одного действия, локальное состояние игры клиента до получения всех обновлений может временно находиться в недопустимом состоянии. На показанной выше схеме между обновлением Убрать пехоту из Винтерфелла
и Добавить пехоту в Королевскую Гавань
есть отсутствующая пехота, которая изменит количество пехоты, отображаемое в UI. Хотя эту конкретную проблему можно решить отправкой комбинированного обновления (например, Переместить пехоту из Винтерфелла в Королевскую Гавань
), не все обновления конкатенировать легко.
Более правильным способом решения было бы комбинирование всех обновлений, выполняемых из-за действия, и их одновременная отправка. По сути, в этом и заключается второй способ.
Методика Delta-update propagation
Способ применения дельты обновления заключается в вычислении дельты между новым состоянием игры и состоянием игры до применения действия. Затем эта дельта передаётся клиентам, чтобы они могли применить её к своему локальному состоянию игры. Именно так работает игровой движок boardgame.io.
Это устраняет два недостатка, описанные в предыдущем способе. Нам больше не надо прописывать в коде все возможные обновления, поскольку после того, как что-то изменилось, это будет вычислено в дельте после обработки действия. Больше у вас не будет промежуточных недопустимых состояний, потому что обновления будут применяться одновременно, атомарно.
Однако при этом мы теряем простоту управления секретным состоянием. Если мы хотим препятствовать передаче некой секретной информации конкретному клиенту, сервер перед отправкой дельты клиентам должен отфильтровать из неё любую потенциально приватную информацию.
Методика action propagation method
Источником вдохновения для создания этого способа стал способ deterministic lockstep, используемый в онлайн-играх реального времени. [Хотя может показаться, что из-за наличия случайности в игре (перемешивание колоды, броски костей и т. п.) мы теряем свойство детерминированности, можно использовать генератор псевдослучайных чисел с порождающим значением, гарантирующий, что случайный бросок будет всегда одинаков и на стороне клиента, и на стороне сервера.]
В нём используется допущение о том, что обработка действий игрока детерминирована, то есть при конкретном состоянии игры применение к нему действия всегда будет давать нам одинаковое итоговое состояние игры. Мы можем воспользоваться этим свойством, чтобы избежать необходимости распространения обновлений состояния игры на клиентов. Вместо этого сервер может применять действие, полученное от клиента, а затем распространять это действие на клиентов, которые далее могут использовать это действие для вывода собственного нового состояния игры. Так как применение действия детерминировано, клиенты придут к тому же состоянию, что и состояние игры на сервере. [Качественно изложенный и проиллюстрированный разбор этого способа можно найти здесь.]
Это решение имеет множество преимуществ по сравнению с предыдущими.
Во-первых, нам не нужно кодировать дополнительную сетевую логику в коде. Единственное, что нужно реализовать — это распространение действия, выполненного игроками.
Во-вторых, потребление канала не привязано к размеру изменений, которым подвергается состояние игры. Если действие одного игрока меняет 1000 сущностей в состоянии игры, нам всё равно нужно передать только действие, а не изменения. Именно поэтому deterministic lockstep используется в стратегиях реального времени, например, в Age of Empires [перевод на Хабре]. Хотя в пошаговых играх довольно редко происходит перемещение множества сущностей при выполнении действия (не говоря уже о настольных играх), благодаря такому подходу открываются новые возможности для пошаговых игр.
В-третьих, поскольку код геймплея выполняется в клиенте, мы можем выполнять анимации различных обновлений, происходящих с состоянием игры. Например, если действие игрока уменьшит сумму его денег на 10, а затем увеличит её на 40, мы можем воспроизвести две разные анимации на стороне клиента, в то время как в предыдущем решении мы получим от сервера только информацию о том, что количество денег увеличилось на 30, поэтому клиент не сможет этого сделать.
В-четвёртых, когда игрок решает выполнить действие, клиент после отправки его на сервер напрямую может применить действие к собственному состоянию игры, не дожидаясь подтверждения сервером. Этот процесс, называемый «оптимистичным обновлением», позволяет нам избавить игровой процесс от задержек.
В целом это решение достаточно изящно. Нам достаточно лишь реализовать распространение действий на игрока, после чего мы можем сосредоточиться на реализации геймплея и нам вообще не нужно больше будет трогать сетевой код!
Однако тут есть один серьёзный недостаток. Чтобы гарантировать, что все клиенты и сервер придут к одному состоянию игры после обработки действия, нам нужно сделать так, чтобы они изначально обладали совершенно одинаковым состоянием игры. Поначалу кажется, что при этом невозможно будет хранить секретность. И в самом деле, как создать состояние, хранящееся только на сервере, если в сетевом коде используется тот факт, что состояние игры одинаково для всех акторов?
Работа с секретным состоянием
Мы можем решить эту проблему довольно изящно — позволив клиентам слегка отличаться от сервера. Если действие требует состояния, которое ранее было скрыто от одного или нескольких клиентов, то мы можем заставить сервер согласовать различия, отправив клиентам эту конкретную часть состояния игры.
Давайте проиллюстрируем это на примере из Swords & Ravens. Когда игрок двигает свою армию на территорию другого игрока, это приводит к бою. Для разрешения боя в S&R оба игрока одновременно выбирают из руки генерала своего дома, который поведёт их армии. Эта механика приводит к активному просчитыванию ходов: оба игрока пытаются догадаться, какого генерала выберет их противник, чтобы выбрать противодействующего ему персонажа, в то же время думая о том, не запланирует ли это противник и не выберет ли он генерала, противодействующего выбранному, и так далее.
Очевидно, что важно хранить секретность выбора одного из противников, если другой игрок пока не сделал свой выбор.
На показанной ниже схеме показано, как мы можем решить эту проблему.
Когда Клиент A
отправляет своё действие серверу (выбор Тайвина Ланнистера), мы распространяем это действие на обоих игроков, но прежде отфильтровываем выбранного лидера из сообщения, отправляемого Клиенту Б
. На этом этапе состояние игры Клиента Б
отличается от состояния игры Сервера
, потому что он не знает, какого лидера выбрал A
. Когда Клиент Б
отправляет своё действие (выбор Маргери Тирелл), мы используем ту же логику и отфильтровываем выбранного лидера из сообщения, передаваемого Клиенту А
. Так как оба игрока выбрали своих лидеров, мы можем согласовать разницу в состоянии игры, отправив информацию о выборе другого игрока. После этого небольшого манёвра у всех клиентов имеется одинаковое состояние игры и мы можем разрешить остальную часть боя детерминированным образом.
Стоит заметить, что мы могли бы решить не отправлять ничего Клиенту Б
после выбора лидера Клиентом A
, отправка этой информации позволяет отобразить в UI Клиента B
сообщение о том, что A
уже выбрал своего лидера.
Заключение
Если бы мне пришлось разрабатывать Swords & Ravens с нуля, я выбрал бы способ с детерминированностью. Возможность реализации сетевого кода только один раз — привлекательная и изящная особенность. Так как AGoT: TBG — довольно сложная игра со множеством разных фаз, из-за сетевой обработки каждого взаимодействия возникло много бойлерплейта, составляющего большую часть кода. Кроме того, мне так и не удалось удобно добавить в UI анимации (перемещение фигур, переход карт из руки на поле и т. п.), что не идёт на пользу AGoT: TBG, в которой одно действие может приводить к множеству обновлений состояния.
Ещё один удобный аспект использования детерминированного способа заключается в том, что можно легко создать библиотеку, обрабатывающую всю сетевую часть пошаговой игры, позволив разработчику сосредоточиться на разработке механик самой игры. Я начал создавать такую библиотеку под названием Ravens. К сожалению, по объективным причинам я не могу продолжить её разработку.
Если вы сейчас занимаетесь реализацией пошаговой игры, то я надеюсь, что эта статья поможет вам выбрать надёжную архитектуру. Если же нет, то пусть её содержание хотя бы покажется вам интересным!