Привет, Хабр.

Я frontend-разработчик и большую часть времени работаю с Vue и TypeScript. В последнее время мне стало интересно глубже разобраться с backend-разработкой, но делать для этого отдельный учебный CRUD-проект не хотелось. Я уже сделал свою первую игру и написал о ней в этой статье. Новая игра задумывалась уже сложнее по механикам и без серверной работы в ней было не обойтись. Таким образом серверную часть я решил добавить в свою новую игру — 3D-игру три в ряд с персонажами, способностями, прокачкой и PvP.

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

  • как описывать способности данными, а не функциями в клиенте;

  • как проверять полученный с сервера игровой контент;

  • как версионировать персонажей и баланс;

  • как не отдавать игрокам наполовину обновлённые данные;

  • как хранить прогресс игрока;

  • как безопасно списывать ресурсы;

  • что делать с повторными запросами;

  • как обрабатывать несколько одновременных запросов;

  • как управлять всем этим через админку.

В итоге backend перестал быть просто API, которое возвращает JSON с героями. Ниже расскажу о том, как сейчас устроена серверная часть проекта и с какими проблемами я столкнулся во время её разработки.

С чего всё началось

Игра представляет собой 3D-вариацию механики «три в ряд». Игровое поле состоит из кубов пяти элементов:

  • огонь;

  • лёд;

  • земля;

  • свет;

  • тьма.

Игрок собирает комбинации и получает за них боевые ресурсы. Эти ресурсы используются в PvP и взаимодействуют со способностями персонажа. У каждого персонажа есть характеристики, уровень, ранг пробуждения, активная способность, пассивная способность и Ultimate.

При этом способность может одновременно влиять и на поле игры три в ряд, и на боевую часть. Например:

Превратить три куба в огненные
+
увеличить огненный урон на 25% до конца раунда

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

Если после релиза я решу изменить стоимость способности с 40 до 35, не хочется ради одной цифры менять TypeScript, собирать игру, создавать новый архив, загружать его в Яндекс Игры и ждать публикацию новой версии. Особенно если речь идёт о балансе, который может меняться довольно часто.

Поэтому я решил сделать игровой контент server-driven. То есть клиент знает, как работает способность, но не знает заранее, какие именно способности и персонажи существуют в игре.

Архитектура серверной части

Сейчас проект представляет собой monorepo. Основные части выглядят примерно так:

game client
    │
    ├── packages/hero-contracts
    │
    ▼
Fastify API
    │
    ├── Prisma
    ▼
PostgreSQL

Vue Admin
    │
    ▼
Fastify API

Стек серверной части получился таким:

Технология

Зона ответственности

Fastify

HTTP API, авторизация, игровые команды и admin API

TypeScript

серверная логика и общие контракты

PostgreSQL

игроки, прогресс, релизы контента и аудит

Prisma

схема базы, migrations и доступ к данным

Valibot

runtime-валидация игрового контента

Vue 3

административная панель

Docker Compose

локальный и production-запуск API, PostgreSQL и админки

Vitest

тестирование контрактов и серверной логики

Отдельно появился пакет:

packages/hero-contracts

Он используется и frontend, и backend. В нём находятся схемы персонажей, описание способностей, типы прогрессии, структура игрового контента, runtime-валидация и часть общей игровой логики. Это оказалось важным, потому что клиент и сервер должны одинаково понимать, что такое персонаж или способность.

Почему TypeScript недостаточно

Первый вариант загрузки контента выглядел примерно так:

const response = await fetch('/v1/game-content/heroes')

const content: HeroContentBundle =
  await response.json()

С точки зрения TypeScript всё выглядит нормально: content имеет тип HeroContentBundle. Но тип здесь существует только во время разработки. Если сервер реально вернёт:

{
  "contentVersion": "hello"
}

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

Поэтому для контрактов я использую runtime-схемы. Например, тип элемента персонажа:

export const ElementTypeSchema =
  v.picklist([
    'ice',
    'fire',
    'earth',
    'dark',
    'light',
  ])

А эффект, изменяющий поле, описан как набор допустимых вариантов:

