Делаем очередную .io-игру

ba1656891b6d4d99939f4b6a15e2a8d1.jpg

Так называемые .io-игры — это браузерные massively multiplayer action-игры, в которых множество людей борются с излишками свободного времени. Massively multiplayer — это значит, что игра представляет собой многопользовательскую массовку из большого количества (сотен+) игроков, играющих в общей локации. Существует мнение, что все началось с игры Agar.io (клетки в чашке Петри). Другими успешными примерами можно назвать Slither.io (змейки) и Diep.io (танчики). Если верить статистике, то каждый день в эти игры играют миллионы игроков. Сегодня существуют десятки различных .io-игр, большинство из которых можно найти, загуглив по запросу «io games list».


Я расскажу о том, как мы делали нашу .io-игру Oceanar.io — игру про рыбок и прочих морских жителей, постараюсь при этом сосредоточиться на вопросах общего технического устройства всей системы, и дам несколько скромных советов.


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


Игровой процесс


Для начала нужно было решить, о чем будет игра. Обдумав игровую механику двух наиболее успешных представителей — Агарио и Слизарио — мы решили, что игра должна обладать следующими свойствами:


  1. Управляемый персонаж должен набираться сил постепенно в течение длительного времени, и этот набор сил должен отражаться визуально.
  2. Нужно чтобы был смысл играть очень долго — много часов подряд. Игрок, проигравший 5 часов, должен иметь определенное преимущество перед тем, кто проиграл 3.
  3. Игровое преимущество не должно зависеть исключительно от времени, проведенного в игре. Должен быть разумный баланс между отыгранным временем и игровым мастерством. Человек, отыгравший несколько минут, должен иметь возможность победить того, кто играет уже несколько часов. Пусть это будет очень сложно, пусть здесь понадобится фактор удачи, но такая возможность должна быть.
  4. Бонусы, полученные в результате победы на другим игроком, не должны безоговорочно доставаться победителю — у других игроков должна быть возможность отхватить их часть.
  5. Иметь хотя бы что-то оригинальное:)

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


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


У меня нет опыта в создании серьезных MMO-игр, но, понабивав шишки на своих прошлых проектах, а также почитав об опыте различных разработчиков (например) у меня сложилось мнение, что клиент-серверная архитектура .io-игр принципиально не сильно отличается от остальных MMO-игр — в ее основе лежат примерно те же идеи. В качестве одного из важных (и приятных для разработчиков-одиночек) отличий .io-игр я бы выделил существенно упрощенные игровые правила: если в серьезной MMORPG для вычисления урона от удара топором по голове гоблина на сервере нужно отыграть целую вериницу правил, то в .io-игре это будет простая формула в одну строчку кода. То есть, .io-игра — это та же MMO с большим количеством игроков, но гораздо более простая с точки зрения организации своих внутренностей. Достаточно простая, чтобы ее мог написать один программист, и клиентскую, и серверную часть.


Клиент


Игровой клиент — это браузер с открытой в нем html-страницей. Технологии те же, что и у большинства других .io-игр, а именно: язык программирования — JavaScript, графика — WebGL, взаимодействие с игровым сервером — через WebSockets. Так уж сложилось, что .io-игры не принято озвучивать (хотя все технические возможности для этого имеются), поэтому мы решили, что и наша игра будет без звука (чтобы не привлекать лишнего внимания у начальства на работе).


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


  1. Умеет отправлять на сервер действия пользователя.
  2. Умеет принимать с сервера сообщения и диспетчеризировать их.
  3. Хранит у себя копию игрового мира, попадающую в его поле зрения.
  4. Визуализирует хранимую копию игрового мира.

Вот примеры сообщений, и действий по их обработке со стороны клиента:


Показать рыбу с идентификатором creature_id, которая находится в позиции position, имеет вектор скорости velocity, принадлежит игроку с идентификатором owner_id, и является рыбой типа creature_type_id.


С точки зрения JavaScript это будет например вот такой объект:


{
    message_type_id: 1,
    creature_id: 68328,
    creature_type_id: 2,
    owner_id: 9306,
    position: { x: 1.436, y: -39.32 },
    velocity: { x: -0.17235, y: -0.1157 }
}

Получив такое сообщение, клиент добавит рыбу в мапу рыб, и когда дело дойдет до отрисовки игровой сцены, рыба появится на экране.


Рыба с идентификатором id1 укусила рыбу с идентификатором id2.


Получив это сообщение, клиент возьмет координаты рыбы с идентификатором id2, и добавит с такими же координатами пузырь в массив пузырей, а также пятно крови в массив пятен крови. Далее отрисовка выведет их на экран.


