Создаём единую инфраструктуру для параллельной разработки мобильных игр

Создаём единую инфраструктуру для параллельной разработки мобильных игр


Она помогла нам переиспользовать игровые механики в непохожих проектах и увеличила скорость разработки на 25%.


Что такое серверное ядро?

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

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

Часто такой базой становится первый проект студии. Нам в руки попало сразу четыре проекта на этапе формирования требований.

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

Вот какие задачи решаем с помощью серверного ядра:

  • Авторизация пользователя — связывание данных с уникальными идентификаторами, предоставляемыми социальными сетями.
  • Обработка покупок — получение чеков платёжных систем от клиента и верификация их на стороне платёжной системы. 
  • Обеспечение межсерверного взаимодействия — стандартные механизмы коммуникации между микросервисами, реализованные на уровне фреймворка.
  • Архитектурные подходы и паттерны для написания бизнес-логики — работа с данными игрока, работа с базой, структура взаимодействия логических сущностей внутри игры. Ядро предлагает библиотеку архитектурных решений, на которые можно опираться при выработке своих.
  • Архитектура для автотестов — налаженный процесс, который можно в готовом виде использовать на всех проектах, и набор примеров автотестов, которые клиент может использовать как эталон для своих проверок.
  • Пайплайны для CI/CD — это процесс организации обновлений кода в репозитории. Однажды налаженный на сервисах ядра он распространяется по остальным проектам по единой схеме.
  • Devops-инфраструктура — единое ядро и схема коммуникации микросервисов позволяет выстроить общую для всех проектов схему масштабирования в зависимости от нагрузки в облаке. 

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


Универсальные решения позволяют команде работать эффективнее.

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

По предварительным оценкам, мы экономим время, начиная с четырёх параллельно разрабатываемых проектов. Каждый следующий проект экономит компании одну роль серверного разработчика. Сейчас в работе шесть проектов, так что грубая оценка экономии 0,75X от базового X времени по совокупности.


Добавление механик или инструментов в ядро

Давайте рассмотрим пример инструмента, который можно легко распространить между проектами, но не так просто придумать с первого раза.

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

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

В примере выше самое простое, что приходит на ум, — добавить в API конкретные запросы по просьбе клиентов. Например, чтобы начислить валюту — запрос «начислитьВалюту», а чтобы добавить сущность — запрос «добавитьСущность».

Например:

@MessagePattern(AddBusinessAssetDto.CommandName)
  async addBusinessAsset(@Payload() body: AddBusinessAssetDto) {
    const player = await this.getPlayer(body);
    …
    const { businessAssetId } = body.data;
    const businessAsset = await this.service.addBusinessAsset(
      player,
      businessAssetId,
    );
    const updates = await player.save();
    return buildSuccessAnswer(businessAsset, { updates });
  }

В API появляются запросы:

  • add-business-asset-cheat
  • add-currency-cheat,
  • add-item-cheat

и так далее.


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

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

После того как чит-запросы на двух проектах начали отнимать ощутимое время разработки, мы решили пересмотреть подход. Сделали универсальный DSL (Domain-specific language) для описания трансформаций данных игрока. С помощью DSL разработчик со стороны клиента может пользоваться одним серверным запросом для любых изменений — и им больше не нужно добавлять новые или модифицировать существующие запросы для читов:

apply-modifications

…
"modifications": [    
  {
    "type":"businessAsset",
    "action":"add",
    "params":{
      "id": "ba_0001",
      "capture": true,
      "level": 5,
    }
  },
  {
    "type":"currency",
    "action":"add",
    "params":{
      "id": "cryptoCoin"
      "amount": 3000
    }
  }
]

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

…
@MessagePattern(ApplyModificationsDto.CommandName)
@UseInterceptors(PlayerModelInterceptor(false))
@UseGuards(DevelopmentGuard)
async modificatePlayer(
  @Payload() data: ApplyModificationsDto,
  @Ctx() { player }: ClientContext,
) {
  const result = await this.modifiactionService.execute(
    player,
    data.data.modifications,
  );
  const updates = await player.save();
  return buildSuccessAnswer(result, { updates });
}

public async execute(player: Player, modifications: TModification[]) {
  const invalidModifications = await this.validateModifications(
    modifications,
  );

  if (invalidModifications.length) {
    Logger.warn(
      `Bad modifications. Validation result: ${JSON.stringify(
        invalidModifications
      )}`,
      this.constructor.name,
    );
    return { success: false, invalidModifications };
  }

  for (const modification of modifications) {
    await this.modificationExecutorsProvider
      .getExecutor(modification.type)
      .execute(player, modification);
    }
  return { success: true };
}…

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

