В индустрии мобильных игр на один проект часто выделяют несколько бэкенд‑разработчиков. Например, в студиях над PvP‑шутером с мета-игрой работают 5–8 серверных специалистов — и это считается нормой.
Однако у нас в компании (около 40 сотрудников, 3 проекта одновременно в разработке) с задачей отлично справляется один бэкенд-программист.
Наши игры сложно взломать: любые действия игрока обязательно проходят серверную верификацию, что практически исключает читерство. Такой подход мы используем уже много лет, опробовали его на десятках проектов, и я не знаю ни одного случая, чтобы кто-то, начав так работать, потом возвращался к традиционным архитектурам.
Эта статья — о том, как реализовать гибридную архитектуру клиент-серверного взаимодействия, которая позволяет достигать высокого уровня защиты с минимальными ресурсами. Это решение выгодно всем:
разработчикам проще писать и тестировать код
бизнес получает экономию на бэкенд части
игроки могут быть уверены в честности матчей и отсутствии читеров
? Всем станет лучше, если разработчики игр будут заниматься непосредственно геймплеем, а не писать бэкенд!
При проектировании архитектуры мобильной игры важно понимать, что существует два типа клиент-серверного взаимодействия, которые требуют разных подходов и решений:
Взаимодействие реального времени (например бой в 3D шутере)
Пошаговый режим с хранением состояния (часто это мета игра - профиль игрока, его предметы, игровые валюты и тому подобное)
Давайте рассмотрим, как устроено каждое из этих направлений.
Взаимодействие реального времени
Для взаимодействия реального времени есть много решений, из наиболее ходовых для юнити проектов можно назвать:
Fusion от www.photonengine.com, ранее их же решения PUN и Bolt
Netcode for Game Objects от самих Unity Tec
Mirror основанный на UNet, предыдущем решении от Unity Tec для сети
Это лишь не большая часть возможных решений, подходы там варьируются от полного доверия клиентам и peer-to-peer, до классических авторитарных серверов с client-side prediction, lag compensation и прочими решениями.
Останавливаться на взаимодействии реального времени мы не будем т.к. это хорошо изученная область со множеством готовых решений, обычно нужно просто выбрать, что лучше подходит под требования вашей игры и команды.
Пошаговый режим с хранением состояния
Довольно неуклюжее название, тем не менее это встречается в подавляющем большинстве современных игр. Например, в начале игры вам предлагают создать своего персонажа. Часто у персонажа постепенно появляются какие-то дополнительные предметы кастомизации или оружия. Тут же речь идёт и о разных игровых валютах, сундуках и прочих техниках часто используемых во Free-to-Play играх.
Тут широко распространены 4 варианта варианта:
Авторитарный сервер + тонкий клиент
Минимальный сервер для сохранения состояния + толстый клиент
Гибридный подход
PlayFab, UnityGameServices
Давайте кратко рассмотрим их.
Авторитарный сервер + тонкий клиент
? Авторитарный сервер:
Вся игровая логика и проверка действий выполняется на сервере. Клиент — только интерфейс и “пульт управления”.
Максимальная защита от читерства, но все портит низкая отзывчивость и возможные задержки для игрока.
Это подразумевает всю логику обработки действий игрока и хранение состояния игрока на сервере. Клиент служит для отображения состояния и для отправки команд на сервер.
Отличное решение, но оно имеет особенности, как технические, так и зачастую организационные:
Технически такая реализация подразумевает что у пользователя отличное интернет соединение, в случае мобильных игр с этим часто бывают проблемы.
Даже в случае нормального соединения, время отклика системы часто бывает заметным, ты нажимаешь на кнопку купить предмет, и он не мгновенно у тебя появляется - а только после обработки сервером. Либо можно показать его сразу же, но это вызвает усложнение логики клиента.
Распределение логики между клиентом и сервером означет что даже небольшие задачи часто будут делаться двумя разработчиками, клиентским и серверным. Взаимодействие между разработчиками обычно является бутылочным горлышком
Сервер пишется под конкретную игру, мало что из него удаётся переиспользовать в последующих проектах
Бываeт сервер пишут на другом технологическом стеке (по разным причинам, иногда это навыки и опыт бэкенд программистов, иногда наследие предыдущих проектов, иногда особенности субъективного восприятия бэкенд разработчиков). Другой технологический стек добавляет проблем на стыке клиента и сервера, незначительные отличия в технологиях всплывают в неожиданных местах.
Несмотря на перечисленные минусы, это все равно неплохо подходит для ПК игр и консольных игр, тысячи проектов использующих этот подход хорошо это подтверждают.
Минимальный сервер для сохранения состояния + толстый клиент
? Толстый клиент, минимальный сервер:
Вся логика игры живёт на клиенте, сервер только сохраняет присланные данные.
Очень просто и дешёво, но любые данные можно легко подделать — защита минимальная.
В этом случае у нас почти вся логика расположена на клиенте, сервер лишь сохраняет состояние игрока, пришедшее с клиента. Сервер доверяет тому, что приходит с клиента.
Если игра без Inapp платежей и без онлайн взаимодействия между игроками, то это вполне рабочий подход.
Огромный плюс тут - это простой сервер, который к тому же можно использовать с минимальными модификациями в разных играх.
Минус - легко манипулировать данными игрока (т.е. использовать читы), т.к. сервер их не проверяет, а просто сохраняет в базе данных “как есть” или извлекает их оттуда.
Гибридный подход
? Гибридный подход:
Быстрая реакция за счёт выполнения логики и на клиенте, и на сервере.
Критические проверки дублируются, что снижает риск читерства, но усложняет разработку и отладку. Самый дорогой и самый распространённый вариант.
Часто задержки, неизбежные в тонком клиенте являются неприемлемыми. Например, мы переставляем строения у себя на базе. Нам нужно проверять, не пересекается ли здание с другими зданиями? Не выходит ли оно за пределы зоны доступной игроку? Находится ли здание в состоянии, позволяющим его переставить? В этому случае часто все эти проверки приходится делать и на клиенте для его “отзывчивости”. А потом их же повторно придётся делать и на сервере. При этом на сервере может отличаться организация данных, он может быть написан на другом языке программирования, делаться другим разработчиком.
Какие тут особенности:
Хорошая отзывчивость, обычно игрок сразу же видит результаты своих действий
Логика частично дублируется между клиентом и сервером, сервер невозможно переиспользовать для другой игры.
Из-за дублирования логики могут возникать проблемы отладки, когда из-за мелких ньюансов какое-то действие на клиенте считается допустимым, а на сервере нет.
Возникают проблемы отката некорректных с точки зрения сервера действий клиента - перезагружать игру полностью проще для разработчиков но хуже для пользователя, откатывать отдельные действия сложнее для разработчиков но лучше для пользователя.
Несмотря на то, что описанный гибридный подход наиболее трудоёмкий, по факту он используется чаще всего, будучи в отдельных случаях либо ближе к тонкому клиенту либо ближе к минимальному серверу с толстым клиентом. В случае толстого клиента на сервере обычно все же пытаются добавлять проверки на совсем наглые взломы игры. В тонком клиенте часть логики дублируют в клиенте для снижения задержек в интерфейсе.
Такие решения как PlayFab или UnityGameServices Economy я бы отнёс так же к гибридным решениям.
Общий код между клиентом и сервером.
? Shared Logic:
Один и тот же код обработки игровых команд запускается и на клиенте, и на сервере.
Это обеспечивает мгновенную реакцию для игрока и одновременно защищает от читерства: сервер повторно выполняет команду и сверяет итоговое состояние с клиентом.
Есть подход, который позволяет совместить удобство разработки и высокий уровень защиты — архитектура Shared Logic. Здесь и клиент, и сервер используют общий набор команд и логику их обработки, реализованных по паттерну проектирования «Команда».
Всякий раз, когда игрок совершает действие, соответствующая команда запускается на обеих сторонах и одинаково изменяет состояние профиля игрока. В финале сервер сравнивает хэш состояния профиля: если он совпадает с клиентским, значит всё корректно. Если нет — это свидетельство вмешательства в данные или логику, и такой случай трактуется как попытка читерства.