export const FieldEffectSchema =
  v.variant('type', [
    v.strictObject({
      type: v.literal('convert'),
      elementType: ElementTypeSchema,
      targetCount: positiveInteger,
    }),

    v.strictObject({
      type: v.literal('swap'),
    }),

    v.strictObject({
      type: v.literal('rotateSegment'),
      orientation: v.picklist([
        'horizontal',
        'vertical',
      ]),
      pattern: v.picklist([
        'single',
        'adjacent',
        'gap',
        'centerOrEdges',
      ]),
      oppositeRotation: v.boolean(),
    }),
  ])

Клиент может получить только тот эффект, который умеет выполнять. Если сервер пришлёт:

{
  "type": "teleportAllCubes"
}

такой контент не пройдёт validation.

Сам по себе подход довольно простой, но для меня здесь было важно разделить две вещи: TypeScript type и runtime contract. До этого в frontend-проектах я не всегда проводил между ними такую строгую границу.

Структурно валидные данные тоже могут быть неправильными

После добавления runtime-схем появилась следующая проблема. Допустим, у персонажа в прогрессии есть требование:

{
  "type": "material",
  "materialId": "fire-crystal",
  "amount": 5
}

С точки зрения schema всё правильно: type допустимый, materialId — строка, amount — положительное число. Но материала fire-crystal может вообще не быть в каталоге игры. То же самое относится к способностям: персонаж может ссылаться на abilityId = fire-explosion, а такой способности уже нет.

Поэтому одной проверки формы объекта оказалось недостаточно. После parsing выполняется дополнительная проверка ссылок. Упрощённо pipeline выглядит так:

JSON
  ↓
runtime schema
  ↓
semantic validation
  ↓
cross-reference validation
  ↓
готовый игровой контент

Например, при проверке материалов собирается набор существующих ID:

const materialIds = new Set(
  bundle.materials.map(({ id }) => id),
)

После этого требования прогрессии проверяются относительно этого набора:

if (
  requirement.type === 'material' &&
  !materialIds.has(requirement.materialId)
) {
  issues.push(
    `Персонаж ${hero.id} ссылается ` +
    `на неизвестный материал ` +
    `${requirement.materialId}.`,
  )
}

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

Почему я объединил персонажей, материалы и способности

Изначально я собирался загружать каталоги отдельно:

GET /characters
GET /abilities
GET /materials

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

characters v15 → success
materials v15  → error

Если frontend сохранит первый успешный ответ, получится:

characters v15
materials v14

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

Поэтому сейчас игровой контент загружается одним bundle:

export const HeroContentBundleSchema =
  v.strictObject({
    contentVersion: positiveInteger,
    balanceVersion: positiveInteger,
    characters: CharacterCatalogSchema,
    abilities: v.pipe(
      v.array(AbilityDefinitionSchema),
      v.readonly(),
    ),
    materials: v.pipe(
      v.array(MaterialDefinitionSchema),
      v.readonly(),
    ),
  })

На клиент приходит одна согласованная версия:

GET /v1/game-content/heroes

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

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

Способности как данные

Самая сложная часть server-driven подхода связана со способностями. Перенести обычные характеристики довольно просто. Например:

{
  "id": "pyraxis",
  "name": "Пираксис",
  "maxHp": 120,
  "elementType": "fire"
}

Но если логика способности остаётся написанной в клиенте:

if (ability.id === 'pyraxis-flame-surge') {
  // уникальная логика
}

то добавить новый тип способности через backend всё равно нельзя. Клиент должен заранее знать её реализацию.

Поэтому я начал разбивать способности на небольшие заранее известные операции. Сейчас активная способность состоит из нескольких частей:

activation
+
fieldEffect
+
battleEffect

fieldEffect отвечает за изменение поля игры три в ряд. Например:

{
  type: 'convert',
  elementType: 'fire',
  targetCount: 3,
}

Другой эффект может разрешить игроку поменять два куба местами:

{
  type: 'swap',
}

Или повернуть часть поля:

{
  type: 'rotateSegment',
  orientation: 'horizontal',
  pattern: 'adjacent',
  oppositeRotation: true,
}

Отдельно описываются эффекты, которые будут применяться к боевой части. Например:

{
  id: 'fire-power',
  timing: 'round-resolution',
  type: 'modify-stat',
  target: 'self',
  stat: 'fireDamagePower',
  operation: 'percent-add',
  value: {
    type: 'constant',
    value: 0.25,
  },
  duration: {
    type: 'until-round-end',
  },
}