Передача обновлений ядра другим клиентам

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

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

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

Обновление ядра — двунаправленная история. Часть изменений проектов попадает в ядро, необходимые изменения ядра распределяются по остальным проектам. Это происходит через Git Merge и иногда болезненный процесс ручного слияния. Мы пока не нашли варианты, как сделать это автоматизировано. 

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


Единая функциональность при различных требованиях

Не становится ли единое ядро ограничением в выборе архитектурных решений на разных проектах? Иногда у клиентских программистов возникают противоречивые требования к модулям ядра. И это естественно.

У каждой из команд своё видение, как должна быть устроена их архитектура, и у нас нет желания навязывать собственное. И мы готовы делать уступки клиентам, потому что игра — их основной продукт. Наша история — инфраструктура. Мы стараемся предлагать командам механики, которые им подходят.

Одним из таких случаев было проектирование механики отправки обновлений пользователей. Первый вариант — использование JSON Patch в качестве списка изменений в данных клиентах — был разработан совместно с клиентской командой, которая предпочитала UniRX и реактивщину. Но не на всех проектах клиентские разработчики оценили подход, и нам пришлось расширить функциональность ядра.

А не ограничивает ли ядро клиентских разработчиков детским выбором определённых инструментов? Был случай, когда нашим клиентам не подошёл предложенный нами способ доставки обновлений ядра. 

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

  • В первом случае — это список того, что изменилось в данных клиента в формате JSON Patch.
  • Второй вариант представляет перечень изменений корневых сущностей. Мы пишем, что поменялось, и передаём объект клиенту целиком. Тут сохраняется совместимость с JSON Patch, но клиенту нет необходимости самому разбирать каждую строку, он может загружать все данные объекта.
  • И в третьем случае — это JSON-структура. Сама модель, но содержащая только изменённые поля. Здесь клиенту уже нет необходимости поддерживать формат JSON Patch.

Каждый вариант по-своему хорош и используется разными командами.


Для наглядности пример первого варианта апдейта:

[
  {
    "op": "replace",
    "path": "/kicks/random/seed",
    "value": 2926611012
  },
  {
    "op": "add",
    "path": "/dailyRewards/slots/0",
    "value": {
      "id": 2,
      "status": 0
    }
  },
  {
    "op": "replace",
    "path": "/dailyRewards/slots/1/status",
    "value": 0
  },
  {
    "op": "replace",
    "path": "/dailyRewards/resetTime",
    "value": 1643362211
  },
  {
    "op": "remove",
    "path": "/foo"
  }
]

Во втором варианте передача производится в том же формате JSON Patch за одним изменением — будут передаваться заранее определённые сущности целиком и всегда в контексте op = "replace" или "add". "remove" будет передаваться как "replace" cо значением null.

Модифицированный под реализацию апдейт из примера выше:

[
  {
    "op": "replace",
    "path": "/kicks/random",
    "value": {
      "seed": 2926611012
    }
  },
  {
    "op": "add",
    "path": "/dailyRewards/slots/0",
    "value": {
      "id": 2,
      "status": 0
    }
  },
  {
    "op": "replace",
    "path": "/dailyRewards/slots/1",
    "value": {
      "id": 1,
      "status": 0
    }
  },
  {
    "op": "replace",
    "path": "/dailyRewards/resetTime",
    "value": 1643362211
  },
  {
    "op": "replace",
    "path": "/foo",
    "value": null
  }
]

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

Модифицированный под реализацию апдейт из примера выше:

{
  "kicks": {
    "random": {
      "seed": 2926611012
    }
  },
  "dailyRewards": {
    "slots": {
      "0": {
        "id": 2,
        "status": 0
      },
      "1": {
        "id": 1,
        "status": 0
      }
    },
    "resetTime": 1643362211
  },
  "artefacts": {
    "map": {
      "02": null
    }
  },
  "foo": null
}

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

Чтобы они быстрее разобрались с нюансами, мы улучшили процесс онбординга.


Как мы проводим онбординг новых разработчиков

Наши проекты работают на Node.js, а пишем мы на TypeScript. В качестве фреймворка мы используем Nest.js. Для коммуникации сервисов использовался RabbitMQ, и недавно мы переехали на NATS, основная база данных MongoDB. В качестве основного облака используем AWS. Мы старались выбирать инструменты максимально близкие к SoTA серверной разработки. 

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

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

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

Говорим, что хорошо, а что нет. Что-то, например, может не укладываться в наш подход. Обязательно слушаем аргументы и в результате приходим к консенсусу. После двух или трёх циклов человек уже понимает схему нашей работы и может в этом процессе участвовать на правах полноценного члена команды. Обычно это занимает две-три недели.


Report Page