Перед тем как погружаться в детали, посмотрим, как же это выглядит при использовании в конкретной игре.
Рассмотрение концепции на примере игры Connect Four.
Для каждой игры мы пишем свой собственный код, реализующий правила и логику этой игры.
В минимальном виде нам необходимо:
описать профиль пользователя определив в нём поля, необходимые для вашей игры.
создать команды, которые будут описывать возможные действия игрока.
написать обработчики этих команд, которые описывают, какие изменения должны произойти с профилем пользователя в результате выполнения этих команд.
В целях иллюстрации тут показана реализация известной игры Connect Four. В ней игроки ходят по очереди, выбирая в какую колонку они хотят поместить диск. Побеждает игрок, первый разместивший 4 диска в ряд.

Для упрощения сделаем, что мы играем только против компьютера, т.е. после хода игрока всегда тут же выполняется ход компьютера.
Таким образом нам достаточно двух команд:
1. Бросить диск в колонку Х
2. Начать новую игру
Отправка команд
В простейшем варианте на клиенте написанном на юнити вызов этот команд может выглядеть вот так:
if (_profile.Result == GameResult.InProgress)
{
// Показать 7 кнопок сброса, по одной на каждую колонку
for (int col = 0; col < 7; col++)
{
if (GUILayout.Button("↓", GUILayout.Width(30)))
{
// Если нажата кнопка '↓', выполнить команду, проверить хэш, отправить на сервер и проверить там
ExecuteCommand(new DropDiscCommand { Column = col });
}
}
}
else
{
if (GUILayout.Button("New Game"))
{
// Если нажата кнопка "New Game", начать новую игру
ExecuteCommand(new NewGameCommand());
}
}
Вызов ExecuteCommand(...) не представляет из себя никакой сложности, несмотря на то, что он означает целую последовательность действий, включающую в себя помимо локального выполнения серверную верификацию.
Отображение состояния
Предположим профиль пользователя (или состояние игры) лежит в переменной _profileи определено вот так:
public enum CellState { Empty, Player, Computer }
public class ConnectFourProfile : UserProfile
{
// В игре Connect Four обычно используется поле размером 6x7.
// В профиле хранится всё состояние игрового поля.
public CellState[,] Board = new CellState[6, 7];
//…
}
Тогда вот так можно в клиенте показывать доску:
//отображение игровой доски
for (int row = 0; row < 6; row++)
{
GUILayout.BeginHorizontal();
for (int col = 0; col < 7; col++)
{
var cell = _profile.Board[row, col];
string label = cell switch
{
CellState.Player => "?",
CellState.Computer => "?",
_ => "⚪"
};
GUILayout.Label(label, GUILayout.Width(30));
}
GUILayout.EndHorizontal();
}
Здесь для иллюстрации используется визуализация интерфейса в Unity через метод OnGUI которая обычно не используется в реальных проектах, но хорошо подходит в целях иллюстрации в статье из-за своей простоты.
Реализация обработки команд
Команда подразумевает изменение профиля игрока.
В простейшем случае она может занимать одну-две строки, например, если мы хотим сбросить состояние, как в случае выполнения команды NewGameCommand.
В целом мы просто указываем, какой тип команды какой код должен вызывать; В этом коде мы можем изменять профиль игрока, при этом он изменится как на клиенте, так и на сервере, так и в базе данных.
Какие-то команды, как например DropDiscCommand выглядят более многословными, но это обычный банальный C# код в котором нет ничего необычного.
public ConnectFourCommandProcessor()
{
// Команда начала новой игры очищает поле и сбрасывает статус игры.
// Статистика побед и поражений остаётся неизменной.
RegisterHandler<ConnectFourProfile, NewGameCommand>((userProfile, cmd) =>
{
userProfile.Board = new CellState[6, 7];
userProfile.Result = GameResult.InProgress;
});
// Это обработчик основной команды — сброса диска.
// В этом примере игра всегда идёт против компьютера.
RegisterHandler<ConnectFourProfile, DropDiscCommand>((userProfile, cmd) =>
{
if (userProfile.Result != GameResult.InProgress) return; // Игнорировать команду, если игра закончена
// Ход игрока
var row = DropDisc(userProfile.Board, cmd.Column, CellState.Player);
if (row == -1)
throw new Exception("Column full");
// Победил ли игрок?
if (CheckVictory(userProfile.Board, row, cmd.Column, CellState.Player))
{
userProfile.Result = GameResult.Win;
userProfile.VictoryCount++;
OnVictory?.Invoke();
return;
}
// Ход компьютера (rule-based AI)
var rand = new DeterministicRandom(userProfile.Seed++); // Детерминированный генератор случайных чисел
int baseCol = rand.Next(0, 7); // Начальная точка для выбора fallback-колонки
int aiCol = ChooseBestMove(userProfile.Board, baseCol);
int aiRow = DropDisc(userProfile.Board, aiCol, CellState.Computer);
// Победил ли компьютер?
if (CheckVictory(userProfile.Board, aiRow, aiCol, CellState.Computer))
{
userProfile.Result = GameResult.Defeat;
userProfile.DefeatCount++;
OnDefeat?.Invoke();
}
// Проверка на ничью
if (userProfile.Result == GameResult.InProgress && IsBoardFull(userProfile.Board))
{
userProfile.Result = GameResult.Draw;
OnDraw?.Invoke();
}
});
}
Из примечательных моментов здесь только что использование вспомогательного класса DeterministicRandom т.к.нам нужно чтобы генератор случайных чисел на одном и том же профиле пользователя с одной и той же командой дал один и тот же результат. Этого нельзя гарантировать с System.Random, даже при синхронизированном seed он может давать разные результаты на разных платформах.
Код выше на самом деле примерно так же выглядел бы, если бы мы даже и не пользовались подходом c Shared Logic.
В нём нет ничего революционного, он не требует думать о многопоточности, реактивном программировании, ECS, асинхронном выполнении и т.п.
Вы можете просто взять даже программиста с небольшим опытом, и он сможет писать этот код. Один, вместо набора из двух программистов, клиентского и серверного.
Основные модули и их роли
Давайте теперь перейдём от рассмотрения примера использования системы к деталям её реализации.
Некоторым читателям может быть удобнее сначала ознакомиться с исходным кодом, прежде чем переходить к подробному объяснению ниже. Посмотреть полный исходный код на GitHub
Наша система состоит из нескольких отдельных модулей, каждый из которых представляет собой самостоятельный проект в составе .NET-решения и выполняет чётко определённую роль:
Shared Logic Core — содержит базовые интерфейсы, структуру профиля пользователя и общую логику выполнения команд. Этот код переиспользуется как на клиенте, так и на сервере.
Shared Logic Server — веб-сервис на .NET 8, обрабатывающий команды, присланные клиентом: он повторно выполняет их на сервере, рассчитывает хэш и сравнивает его с клиентским значением для верификации.
Specific Game Plugin — реализует конкретные игровые команды и их обработчики, а так же содержит описания профиля пользователя для конкретной игры. Этот код так же переиспользуется как на клиенте, так и на сервере.
Unity Client — пример реализации клиента на Unity, выполняющий команды локально, мгновенно обновляющий состояние профиля и отправляющий запросы на сервер для подтверждения.
Shared Logic Core подключается к Shared Logic Server как проект-зависимость, к Unity клиенту он подключается через юнитевский Package Manager.
Specific Game Plugin загружается Backend Service ом во время запуска, это позволяет использовать один и тот же Backend Service для разных игр без пересборки. К юнити клиенту Specific Game Plugin подключается через юнитевский Package Manager.
Specific Game Plugin в примере содержит код для реализации известной игры Connect Four.
Подробности реализации
Shared Logic Core
Ядро системы находится здесь. Предполагается что его не нужно менять под конкретную игру, это просто код переиспользуемый из проекта в проект.
В иллюстративных целях весь код данного модуля размещён в одном файле SharedLogic.cs. Учитывая что кода совсем немного, так легче ориентироваться.