Таким образом можно сказать, что сообщения от сервера клиенту — это в основном уведомления о том, что в поле зрения клиента что-то произошло. Что-то такое, о чем клиент должен знать, чтобы правильным и понятным образом (графически) уведомить об этом игрока. Примеры других типов сообщений — «игра окончена с таким-то счетом», «в игру вошел игрок с таким-то именем», «список топ-10 игроков», «список всех игроков и их координат» (для отображения их на карте), и так далее.


Сервер


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


Наш сервер написан на C++, из сторонних библиотек используется Boost, сетевая часть — Boost.Asio. Разработка и тестирование — Visual Studio / Windows, рабочий сервер — GCC / Linux.


Игровая комната


Здесь происходит вся игровая логика. Комната — это объект, который, среди прочего:


  1. Хранит контейнеры игровых объектов: существ, еды, наблюдателей.
  2. Умеет принимать от клиентов сообщения и диспетчеризировать их.
  3. Умеет уведомлять клиентов о событиях, наступивших в комнате. Некоторые из уведомлений отправляются только если данные события наступили в поле зрения клиента.
  4. Реализует функцию перехода комнаты из состояния An в состояние An+1 (апдейт игрового мира).
  5. Периодически выполняет апдейт игрового мира с течением времени (в нашем случае — 10 раз в секунду).

Обезжиренный интерфейс игровой комнаты на псевдо-коде:


class room
{
methods:
    join(user);
    update(dt);
    dispatch(protocol::kill_creature);
    dispatch(protocol::consume_food);
    dispatch ...
    dispatch ...
    dispatch ...

properties:
    container creatures;
    container foods;
    container observers;
}

Апдейт игрового мира


Это функция, которая реализует изменения в игровом мире, произошедшие за фиксированный отрезок времени dt (в нашем случае — 0,1 секунды), я буду называть эту функцию update. В общих чертах update игровой комнаты — это вызов update для игровых объектов, то есть вызов update далее вниз по лестнице агрегации, от общего к частному. Простой пример: пусть у нас есть рыба, находящаяся в координатах position, и двигающаяся с вектором скорости velocity. Тогда ее функия update — это вычисление ее позиции через dt времени:


next_position = position + dt * velocity;

Помимо перемещения, рыба может за это dt принять решение отправиться в какое-то другое место, и тогда, помимо обновления своих координат, в результате update может измениться ее вектор скорости velocity. На псевдо-коде update комнаты выглядит следующим образом:


room::update(dt)
{
    message_queue.dispatch(); // Диспетчеризовать сообщения, адресованные комнате, находящиеся в очереди

    foreach(creature in creatures)
    {
        creature.update(dt);
    }

    foreach(food in foods)
    {
        food.update(dt);
    }

    foreach(observer in observers)
    {
        observer.update(dt);
    }

    spawn_food();
    spawn_npc();
}

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


Разбиение игрового пространства


В ряде алгоритмов, реализующих логику игры, нужно уметь перебирать объекты, ближайшие к данному. Например — показать пользователю всех рыб в поле его зрения, или проверить, не собирается ли данная рыба укусить кого-то из своего окружения. Если мы будем перебирать пары объектов по принципу «каждый с каждым», то мы получим квадратичную сложность, а это нам не нужно, ни для CPU, ни для трафика.


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


Если нас интересует список объектов, ближайших к данному, мы запрашиваем списки клеточки, в которой находится данный объект, а также списки объектов из соседних клеточек. Итого 9 клеточек.


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


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


Протокол


Отдельный вопрос — протокол взаимодействия сервера и клиентов. Под протоколом я подразумеваю описание набора и структуры сообщений, которыми будут обмениваться сервер и клиенты, их сериализацию при отправке, и десериализацию и диспетчеризацию при получении. Я использовал свое собственное упрощенное велосипедное решение — кодогенератор из JSON-описателя пакетов (так уж получилось). Если вам нужно готовое серьезное решение, а не велосипед, то есть ru.wikipedia.org/wiki/Protocol_Buffers, который тоже является кодогенератором.


Почему кодогенерация?


  1. Позволяет минимизировать код, который нужно писать руками.
  2. Скорость работы — в кодогенераторе вы не ограничены никакими правилами, можно сгенерировать максимально быстрый код, в рамках используемого языка.
  3. Объем передаваемых данных — можно (и нужно) плотно упаковывать данные в соответствии с вашими потребностями, минимизируя трафик (об этом отдельно ниже). С кодогенератором особенно удобно ввести дополнительные атрибуты к полям сообщений, задающие правила упаковки этих полей.

Очереди сообщений


Для отправки сообщений комнате используется multiple producer single consumer очередь. Для отправки сообщений клиентам — single producer single consumer. Оба типа очередей — самописно-велосипедные. Вероятно, можно было воспользоваться Boost.Lockfree, но, опять же, так уж получилось. Для (де-)сериализации и диспетчеризации сообщений, путешествующих внутри сервера, используется тот же кодогенерированный механизм, что и для передачи сообщений по сети.


