Привет! Меня зовут Кирилл, я руководитель отдела серверной разработки в Pixonic. Здесь я работаю уже более 5 лет. Долгое время Pixonic была компанией одной игры — War Robots. Но однажды к нам пришло осознание, что так больше продолжаться не может, и мы начали работу над созданием новых проектов.
Поначалу мы взялись за это дело по старинке, используя традиционные для нас подходы: писали клиент на Unity 3D, бэкенд разрабатывали на Java. Это было привычно, понятно, но имело ряд серьезных недостатков. Проекты разрабатывались медленнее, чем нам бы хотелось. Для выполнения любой задачи необходимо было задействовать как минимум двух разработчиков. Однако, когда в разработке участвуют два и более человека, неизбежно возникают ошибки в духе: то один не так понял другого, то второй работает быстрее, чем первый. Такие ситуации приводят к тому, что кому-то из разработчиков в дальнейшем приходится возвращаться к задаче, которую он, казалось, уже давно закончил, а ведь у него и других дел полно. Так мы начали думать над тем, как разрешить эту проблему.
Еще нас огорчало, что каждый раз так или иначе приходилось сталкиваться с инфраструктурными вопросами: разработка и поддержание API и схем баз данных, написание DTO’шек, их преобразование из и в модели на клиенте и сервере. Эта рутина многим привычна, они ее даже не замечают, однако она отнимает время, которое можно было бы использовать с большей пользой. Эту проблему, казалось бы, можно решить клонированием предыдущего проекта — но все оказывается сложнее, когда дело доходит до взаимодействия с геймдизайнерами. Они постоянно придумывают что-то новое, и даже если поначалу кажется, что у проектов много общего — возьми да скопируй, — то по факту оказывается, что это совсем не так. В итоге от клонированного проекта остается только набор библиотек и каркас à la bootstrap.
После долгих размышлений наши желания оформились в следующие требования:
- Один разработчик должен уметь выполнить задачу как на клиенте, так и на сервере: не должно быть разделения ролей по профилю.
- Разработчики в своей работе не должны задумываться об инфраструктуре: о базах данных, протоколах взаимодействия между сервисами, логировании совершенных операций. Все внимание должно быть сосредоточено на разработке бизнес-логики.
- Модели, используемые на сервере, должны как можно чаще использоваться на клиенте.
- Фреймворк должен предоставлять простые и понятные механизмы для выполнения рутинных операций: списания/начисления игровой валюты, выдачи игровых предметов, открытия лутбоксов, работы с платежами из сторов, с аналитикой и т.д.
- Все операции, совершаемые игроком, должны быть доступны из панели администрирования — это позволило бы команде техподдержки, тестировщикам и разработчикам лучше понимать, что происходило с игроком в случае поступления жалоб или обнаружения ошибок.
На момент начала разработки фреймворка на рынке уже было несколько предложений, частично удовлетворяющих нашим требованиям, — PlayFab и GameSparks. Это отличные решения, построенные по модели LiveOps, но имеющие ряд критических для нас недостатков:
- Код пишется на JavaScript, что не дает возможности полноценного переиспользования в Unity 3D, и в любом случае приходится преобразовывать ответы сервиса в модели клиента. Получается, что разработчик должен знать два языка программирования и выполнять дополнительную работу, которой хотелось бы избежать.
- Наша цель — разработка хита, а значит — речь идет о миллионах игроков в день. Стоимость работы этих сервисов на наших объемах становится значительно больше, чем стоимость самостоятельного владения подобным решением.
Итак, у нас получился следующий набор необходимых технологий и методов:
- C# (.net core для сервера и клиента, .net 3.5, 4.X для клиента). Мы хотим, чтобы разработчик мог разрабатывать как клиентскую, так и серверную часть задачи. Уйти от Unity 3D мы не можем, а вот написать сервер на C# — вполне.
- Orleans — фреймворк для построения распределенных, отказоустойчивых и масштабируемых систем в модели акторов от Microsoft (использовался в Halo). Использование этого фреймворка обусловлено тем, что с нашими задачами рано или поздно придется масштабироваться — к тому же, хочется, чтобы решение было отказоустойчивым.
- GRPC — для общения сервисов между собой, так как в системе, кроме сервиса игроков, построенного на Orleans, существуют и другие: авторизация, загрузка каталогов и прочее — в том числе и сервисы, которые ранее были написаны на Java и оказались по-настоящему автономны и независимы от того проекта, в котором используются.
- Postgres — для хранения данных игроков. Для масштабирования базы данных мы используем шардирование.
- Docker — с ним удобно разворачивать окружение локально и в тестовой среде. Таким образом, геймдизайнеры и разработчики могут работать с метой так, чтобы никому не мешать. Можно у себя ее локально поднять, проверить, что все работает, как нужно, и запушить уже измененный код в репозиторий.
- Prometheus — для мониторинга.
- Event Sourcing — парадигма, которую мы используем для хранения данных игроков. Она часто используется в банках. Подход здесь такой: когда мы работаем через Event Sourcing, все, что мы делаем, представлено в виде потока событий. Как упоминалось ранее, хотелось бы постоянно иметь историю, которая сохраняется в базе данных. Этот поток событий и есть наша история, по которой мы можем отслеживать, что происходило. Если что-то пошло не так, мы можем посмотреть интересующее нас событие в прошлом и полностью восстановить состояние игрока.
Кроме технической составляющей, есть еще логическая. Ни один проект, который мы делаем, не может существовать в вакууме: он должен постоянно конфигурироваться, настраиваться, и желательно, чтобы это проходило не в режиме, когда мы что-то уже сделали, скомпилировали и выпустили. Хотелось бы не обновлять клиент каждый раз, как мы выполняем перенастройку параметров бизнес-логики (цена предметов, скидки, запас здоровья у аватаров, урон от оружия и т.д.).
Обычно настройками параметров игры занимаются геймдизайнеры. Они уже давно работают в таблицах Excel или Google Sheets, поскольку в них удобно производить расчеты, задавать формулы, строить графики. Поэтому мы решили, что хорошей идеей будет эти же таблицы использовать не только для расчетов, но и для хранения параметров игры. Единственное, чего нам не хватало — формализации правил хранения данных, чтобы парсер знал, откуда эти данные брать. В итоге мы сделали несколько вариантов шаблонов вкладок под наши нужды:
- вкладки конфигурирования игровых предметов;
- вкладки настройки экспериментов (A/B тестов);
- вкладки настройки лутбоксов;
- вкладки настройки игровых валют;
- вкладки для хранения простых настроек, заданных в виде «ключ-значение».
Пример конфигурирования предмета
На примере выше показана вкладка, содержащая конфигурации предметов. Они имеют несколько стандартных колонок: ID, теги и цена. Эти значения обрабатываются особым образом. По ID и тегам, например, производится индексирование, так что по ним легко осуществлять поиск. Остальные колонки задаются свободно, и их может быть сколько угодно. Из кода такие данные получить очень просто:
// Находим в каталоге предмет с идентификатором 'Reaver_1':
ItemBlueprint itemBlueprint = catalog.GetItemBlueprint(ItemBlueprint.ValueOf("Reaver_1"));
// Получаем проверенное значение из колонки с названием 'grade'.
// Если поле отсутствует или имеет неверный тип, далее этот результат будет передан
// в админку, где будет выведена информация о месте нахождения ошибки:
Validated grade = itemBlueprint.ShouldHasIntAttr("grade");
Помимо каталогов, фреймворк предоставляет другие базовые примитивы, которые используются во всех наших играх:
- Профиль игрока. Он содержит имя и дату регистрации пользователя, а также позволяет хранить проектно специфичные данные.
- Кошелек. Благодаря нему начисляются и списываются валюты, выводится их баланс. Помимо прочего, реализация нашего кошелька позволяет отслеживать источники, из которых были получены валюты, и, как следствие, определять, на что игроки тратят реальные деньги, а на что — полученные в процессе игры. Это важно для понимания того, что представляет большую ценность для игрока.
- Инвентарь. Он позволяет управлять внутриигровыми предметами: добавлять их, удалять, осуществлять поиск.
- Предметы. Они делятся на два вида:
- Обладающие индивидуальностью и состоянием. Таким предметам можно задавать проектно специфичные параметры. Так, например, у брони может отслеживаться состояние ее износа.
- Не обладающие индивидуальностью. Обычно это потребляемые предметы. В таком случае хранится только счетчик их количества. Пример таких предметов — бутылки с целебным зельем.
- Лутбоксы. Сейчас без них немыслима ни одна free-to-play игра. Наше решение позволяет задавать различные варианты генерации контента для выдачи: от 100% гарантированного — в этом случае лутбоксы можно использовать как обычные контейнеры, — до полностью случайного.
Помимо этих базовых примитивов, на которых строится большая часть мета-геймплея в играх, фреймворк также предоставляет механизмы работы с A/B тестами, авторизацией, обработкой платежей сторов, выполнения отложенных задач, обезличивания данных и многие другие.
Приведем небольшой пример демо-команды. Хотя в ней и не происходит никакой сложной работы с бизнес-логикой, для наших демонстрационных нужд она подходит отлично:
// Описываем команду, представляющую из себя обычную DTO,
// которая может быть сериализована в Json:
public class DemoCommand : ICommand
{
public string BlueprintId;
public int Value;
}
// Описываем обработчик команды:
public class DemoHandler : HandlerBase, ICommandHandler
{
public void Handle(ICommandContext context, DemoCommand command)
{
// Inventory — объявлен в HandlerBase.
// Создаем новый предмет по образцу из каталога:
var demoItem = Inventory.GrantItem(ItemBlueprintId.ValueOf(command.BlueprintId));
// Задаем предмету значение атрибута 'demo_value':
demoItem.Attributes["demo_value"] = command.Value;
}
}
Заводим команду и ее обработчик. В реализации обработчика видно, что мы обращаемся к инвентарю и выдаем предмет игроку. После этого мы присваиваем предмету атрибут «demo_value» со значением, переданным в команде.
Ниже приведен пример того, как происходит выполнение команды и обработка ее результата на клиенте:
// Выполняем команду на сервере:
var command = new DemoCommand { BlueprintId = "Reaver_1", Value = 777 };
var commandResult = connection.Execute(command);
// Из ответа получаем обновленный инвентарь игрока:
var inventory = commandResult.Player.Inventory;
// Получаем последний созданный предмет:
var demoItem = inventory.FindItemsByBlueprint(ItemBlueprintId.ValueOf("Reaver_1")).Last();
// Выводим установленное значение:
Console.WriteLine(demoItem.Attributes["demo_value"]);
Так при чем здесь Event Sourcing?
Как видно из предыдущего примера, мы пишем код в классическом императивном стиле. Здесь нет работы с базой данных. Метод обработки команды не возвращает никаких результатов. Так как же это работает?
Никакой магии тут нет. На вызове метода инвентаря GarntItem и при операции присвоения фреймворк генерирует события, сохраняемые в контексте выполнения. После совершения операции эти события заносятся в базу данных. Они же уходят на клиент, где применяются к текущему состоянию игрока.
Ниже приведена упрощенная диаграмма того, что происходит при взаимодействии клиента и сервера:
В базе данных сохраняется каждая транзакция обработки команды со всеми событиями, которые произошли в этот момент. Эти транзакции используются как для дальнейшего восстановления профиля игрока со всеми его данными, так и для отображения в панели администрирования.
Ниже приведен пример отображения транзакций игрока из реального проекта. Здесь мы видим две прошедших транзакции. Одна из них — создание нового игрока, вторая — принятие пользовательского соглашения с определенной версией.
Пример лога транзакций из реального проекта
В настоящее время фреймворк используется в трех проектах студии. Один из них уже около полугода находится в продакшене. В разработке этого проекта команда серверных разработчиков принимала непосредственное участие, чтобы понять, насколько удобно пользоваться нашим решением. Почему? Потому что мета была еще достаточно молодой. И на проекте мы приводили ее в порядок: упрощали API, что-то выкидывали за ненадобностью, где-то, напротив, добавляли разнообразия в наиболее часто повторяющихся паттернах.
В двух других играх, как и задумывалось, всю разработку осуществляют уже Unity-разработчики без нашего вмешательства. Это позволяет компании развиваться и разрабатывать новые проекты без расширения штата.
Несмотря на то, что в проекте уже многое сделано, он продолжает активно развиваться: проводятся оптимизации, добавляются новые функции. Мы уверены, что фреймворк ждет большое будущее в наших текущих и новых проектах. А может, и не только в наших, и эта идея окажется полезна и читателям тоже.