Таким образом, один персонаж может быть собран из механик, которые уже поддерживает движок.

Вычисляемые значения

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

Простая константа:

{
  type: 'constant',
  value: 20,
}

Значение от игрового ресурса:

{
  type: 'resource',
  target: 'self',
  resource: 'fireDamage',
  multiplier: 0.3,
}

Или составное выражение:

{
  type: 'sum',
  values: [
    {
      type: 'constant',
      value: 10,
    },
    {
      type: 'resource',
      target: 'self',
      resource: 'fireDamage',
      multiplier: 0.2,
    },
  ],
}

Сейчас поддерживаются:

constant
resource
sum
multiply
min
max

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

Пока принцип простой: если в игре появляется новая реальная механика, для неё можно добавить новый primitive.

Проблема с loadout персонажа

После переноса способностей в данные проявилась ошибка в модели персонажа. Изначально loadout выглядел так:

interface HeroAbilityLoadout {
  activeAbilityId: string
  passiveAbilityId: string
  ultimateAbilityId: string
}

У героя три способности, поэтому три ID выглядели логично. Затем unlock способностей тоже стал data-driven:

Active   → уровень 1
Passive  → уровень 2
Ultimate → уровень 3

Получалось, что персонаж первого уровня обязан иметь:

ultimateAbilityId: string

хотя Ultimate ему ещё недоступен.

В результате контракт пришлось изменить:

interface HeroAbilityLoadout {
  activeAbilityId: string | null
  passiveAbilityId: string | null
  ultimateAbilityId: string | null
}

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

Это была небольшая проблема, но она хорошо показала отличие server-driven модели от набора статических объектов. Когда правила становятся данными, старые предположения в типах начинают проявляться довольно быстро.

Версии контента

После того как персонажи стали загружаться с backend, понадобилось версионирование. В bundle сейчас есть два числа:

{
  contentVersion: 15,
  balanceVersion: 7,
}

contentVersion относится к версии набора игрового контента. balanceVersion позволяет отдельно отслеживать изменения баланса.

У самого персонажа также есть версия и минимальная версия движка:

{
  id: 'pyraxis',
  version: 3,
  requiredEngineVersion: 2,
}

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

const incompatible =
  content.characters.some(
    ({ requiredEngineVersion }) =>
      requiredEngineVersion > engineVersion,
  )

if (incompatible) {
  throw new ContentError(
    'invalid-content',
    'Опубликованный контент требует ' +
      'более новую версию движка.',
    409,
  )
}

Пока система версий довольно простая, но для PvP она становится особенно важной. Если бой был создан с персонажем версии 3, а через неделю баланс персонажа изменился и появилась версия 4, старый бой нельзя незаметно пересчитать уже с новыми значениями.

Поэтому snapshot боя хранит ссылку на конкретную версию персонажа. Эту часть я ещё продолжаю развивать.

Draft и Published

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

Первый вариант был довольно прямым:

изменил данные
↓
сохранил
↓
игроки получили изменения

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

Поэтому у релиза есть состояния:

DRAFT
PUBLISHED
ARCHIVED

DRAFT используется как рабочая область. Он может какое-то время быть неполным. Например, backend специально разрешает сохранить незавершённый draft. Но операция публикации выполняет полную проверку:

draft
  ↓
parse
  ↓
schema validation
  ↓
references validation
  ↓
assets validation
  ↓
starter heroes validation
  ↓
publish

Только после этого контент становится доступен игре.

Атомарная публикация

Переключение опубликованной версии выполняется в транзакции. Упрощённо:

const published =
  await db.$transaction(async (tx) => {
    await tx.contentRelease.updateMany({
      where: {
        status: 'PUBLISHED',
      },
      data: {
        status: 'ARCHIVED',
      },
    })

    return tx.contentRelease.update({
      where: {
        id: draft.id,
      },
      data: {
        status: 'PUBLISHED',
        publishedAt: new Date(),
      },
    })
  })

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

На практике PostgreSQL-транзакции начали появляться в проекте именно из таких задач, а не потому, что я заранее решил использовать их в архитектуре.

Audit log

Для действий в админке добавлен журнал изменений. Сейчас запись содержит примерно такую информацию:

adminId
action
entityType
entityId
reason
before
after
createdAt

Например, при публикации контента сохраняется причина:

"Уменьшен коэффициент Fire Ultimate"

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

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

Прогресс игрока

Следующей частью backend стал прогресс. На сервере сейчас хранятся:

Player
PlayerWallet
PlayerHero
PlayerMaterial

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

Например, состояние героя в базе выглядит примерно так:

playerId
heroId
level
copies
awakeningRank
abilityLoadout

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

Повышение уровня как одна операция

С frontend повышение уровня выглядит довольно просто:

POST /v1/player/heroes/:heroId/level-up

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

Если выполнить эти действия отдельно, может возникнуть ситуация:

gold уменьшился
materials уменьшились
level-up упал

Игрок потерял ресурсы, но уровень не получил. Поэтому изменение прогресса выполняется в транзакции:

return db.$transaction(async (tx) => {
  const hero =
    await tx.playerHero.findUnique(/* ... */)

  const wallet =
    await tx.playerWallet.findUnique(/* ... */)

  const requirements =
    getProgressionRequirements(
      definition,
      'level-up',
      state,
    )

  checkRequirements(
    requirements,
    state,
    inventory,
    wallet.gold,
  )

  await tx.playerWallet.update(/* ... */)
  await tx.playerMaterial.update(/* ... */)
  await tx.playerHero.update(/* ... */)

  return mutationResult(/* ... */)
})

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

Повторный HTTP-запрос

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

client → level-up
server → списал ресурсы
server → повысил уровень
server → отправил response

Но соединение оборвалось до того, как клиент получил ответ. С точки зрения frontend запрос завершился ошибкой, поэтому он может сделать retry. Backend получает второй такой же запрос и без дополнительной защиты повторно спишет ресурсы и повысит уровень.

Поэтому для изменяющих состояние игровых команд я добавил Idempotency-Key. Запрос выглядит примерно так:

POST /v1/player/heroes/pyraxis/level-up

Idempotency-Key:
550e8400-e29b-41d4-a716-446655440000

В базе есть отдельная запись:

playerId
operation
key
requestHash
response

Перед выполнением операции сервер ищет существующий ключ:

const previous =
  await tx.idempotencyRecord.findUnique({
    where: {
      playerId_operation_key: {
        playerId,
        operation,
        key: idempotencyKey,
      },
    },
  })

Если запись уже существует, операция не выполняется повторно:

if (previous) {
  return previous.response
}

Но одного ключа тоже недостаточно. Клиент случайно может использовать один и тот же ключ для двух разных команд. Поэтому дополнительно рассчитывается hash запроса:

const requestHash =
  createHash('sha256')
    .update(
      JSON.stringify({
        operation,
        heroId,
      }),
    )
    .digest('hex')

Если ключ совпадает, но содержимое команды другое, сервер возвращает конфликт:

if (
  previous.requestHash !== requestHash
) {
  throw new DomainError(
    'idempotency-conflict',
    'Idempotency-Key уже использован ' +
      'для другой команды.',
    409,
  )
}

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

Одновременная авторизация из двух вкладок

Ещё одна проблема возникла при создании пользователя. Игра авторизует игрока через данные Яндекс Игр. При первом входе backend должен создать Player, wallet, начальный inventory и стартовых персонажей.

Логика начинается с поиска пользователя:

const existing =
  await db.player.findUnique({
    where: {
      yandexId: profile.yandexId,
    },
  })

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

request A → player not found
request B → player not found

request A → create player
request B → create player

Проверка через обычный if (!existing) от этого не защищает. Поэтому уникальность yandexId обеспечивается на уровне PostgreSQL:

model Player {
  id       String @id @default(cuid())
  yandexId String @unique
}

Один запрос успешно создаст пользователя. Второй получит ошибку уникального ограничения. После этого backend повторно читает уже существующую запись и продолжает работу с ней.

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

Авторизация через Яндекс Игры

Отдельно пришлось разобраться с тем, как вообще доверять данным о пользователе. Frontend получает от SDK подписанные данные игрока и отправляет их на backend:

POST /v1/auth/yandex

Сервер проверяет подпись с помощью секрета игры. После успешной проверки можно использовать полученный yandexId для поиска внутреннего пользователя.