Основные классы:
UserProfile
Сериализуемое представление состояние игрока, например инвентарь, прогресс, валюты и тп.
Обычно наследуется с целью добавления полей, необходимых для конкретной игры.
ComputeHash используется для вычисления хэша состояния, который используется при сравнении результата выполнения команды на клиенте и на сервер.
GameCommandProcessor
Связывает типы команд с делегатами их обработки.
Делегаты обработки принимают профиль игрока и команду. По ходу выполнения команды они обычно меняют профиль игрока.
Зарегистрировать делегат обработчик можно через RegisterHandler
ExecuteCommand принимает аргументом изменяемый профиль пользователя и команду, которую к нему нужно применить. Этот метод вызвается как на клиенте, так и на сервере.
Важно что GameCommandProcessor не должен иметь в себе состояние - состояние должно быть всё внутри UserProfile. На сервере существует всего один экземпляр GameCommandProcessor который обслуживает запросы всех клиентов.
ICommandData
Маркерный интерфейс для всех классов с данными команд.
Shared Logic Server
Удобно начать рассмотрение с файла Program.cs:
Он загружает файл SpecificGamePlugin.dll и создаёт экземпляр основного класса игровой логики, который должен наследоваться от GameCommandProcessor.
Помимо этого выполняются обычные для WebAPI сервисов вещи, настройка используемой сериализации, настройка драйвера MongoDB.
Для сериализации использован Newtonsoft.JSON: это не быстро, но наглядно и работает с AOT что важно для юнити.
Для базы данных используется MongoDB это опять же наглядный вариант, не требующий настройки после установки. А с помощью Compass удобно посмотреть получившиеся в БД записи в читаемом виде.
Ключевой класс - это:
CommandController
[ApiController]
[Route("api/[controller]")]
public class CommandController : ControllerBase
{
public async Task<IActionResult> LoadProfile(Guid userId);
public async Task<IActionResult> Execute([FromHeader(Name = "X-User-Id")] Guid userId, [FromBody] ExecuteCommandRequestDTO request);
}
При вызове с клиента LoadProfile он просто забирает через UserProfileRepository профиль пользователя из БД и отдаёт его, это нужно единожды во время запуска клиента игры.
В случае вызова выполнения команды (Execute) он так же извлекает с помощью UserProfileRepository профиль пользователя из БД, передаёт профиль с командой на выполнение в GameCommandProcessor, далее сверяет хэш изменённого профиля пользователя с хэшем пришедшим от клиента и сообщает клиенту, всё ли сошлось. Если всё совпало - клиент получает уведомление об успешном выполнении команды, либо он получает ошибку.
Specific Game Plugin
Для каждой игры мы пишем свой собственный код, реализующий правила и логику это игры.
Этот код для простоты собран в данном случае в один файл SpecificGamePlugin.cs, но в реальном проекте это обычно набор файлов и классов. Этот код подключается как к юнити клиенту (как исходный код через manifest.json / package.json) так и к серверу в виде SpecificGamePlugin.dll выложенного в SharedLogicServer/Plugins/SpecificGamePlugin.dll