Я постарался описать общую структуру игры и некоторые ее отдельно взятые, важные на мой взгляд моменты, чтобы у вас сложилась полная картина того, как устроена вся система целиком. Надеюсь, у меня это получилось :)


Советы


Если вы собрались сделать свою .io-игру, то я могу сказать лишь одно — берите и делайте! :) И вот несколько скромных советов:


Боты


Вместе с клиентом и сервером сразу же сделайте ботов. Причем боты должны ходить на сервер по сети, по тому же протоколу, что и клиенты, и «видеть» ту же информацию, что и реальные игроки. Это существенно облегчает отладку, а также позволяет оценить потребляемые ресурсы в условиях, приближенных к боевым. Сколько всего игроков онлайн потянет сервер? Когда мы упремся в CPU или ширину сетевого канала? На эти и некоторые другие вопросы вам помогут ответить боты, тем самым избавив вас от неприятных сюрпризов после релиза. Кроме того, боты позволят сделать игру не слишком скучной, пока в ней мало живых игроков.


Сетевой трафик


Так вот, минимизация сетевого трафика. Обычно у хостинг-провайдеров трафик либо платный по счетчику, либо предоплаченный пакет с возможностью докупки, либо безлимитный, но со звездочкой-сноской, по которой находится оговорка, что на самом деле не такой уж он и безлимитный — при исчерпании определенного объема вас будут ждать карательные меры в виде, например, урезания ширины канала, или же придется таки платить сверху. Поэтому — тщательно продумайте ваш протокол и экономьте каждый байт! У нас 10000 живых игроков, отыгравших за сутки, генерировали за эти сутки порядка 200 гигабайт игрового трафика. Наверно это может показаться не так уж много, но не забывайте, что объем потребляемого трафика будет расти вместе с вашей аудиторией. Несколько сэкономленных байт в отдельно взятом пакете могут сэкономить сотни мегабайт или даже гигабайты трафика в сутки.


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


HTTPS


Если вы захотите сделать вашу игру в том числе iframe-игрой ВКонтакте, то придется принудительно переехать на HTTPS, поскольку раз сам vk.com работает через HTTPS, то и от iframe внутри него будет требоваться тот же протокол. А сайт, работающий на HTTPS, может работать только с SSL-вебсокетами.


Список технологий


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


  1. HTML / CSS — верстаем сайт.
  2. JavaScript — программируем клиентскую часть игры.
  3. WebGL / OpenGL ES Shading Language — делаем графон.
  4. WebSocket — делаем сеть.
  5. Protocol Buffers / Велосипед — делаем протокол клиент-серверного общения.
  6. Язык, на котором будет написан игровой сервер. Здесь выбор огромен: Java, Scala, C++, Rust, Erlang, Haskell… Что только вашей душе угодно.
  7. PROFIT!

Результат


Наша игра все еще находится в бете, но посмотреть на результат можно уже сейчас: oceanar.io (пока что работает только для десктопа, мобильные и планшетные версии на очереди).


Спасибо за внимание. В комментариях отвечу на ваши вопросы.

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

  • 10 ноября 2016 в 21:53 (комментарий был изменён)

    0

    Периодически выполняет апдейт игрового мира с течением времени (в нашем случае — 10 раз в секунду).

    Тикрейт 10 раз в секунду — не слишком мало? Обычно в динамичных играх используется обновление не меньше 30 раз в секунду

    • 10 ноября 2016 в 21:54

      0

      Сначала я тоже сомневался, но потом сделал дополнительную интерполяцию на клиенте, и оказалось что этого вполне достаточно.
      • 10 ноября 2016 в 22:23 (комментарий был изменён)

        0

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


        P.S. Медуз стоит заменить на каких-нибудь акул, странно выглядит, когда милая медузка пожирает половину стаи…

        • 10 ноября 2016 в 22:36

          0

          100 мс — это не адские лаги, возьмите секундомер и посмотрите сколько длится 1/10 секунды — это очень быстро :) Видит ли клиент мир в прошлом — вопрос сложный, поскольку время на сервере идет точечными скачками, между которыми вселенная находится в замороженном состоянии :) Скорее нет, клиент видит мир таким, каким он был то время назад, которое потребовалось чтобы доставить TCP-пакет с сервера на клиент — это может быть гораздо быстрее, чем 100 мс. И Ping в углу экрана пусть вас не смущает — внутриигровой понг отправляется не сразу, а в конце ближайшего update. Поэтому он часто близок к dt -100 мс. Клиент отправляет данные серверу да, каждые 100 мс.

© Habrahabr.ru