Затем backend создаёт свой JWT:

const token =
  await reply.jwtSign(
    {
      kind: 'player',
      sub: player.id,
    },
    {
      expiresIn: '4h',
    },
  )

И уже этот token используется для следующих запросов к игровому API. То есть Yandex используется для подтверждения личности игрока, а дальше сессией управляет мой backend.

Для локальной разработки есть отдельный dev auth, который полностью отключается в production.

Администратор и игрок — разные типы сессий

Админка работает через тот же Fastify API, но авторизация устроена отдельно. В JWT есть тип:

type TokenPayload = {
  kind: 'player' | 'admin'
  sub: string
  role?: string
}

Игровой запрос проходит через requirePlayer, административный — через requireAdmin. Для админки JWT хранится в httpOnly cookie.

В базе у администратора есть роль:

OWNER
EDITOR
SUPPORT

Пока разграничение возможностей ещё довольно простое, но сам тип пользователя уже является частью контракта. Это помогает не смешивать игровые endpoint’ы и управление контентом.

Assets тоже стали частью backend

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

Сейчас assets загружаются через Fastify multipart. Для файла проверяется MIME type, максимальный размер, допустимое расширение по содержимому и безопасность SVG.

Разрешены:

PNG
JPEG
WebP
SVG

Для SVG дополнительно проверяются потенциально опасные конструкции:

/<script\b|on[a-z]+\s*=|javascript\s*:/i

Сам файл получает SHA-256:

const hash =
  createHash('sha256')
    .update(buffer)
    .digest('hex')

На диске он сохраняется под ключом, построенным из hash и имени. Метаданные лежат в PostgreSQL:

key
originalName
mimeType
size
sha256
relativePath
category

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

Где всё это запускается

Игра публикуется в Яндекс Играх как обычная frontend-сборка:

dist.zip

Сам Fastify-сервер Яндекс Игры не запускают, поэтому backend находится на отдельной VM. Сейчас production-схема примерно такая:

Yandex Games
      │
      │ frontend
      ▼
   browser
      │
      │ HTTPS
      ▼
      VM
      │
 reverse proxy
   ┌───┴────┐
   │        │
 /api    /assets
   │        │
Fastify   files
   │
PostgreSQL

Там же работает административная панель. API, PostgreSQL и админка запускаются через Docker Compose. PostgreSQL не доступен напрямую из интернета.

Разделение frontend и server environment

Во время настройки инфраструктуры я отдельно разделил переменные окружения. Раньше довольно легко было сложить всё в один .env, но frontend и backend имеют совершенно разные требования.

Поэтому сейчас используются два файла:

.env.game
.env.server

В .env.game находятся только публичные настройки:

VITE_API_URL
...

Эти значения попадают в собранный JavaScript.

В .env.server находятся:

DATABASE_URL
JWT_SECRET
YANDEX_GAMES_SECRET
POSTGRES_PASSWORD
ADMIN_EMAIL
...

Этот файл используется только на сервере. Разделение довольно простое, но здесь особенно важно помнить, что любая переменная, попавшая в frontend bundle, перестаёт быть секретной. Название вроде VITE_SECRET ничего не меняет.

Health и readiness

Для backend добавил два отдельных endpoint’а:

GET /health
GET /ready

health просто показывает, что процесс Fastify работает:

app.get(
  '/health',
  async () => ({
    status: 'ok',
  }),
)

ready дополнительно проверяет PostgreSQL:

app.get(
  '/ready',
  async (_request, reply) => {
    try {
      await db.$queryRaw`SELECT 1`

      return {
        status: 'ready',
      }
    } catch {
      return reply
        .code(503)
        .send({
          status: 'not-ready',
        })
    }
  },
)

Это нужно, например, после запуска контейнера. Сам Node.js уже может работать, но база ещё недоступна. С точки зрения обычного health check приложение живо, а с точки зрения обслуживания запросов — ещё нет.

Что сейчас мне не нравится в архитектуре

Backend развивался довольно быстро, и одна проблема уже хорошо заметна. Основной файл:

apps/api/src/app.ts

стал большим. В нём сейчас находятся маршруты, авторизация, работа с игроками, progression, публикация контента, assets, admin API, часть validation и вспомогательные функции.

