Три года назад я приступил к разработке 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. К сожалению, по объективным причинам я не могу продолжить её разработку.

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

Комментарии (13)


  1. Ladone
    17.02.2022 11:46

    Даже не слышал о таком проекте, спасибо! Мы как раз в офисе в неё поигрываем ИРЛ. Затестим.


  1. Adamos
    17.02.2022 11:56
    +1

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

    Не очень понятно, зачем вообще что-то бросать на стороне клиента. Клиент передал на сервер намерение - взять карту. Сервер бросил кубик и ответил этому клиенту, какая взята карта, всем остальным - факт, что карта взята.

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

    Никаких расхождений состояния, и заодно потенциальные читеры обтекают...


    1. Fen1kz
      17.02.2022 13:16

      Это Delta-update propagation по терминологии статьи. Ну да, конечно, мне кажется автор преувеличил проблему секьюрности в этом подходе.


      Я в своей игре использую этот подход (Сейчас посидел, подумал, походу у меня все же action propagation. Потому что я не высылаю специальную дельту, я просто исполняю код сервера на клиенте)


      У меня секьюрность достигается методами toClient / toOthers в моделях


      Хотя, если так критично посмотреть, то, например, карты у меня раздаются не очень красиво:


      // Это redux thunk action если что:
      export const server$gameGiveCards = (gameId, userId, count) => (dispatch, getState) => {
      
        // Берем карты
        const cards = selectGame(getState, gameId).deck.take(count);
      
        // Диспатчим экшн раздачи на стор сервера, чтобы он раздал карты
        dispatch(gameGiveCards(gameId, userId, cards));
      
        // Отдельно диспатчим экшн юзеру чтобы он эти карты получил
        dispatch(toUser$Client(userId, gameGiveCards(gameId, userId, cards.map(card => card.toClient()))));
      
        // А вот всем остальным юзерам (uid !== userId) высылаем "отфильтрованные" карты. Поидее можно было бы число, не помню почему так сделал, вроде из-за анимации проще было.
        dispatch(to$({clientOnly: true, users: selectUsersInGame(getState, gameId).filter(uid => uid !== userId)}
          , gameGiveCards(gameId, userId, cards.map(card => card.toOthers().toClient()))));
      };

      И хорошо, что у меня мало скрытой инфы в игре, а то было бы действительно неудобно.


      Я вижу что перевод, но в целом хочется посоветовать тем, кто пишет пошаговую веб-игру на js брать redux. Тут это разделение на экшены / редьюсеры прям очень хорошо заходит. Вот код клиента игры — вся игровая логика просто копируется с сервера.


  1. Enfriz
    17.02.2022 12:06
    +2

    код геймплея выполняется в клиенте

    Но это ведь ненадёжно для сетевых игр. Как защищаться например от подделки памяти? Вот я себе нагенерирую миллион монет на клиенте. А на другом клиенте у меня 10 монет. Как они разрешают этот конфликт?


    1. soniq
      17.02.2022 12:25

      Так сервер все равно проверяет корректность действий. Хакнутый клиент отправит команду «купить шлем за миллион», но в ответ получит не шлем а ошибку.


      1. Adamos
        17.02.2022 13:39
        +1

        Выполнить код на клиенте и бежать проверять его результат на сервер, которому для проверки все равно придется выполнить тот же код?

        Вместо того, чтобы просто выполнить код на сервере и выдать клиентам заведомо валидный результат?


        1. soniq
          17.02.2022 13:43

          Именно так, да. Статья же как раз про то, почему не всегда получается посчитать все состояние на сервере и просто раздать клиентам.


          1. Adamos
            17.02.2022 13:50
            +1

            Все равно не понимаю. Что мешает в пошаговой стратегии игроку общаться с сервером в формате "намерение - результат", а в конце его хода серверу раскидать сумму результатов другим игрокам?


            1. soniq
              17.02.2022 14:29
              +1

              Я так понял, что игра у них не очень-то пошаговая. В том смысле, что другие игроки на твоём ходу не просто смотрят на чёрный экран, а в реальном времени видят, что ты делаешь (ну, в рамках механик, конечно)


              1. Adamos
                17.02.2022 14:49

                Так это "делаешь" все равно проходит верификацию на сервере. Ну, не весь ход, кусками можно передавать. На вопрос "почему это делается И на клиенте, И на сервере" я все равно ответа не вижу.


                1. Fen1kz
                  17.02.2022 15:37

                  Автор предлагает 3 варианта:


                  update propagation — это "намерение — результаты". Здесь минусы в том, что на клиенте создается невалидное состояние.


                  delta-update propagation — это "намерение — результат". Минусы в лишнем коде на клиенте и в секретном состоянии. Например у вас есть видимый юнит А и скрытый юнит Б. Вы двигаете их, получаете дельту "юнит А передвинулся, юнит Б передвинулся", а дальше вам надо лезть в логику сервера и передавать одному игроку "юнит А передвинулся, юнит Б передвинулся", а другим игрокам "юнит А передвинулся".


                  action propagation — передавать просто намерение. "Передвинуть юнит А, передвинуть юнит Б". Дальше на сервере мы можем проверить, двигаются они или нет и выслать не результат, а только намерение.


                  Важно заметить, что намерение отфильтровать легче:


                  Пришло 2 намерения, одно передаем, второе пропускаем.


                  В случае с дельтой это будет собираем дельту 1, собираем дельту 2, одну туда, вторую сюда.


                  И вот сервер высылает клиентам намерение:
                  1) Игроку "Подтверждаю: (Передвинуть юнит А, передвинуть юнит Б)"
                  2) Другим: "Подтверждаю: (Передвинуть юнит А)".


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


                  Проверять то, как выполнился код на клиенте не надо — либо клиент не модифицирован и работает так же как сервер, либо клиент начитерил себе скорость юнита "+100500", и тогда сам себе злобный буратино.


                  Раз в какое-то время или событие можно высылать общее состояние для синхронизации, конечно.


                  Поэтому, Q: "почему это делается И на клиенте, И на сервере"
                  A: Так проще чем городить дельты, объединять их, разделять их и пр.


                1. soniq
                  17.02.2022 15:41
                  +1

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

                  Чтобы сэкономить трафик и, главное, избежать заеданий интерфейса при не идеальном коннекте, спекулятивно запускают часть логики на клиенте — клиенту просто говорят что юнит А атакует юнит Б, и он уже сам дальше высчитывает в какой момент времени включить анимацию меча у юнита А, а когда на юните Б нарисовать раны.

                  И вот тут интересно то, что можно конечно сделать два разных кода — один следит за правилами, а второй отображает ход игры. Но при достаточно сложных правилах игры, логика будет неизбежно разъезжаться — по правилам сервера юнит умер, а правила клиента не обновили, и анимация смерти не запустилась. И чтобы этого избежать, просто запускают один и тот же код и на сервере и на клиенте.


    1. vesper-bot
      17.02.2022 13:00
      +2

      В таких случаях используется принцип "сервер всегда прав", так как априори именно на нем "идеальное" состояние игры. Если действия какого-то клиента или информация от него противоречит состоянию на сервере, его пинают за читерство.