ConnectFourProfile
public class ConnectFourProfile : UserProfile
{
public CellState[,] Board = new CellState[6, 7];
public GameResult Result = GameResult.InProgress;
public int Seed;
public int VictoryCount = 0;
public int DefeatCount = 0;
}
Добавляет поля, специфические для игры ConnectFour в базовый профиль пользователя. Board имеет размерность 6 рядов и 7 столбцов.
Seed нам нужен т.к. при выполнении команд нам нужен один и тот же результат на клиенте и сервере, поэтому генератор случайных чисел должен быть опираться на один и тот же seed.
DropDiscCommand
public class DropDiscCommand : ICommandData
{
public int Column { get; set; }
}
Это основная команда игры, на каждом шаге игрок может выбрать, в какую именно колонку он хочет забросить свой диск.
NewGameCommand
public class NewGameCommand : ICommandData { }
Команда начала новой игры. Иногда команды даже не содержат никаких параметров.
ConnectFourCommandProcessor
public class ConnectFourCommandProcessor : GameCommandProcessor
{
public ConnectFourCommandProcessor()
{
// Команда начала новой игры очищает поле и сбрасывает статус игры.
// Статистика побед и поражений остаётся неизменной.
RegisterHandler<ConnectFourProfile, NewGameCommand>((p, cmd) =>
{
//реализация команды новой игры
});
RegisterHandler<ConnectFourProfile, DropDiscCommand>((p, cmd) =>
{
//реализация команды сброса диска
});
}
public override UserProfile CreateDefaultProfile(Guid userId);
//дополнительные вспомогательные методы
}
Это класс который связывает всё воедино, в нём зарегистрированы обработчики команд DropDiscCommand и NewGameCommand. В нём описано, как создать профиль нового пользователя (CreateDefaultProfile). При обработке команд он использует вспомогательные методы, относящиеся к логике конкретной игры, размещённые в нём же.
DeterministicRandom
Простой детерминированный генератор случайных чисел. Как отмечалось выше, он гарантирует, что для одного и того же профиля пользователя и команды на клиенте и сервере будет получаться одна и та же последовательность случайных чисел.
Диаграмма последовательности выполнения команды
Пример последовательности выполнения команды DropDisc:

Подводные камни, ограничения и особенности отладки
Конечно есть и ограничения такого подхода.
Вам нужно, чтобы состояния совпадали бит-в-бит.
Иначе не сойдутся хэши.
Что может вызвать проблемы?
Арифметические вычисления
Использование float часто приводит к разным результатам на разных платформах. Поэтому используйте decimal или целочисленную арифметику.
Сериализация
Использование при сохранении типов данных с негарантированным порядком, такого как например Dictionary<TKey, TValue> или HashSet<T> при сериализации может давать разный порядок, что приведёт к разному хэшу т.к. сравниваем мы сериализованные данные для простоты. Такой проблемы нет с List или обычными массивом. Так же можно использовать SortedDictionary.
Вы можете так же по своему переопределить реализацию UserProfile.ComputeHash() чтобы он не полагался на сериализацию, но это потребует дополнительных усилий и кода.
Проблемы сериализации будут зависеть от конкретного решения, используемого для сериализации, совсем необязательно использовать для этого JSON. Однако, если вычисление хэш кода основано на сериализованном массиве байт, нужно держать в голове эти особенности.
Добавление состояния вне UserProfile
Всё состояние игрока должно полностью храниться только в полях вашего класса, который наследуется от UserProfile.
Если вы добавите поле, например, непосредственно в GameCommandProcessor для хранения каких-то данных, связанных с состоянием игрока, сервер об этом не узнает. Более того, по замыслу архитектуры на сервере существует только один экземпляр GameCommandProcessor — этот класс должен быть без состояния (stateless).
Аналогично, могут возникнуть проблемы, если вы введёте статическую переменную и будете обращаться к ней в коде обработки команд. На сервере такая переменная не обязательно будет иметь то же значение, что и на клиенте.
Однако вы можете свободно использовать локальные переменные внутри своих методов — их “жизнь” ограничена временем выполнения команды.
К этому ограничению быстро привыкаешь, но компилятор тут не поможет — ошибки проявятся только во время выполнения и могут быть неочевидны.
Отсутствие привычного Unity API
В коде SpecificGamePlugin отсутствует зависимость UnityEngine.dll т.к. Код оттуда должен выполняться не только в юнити, но и на сервере. Некоторые типы оттуда часто хочется иметь и в SpecificGamePlugin, но обычно приходится заводить их аналоги. Например, UnityEngine.Random или UnityEngine.Mathf или UnityEngine.Vector3. Но если мы говорим действительно об игровых правилах, которые и должны определяться в SpecificGamePlugin, то таких классов совсем немного и их замена не представляет большой проблемы.
Отладка
Для отладки этот подход очень удобен.
Обычно ведь часто бывает, что отладка игры совместно с бэкендом не простая: нужно установить много зависимостей на ваш компьютер, собрать сервер, собрать игру, запустить и то и другое, подключить к обоим дебаггер, настроить таймауты сетевых подключений чтобы они не мешали отладке и т.п.
Тут же мы обычно может в целях отладки вообще отключить отправку команд на сервер. Да, наше состояние не будет сохранено на сервере, но зато вы сможете отлаживать все команды обычным дебаггером потому что все они будут выполнятся локально.
? Отладка без сервера
вы можете реализовать отладочный режим только для игры в редакторе, который не требует отправки данных на сервер и вместо этого записывает UserProfile на диск и оттуда же его считавает при запуске игры.
Использование таких инструментов как MongoDB Compass позволяет посмотреть в достаточно удобном виде профили пользователей и даже отредактировать их:

Тестирование производительности
Всё это замечательно, но что по скорости?
Основная нагрузка тут как правило от сериализации:
Нужно сериализовать саму команду для отправки её на сервер.
Затем нужно сериализовать UserProfile, чтобы посчитать его хэш.
Затем нужно десериализовать команду на сервере.
Затем нужно десериализовать из BSON UserProfile который вернёт MongoDB
Затем нужно снова сериализовать UserProfile в BSON для того, чтобы положить его обратно в MongoDB
Затем нужно снова сериализовать UserProfile, чтобы посчитать его хэш уже на сервере.
Мы можем избавится от пунктов 2 и 6, переопределяя методы подсчета хэш кода.
Можем почти избавится от 4 и 5 если будем кэшировать в памяти UserProfile так, чтобы не нужно было каждый раз обращаться к базе данных, к тому же в памяти его можно хранить в десериализованном виде.
? Кэширование обращений к БД
Кэширование обращений не только снижает нагрузку на БД, но так же намного уменьшает количество тяжёлых операций по сериализации-десериализации данных
Но давайте просто посмотрим на то, что написано максимально просто?
Для этого я написал Benchmark который создаёт пользователя, выполняет команды сделать ход 10 раз в разные столбцы, затем выполняет команду “начать новую игру” и делает ещё 10 ходов. Вы можете увидеть его исходный код в Benchmark/Program.cs
Вот результаты его выполнения на AMD Ryzen 9 5900X (12 cores)
Вот результаты его выполнения на AMD Ryzen 9 5900X (12 cores)
E:\Projects\SharedLogic.github\sharedlogic\Benchmark\bin\Release\net8.0>Benchmark.exe
Benchmark completed.
Total requests: 70400
Elapsed time: 3.97 seconds
Requests per second: 17712.19
Для сравнения, результаты выполнения на 7-летнем мобильном Core-i5-8300H (4 cores):
C:\Projects\SharedLogic\Benchmark\bin\Release\net8.0>Benchmark.exe
Benchmark completed.
Total requests: 70400
Elapsed time: 21,19 seconds
Requests per second: 3322,58
Видно что производительность отлично масштабируется с ростом производительности.
Что такое 17 тысяч запросов, много это или мало? Если мы используем эту систему по назначению, то обычно частота запросов от пользователя не так велика. Это какие-то операции в инвентаре, снаряжение, открытие сундуков рулетки и тому подобные действия, в реальности это в среднем 1 запрос в 10 секунд. Таким образом даже простейшая реализация на небольшом профиле и на обыкновенном настольном компьютере может быть достаточной для порядка 80-170 тысяч одновременных пользователей, что точно хватит на ваш проект.
Ради интереса попробовал добавить простейшее кэширование профилей в памяти, чтобы не обращаться слишком часто к БД. Результаты тестов на AMD Ryzen 9:
E:\Projects\SharedLogic.github\sharedlogic\Benchmark\bin\Release\net8.0>Benchmark.exe
Benchmark completed.
Total requests: 70400
Elapsed time: 0.96 seconds
Requests per second: 73262.07
Это уже означает сотни тысяч CCU, по-моему это отличный результат!
? Проксирование
Для production-релизов .NET сервер лучше запускать за обратным прокси — например, Nginx. Это повышает безопасность, позволяет включить HTTPS, улучшает производительность, ограничивает количество подключений и делает приложение устойчивее к сбоям. .net 8 (Kestrel) не предназначен для прямой работы с интернет-трафиком.
Выводы
Опыт нашей команды показывает: внедрение гибридной архитектуры Shared Logic для мобильных игр даёт реальное преимущество — как в безопасности, так и в скорости разработки. Один бэкенд‑разработчик способен сопровождать сразу несколько игровых проектов без потери качества, что раньше казалось невозможным.
? Прошло проверку в продакшене:
В нашей компании по этой архитектуре уже успешно выпущено более десятка проектов — и она доказала свою эффективность на практике.
Такой подход освобождает команду от рутины, даёт бизнесу экономию ресурсов, а игрокам — мгновенный отклик и защиту от читерства. Сервер подтверждает каждое действие игрока, сверяя состояние, и любые попытки обхода легко обнаруживаются.
Ключевые итоги:
Вместо большой бэкенд команды достаточно одного опытного инженера, а освободившиеся ресурсы идут на развитие новых функций и самой игры.
Логику для каждой игры легко масштабировать и расширять — всё сосредоточено в отдельном плагине.
Игроки получают честные матчи и предсказуемый игровой опыт.
Отладка и тестирование упрощаются: все команды можно выполнять и проверять локально.
Производительность решения подтверждена тестами — даже на обычном железе система выдерживает тысячи запросов в секунду, а при кэшировании — десятки тысяч.
Shared Logic — это не просто очередной паттерн, а проверенный инструмент, особенно для мобильных и midcore-проектов с развитой мета-игрой.
Полный исходный код выложен на гитхабе
Спасибо за чтение, эта статья требует усилий для понимания. Буду рад обсудить детали, услышать ваши идеи и опыт в комментариях.
Комментарии (6)
SadOcean
13.06.2025 19:04Мы используем похожий подход в нескольких наших проектах.
Если стоит такая цель проекта, архитектура неплохая, но нужно отметить несколько аспектов:
- Разрабатываешь де факто оффлайн игру, а платишь за нее как за онлайн игру. Код все же несколько сложнее, чем, к примеру, обычная игра с облачными сохранениями. Аналогично платишь за сервера - им нужно выполнять весь код логики.
- Исходящие из этого проблемы - зависимость от интернета, проблемы с соединением. Толстый слой обвязок на каждой команде может быть тяжеленьким (да, да, сериализация-десериализация).
- Полный cheat proof - это преувеличение. Клиент все еще в руках врага. Многие операции (покупки, реклама, любые локальные действия, если они чисто клиентские, что практично) не безопасны. По сути пользователь не может сказать "сохрани мне 1000 деняк", но может прислать "выполни ка команду квест завершен 10000 раз." Конечно команда может быть защищена и проверять состояние игрока, но все дыры не перекроешь, все эдж кейсы не предусмотришь, даже двойные верификации могут оставлять лазейки.
- Никак не поможет если вам нужен реальный мультиплеер. Система синхронна только пока работает связка клиент-сервер. Клиент ведущий, но сервер все валидирует, система надежная, но к ней не подмешать других игроков. Соответственно если нужен мультиплеер - по сути ты делаешь отдельный классический мультиплеер с колбеками и задержками (и их коррекцией, предсказаниями и хаками). А потом, по результатам сессии, говоришь серверу "Я реально победил в этом матче, зуб даю" (поверь мне на слово). Конечно можно использовать всякие валидационные коды и криптозащиту, но все же.
Для многих проектов эта архитектура может быть оверинженерингом и неприятно удивить ваше начальство счетами за сервера. Знайте, с чем работаете.
В остальном модненькая такая.
Так же рекомендую тщательно продумать над хорошим API команд, чтонибудь в духе
logic.Do(new ApplySomeCommand(a, b, c)) и системой событий.
И писать поменьше бойлерплейта. За все архитектуры надо платить, мы натурально пишем по несколько классов на каждый чих (архитектура не моя, поэтому повлиять на API Я не мог)
Существует похожая архитектура синхронизированных детерменированных логических контейнеров. Обычно используется в стратегиях. У нее похожий принцип, но логика другая - клиенты независимо применяют компактные команды и апдейты на систему (карта, юниты и прочее). За счет того, что передаются только команды (действия игрока), такие системы могут пережевывать тысячи юнитов - клиенты отправляют друг другу только команды в духе SelectUnitsInAres(FromXY, ToXY) и SendSelectedUnits(ToXY), а юниты по факту обсчитываются локально - каждый клиент выделяет все 10000 юнитов в области и отправляет их в точку, а потом просто сравнивают хеши состояний - нет ли рассинхрона.
iamkisly
Статья большая и классная, но мне кажется, то что предлагает ТС это очевиднейшая вещь, тему которой не поднимали потому что она очевидная. Мы переиспользуем логику с незапамятных времен, в чем новость то?
NikolayLezhne Автор
Спасибо за ваш комментарий!
Я уверен, что не только мы используем этот подход, но здесь речь не просто о выделении кода в общую библиотеку, которую подключают и на клиенте, и на сервере.
Это скорее про целостную архитектуру с детерминированным поведением, проверкой хэша состояния и полной синхронизацией команд между клиентом и сервером.
Очевидно, что статья — это по сути описание одного из шаблонов проектирования, но найти подробную информацию по именно такому подходу мне было сложно.
Ближайшее, что я нашёл — статья про сетевую синхронизацию в Age of Empires, где тоже используется детерминированность и проверка через хэш состояния: https://www.gamedeveloper.com/programming/1500-archers-on-a-28-8-network-programming-in-age-of-empires-and-beyond
Также похожие описания есть здесь (хоть и про язык Haxe): https://community.haxe.org/t/sharing-logic-between-client-and-server/923/7, но там концепция только вскользь упоминается.
Думаю, одной из причин является отсутствие устоявшегося общепринятого названия для этого подхода.
Насколько я понял, вы тоже используете похожую архитектуру? Могли бы поделиться опытом? Какой технологический стек у вас? Unity и .NET backend?
Были ли у вас какие-то вызовы или проблемы, которые я не затронул в статье?
korchoon
Если вещь "очевиднейшая", почему схожее решение успешно продается той же Metaplay? Причем некоторые студии покупали их решение даже по модели revenue share.
Вы точно читали статью?
NikolayLezhne Автор
Почитал про Metaplay — это очень близко к тому, что описано в статье.
https://docs.metaplay.io/introduction/introduction-to-metaplay.html#code-sharing-between-client-and-server
Думаю, для многих команд это может быть хорошим решением, но я стараюсь минимизировать использование облачных сервисов — если такой сервис закроется, можно оказаться в сложной ситуации.
А так спасибо за наводку! Думаю, будет здорово собрать в комментариях примеры использования подхода, описанного в статье.
korchoon
Это был ответ, что решение не "очевиднейшее". К тому, что компании готовы делиться прибылью из-за того, что не могут/не знают как сделать это самим.
Mеtaplаy - убогое, неповоротливое решение (работаю в компании, где один из проектов на нем). И подсев на него в живом проекте, с него сложно уйти из-за оверинжиниринга и нестандартных решений внутри.
И для студии лучше вложиться в свой сервер. Эта статья - отличное начало для инхаус решения