Само приложение пока небольшое, поэтому оно работает. Но файл уже становится неудобно изменять.

Когда я только начинал backend, можно было сразу создать структуру:

controllers
services
repositories
use-cases
domains

Но тогда я бы в основном раскладывал код по папкам, не понимая реальные границы системы. Сейчас они постепенно начали проявляться естественным образом:

auth
content
progression
assets
admin

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

Тестирование

Как и в предыдущей игре, я стараюсь не тестировать только HTTP-код. Большая часть правил вынесена в обычные функции и shared contracts.

Например, отдельно можно проверить:

  • валидность HeroContentBundle;

  • ссылки между материалами и персонажами;

  • корректность loadout;

  • доступность способности на конкретном уровне;

  • требования повышения уровня;

  • starter heroes;

  • вычисление описаний способностей;

  • повторное использование Idempotency-Key;

  • создание игрока.

Особенно полезными оказались тесты вокруг контрактов. Когда я меняю структуру способности, тесты довольно быстро показывают, в каких местах frontend и backend перестали одинаково её понимать.

Что оказалось сложнее всего

Самой сложной частью пока оказалась не работа с Fastify или PostgreSQL. HTTP endpoint написать относительно просто. Гораздо больше времени занимает определение того, какие состояния игры вообще считаются корректными.

Например:

может ли герой иметь Ultimate,
если он ещё не открыт?

Или:

можно ли удалить material,
который используется в progression?

Или:

что произойдёт со старым PvP-бойом,
если персонажа уже перебалансировали?

Или:

должен ли повторный level-up
повторять операцию?

Когда backend только отдаёт список объектов, эти вопросы почти не заметны. Но как только сервер становится источником истины для контента и прогресса игрока, практически каждое такое правило приходится формулировать явно.

Вторая сложность — граница между кодом и игровым контентом. Если каждая новая способность требует добавлять отдельный:

if (ability.id === ...)

то server-driven система мало что даёт. С другой стороны, делать полностью универсальный scripting engine для небольшой игры три в ряд тоже не хочется. Поэтому пока я стараюсь оставлять на сервере комбинации данных, а в движке — ограниченный набор хорошо определённых операций.

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

Что получилось в итоге

Сейчас backend игры уже умеет:

  • авторизовывать игрока через Яндекс Игры;

  • создавать внутреннего пользователя;

  • хранить wallet и inventory;

  • хранить прогресс персонажей;

  • повышать уровень и ранг пробуждения;

  • изменять ability loadout;

  • проверять игровые требования;

  • выполнять изменения в транзакциях;

  • защищать команды через Idempotency-Key;

  • хранить server-driven персонажей;

  • хранить способности отдельно от персонажей;

  • валидировать контент через runtime-схемы;

  • проверять ссылки между сущностями;

  • хранить версии контента;

  • работать с DRAFT, PUBLISHED и ARCHIVED;

  • публиковать новый контент атомарно;

  • вести audit log;

  • загружать и проверять assets;

  • обслуживать отдельную административную панель.

При этом большая часть боевой логики всё ещё остаётся на клиенте. Backend пока в первую очередь отвечает за:

identity
content
progression
persistence

Следующим большим этапом будет асинхронный PvP, где серверу уже придётся хранить состояние самого боя и намного строже контролировать игровые действия.

Что хочу разобрать во второй части

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

Там уже есть:

  • FieldEffect;

  • BattleEffect;

  • PassiveEffect;

  • вычисляемые ValueExpression;

  • unlock способностей;

  • привязка одной способности к разным персонажам;

  • редактор в админке;

  • автоматическое описание способности из её реальных параметров.

Последнее появилось из довольно простой проблемы. Если отдельно хранить:

damage = 20

и отдельно текст:

"Наносит 20 урона"

то после изменения balance на:

damage = 25

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

Но это уже отдельная тема.

Вместо заключения

Я начинал backend этой игры с желания перенести персонажей из TypeScript на сервер и менять баланс без новой публикации клиента. В итоге самая полезная часть оказалась даже не в том, что я научился поднимать Fastify рядом с PostgreSQL.

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

Со стороны frontend всё это часто скрывается за одним:

await api.levelUp()

А на сервере как раз начинается самая интересная часть.

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