ESPNBuzz: Week 6 scoring leaders through Sunday Night FootballPunchJigawa gov pledges upward review of corps members’ allowanceInquirer1PWD party-list: ADHD diagnosis not an excuse to mock PWDsThe Jerusalem PostHamas planned to detonate bombs at Gaza refugee camps to cover terrorists' escape, IDF revealsBollywood HungamaTiger Shroff Firing Incident: Three accused arrested by Kota police while travelling on Pune-Jaipur expressZDF heuteEntdecken Sie das ZDF-NachrichtenstudioWirtualna PolskaKosiniak-Kamysz o dronie na Litwie. "Strona ukraińska przeprosiła"Complete SportsSemenyo Out For Two Months With Knee InjuryVarietyMiley Cyrus Fires Back at Criticism of Hollywood Bowl Concert and Considered Not Doing Second Show: ‘It’s Hard for Me to Stand Up Here’VilaWebLa UE demana de distingir les protestes pacífiques dels aldarulls i accions de pillatge a Françaالشرقبريطانيا تحذر من المشاركة في مناقصات مخطط E1 الاستيطاني بالضفةNHK 社会渋谷区マンション建設工事めぐり住民が最高裁に特別抗告
The Daily Newsstand · Free, Always
Monday, October 19, 2026

Карточный бой, который сервер может переиграть: детерминированный движок браузерной MMO на TypeScript

Translate

Мы делаем «Зархолд» — браузерную MMORPG, в которой любой бой, от первого кобольда до рейдового босса, — это пошаговая карточная дуэль. Клиент написан на React и PixiJS 8, сервер — на Fastify и PostgreSQL, а между ними лежит пакет shared с движком боя на TypeScript. Один и тот же код движка работает в браузере игрока и на сервере.

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

Зачем серверу переигрывать бой

В онлайн‑игре с рейтингом доверять клиенту нельзя: если клиент сообщает «я победил», изменённый клиент будет сообщать это всегда. Обычно выбирают одно из двух:

  • сервер ведёт бой сам, а клиент присылает только намерения и рисует результат;

  • клиент играет локально, а сервер потом проверяет исход.

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

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

Правило 1: никакой случайности, кроме одного генератора

Шапка engine.ts (сейчас в нём около 9 тысяч строк) формулирует контракт прямо:

/**
 * The deterministic, server-authoritative duel engine.
 *
 * Every exported function is PURE in the sense that the *only* state it reads
 * or writes is the DuelState passed in (and a deterministic RNG seeded
 * from it). There are no clock reads, no `Math.random`, and no I/O — given the
 * same starting state and the same sequence of calls you always get the same
 * result. That is what makes a duel replayable and validatable on the server.
 */

Весь случайный выбор — тасовка колоды, выбор карт для эффекта «сбросить N карт» у босса и тому подобное — идёт через один генератор. Это mulberry32: крошечный, быстрый и одинаковый в любом движке JavaScript, потому что опирается только на 32-битную целочисленную арифметику.

export interface Rng {
  /** Internal 32-bit state. Exposed so a duel can be serialized & resumed. */
  state: number;
}

export function createRng(seed: number): Rng {
  return { state: seed >>> 0 };
}

export function nextFloat(rng: Rng): number {
  // mulberry32
  rng.state = (rng.state + 0x6d2b79f5) | 0;
  let t = rng.state;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}

export function shuffle<T>(rng: Rng, items: readonly T[]): T[] {
  const out = items.slice();
  for (let i = out.length - 1; i > 0; i--) {
    const j = nextInt(rng, 0, i);
    [out[i], out[j]] = [out[j]!, out[i]!];
  }
  return out;
}

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

Генератор при этом не криптографический, и не должен им быть: сид выдаёт сервер, а от генератора нужна только воспроизводимость.

Правило 2: порядок, в котором тратится случайность, — часть протокола

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

// Clone in a fixed order (player, then each ally, then each enemy) so the RNG
// stream — and therefore every shuffle — is identical for a given seed. With
// no allies this clones exactly [a, b…] — the byte-identical legacy stream.
const player = cloneChar(a);
const allies = (opts.allies ?? []).map(cloneChar);
const enemies = enemyInputs.map(cloneChar);

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

Ещё одно решение, которое сильно упростило жизнь: в переработке характеристик мы убрали из игры критические удары. Числа на карте — ровно то, что произойдёт, и в PvP единственная случайность — тасовка и добор. Это решение пришло из геймдизайна (бой можно посчитать, а не надеяться на удачу), но и движку стало проще: меньше мест, где поток случайности может разойтись.

Как сервер переигрывает бой

Схема для боя на арене выглядит так:

  1. Сервер выдаёт матч: создаёт запись с соперником и сидом и отдаёт клиенту идентификатор матча и сид.

  2. Клиент играет бой тем же движком и записывает ходы: розыгрыш карты, силу класса, замену стартовой руки, расходник, расовую способность, активный предмет.

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

  4. Сервер собирает обоих бойцов из своих собственных данных, запускает startDuel с выданным сидом и применяет ходы по одному.

Сердце проверки — короткий цикл:

const MAX_REPLAY_MOVES = 2000;

export function replayPvpDuel(player, opponent, seed, moves) {
  // forPvp: Стойкость применяется, критов нет — повтор полностью детерминирован.
  const state = startDuel(player, opponent, seed, { forPvp: true });

  let movesApplied = 0;
  for (const move of moves) {
    if (movesApplied >= MAX_REPLAY_MOVES) break; // защита от журнала на миллион ходов
    if (state.winner !== null) break;            // бой уже кончился, дальше шум
    applyReplayMove(state, move);
    movesApplied += 1;
  }
  return { winner: state.winner, playerWon: state.winner === 'a', movesApplied, state };
}

Несколько свойств, которые здесь важны:

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

  • Нельзя «победить, не добив». Если клиент не прислал добивающий ход, бой заканчивается с winner: null, и победа не засчитывается.

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

Чья «кукла»: почему бойцов собирает сервер

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

Такие расхождения мы действительно ловили. Например, сервер по своему правилу не учитывал часть фамильных предметов, а клиент их учитывал — и у одного и того же героя получалось 352 единицы здоровья у клиента против 332 у сервера. Бой с таким расхождением при повторе может закончиться иначе, хотя игрок ничего не нарушал.

Три режима: выключено, наблюдение, строго

Отсюда главный принцип серверных проверок в проекте — «никогда не ломать честного игрока». Каждая проверка живёт за флагом окружения с тремя значениями, который читается один раз при старте процесса:

  • off — проверки нет;

  • shadow — сервер переигрывает бой и пишет расхождение в журнал античита, но решение не меняет;

  • enforce — решает исход сервера.

Режим наблюдения — это не формальность. Именно он показывает расхождения вроде истории с фамильными предметами до того, как они ударят по рейтингу честных игроков. Каждая запись о расхождении содержит матч, оба исхода, режим и версию движка клиента, так что по журналу видно, где проблема в нашей сборке бойцов, а где — в клиенте. Решение о строгом режиме принимаем по данным: считаем долю расхождений среди привязанных боёв и разбираем причину каждого, а не ориентируемся на «вроде бы мало».

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

Версия движка — тоже часть протокола

Правила боя меняются: появляются новые эффекты, меняется математика карт. Если старый клиент (вкладка, которую игрок не перезагружал со вчерашнего дня) сыграет бой по старым правилам, а сервер переиграет по новым, исходы разойдутся — и честный игрок будет наказан за то, что не обновил страницу.

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

export const DUEL_ENGINE_VERSION = '2026-10-14.v44-rashodniki-po-tekstu';

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

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

/**
 * Включать ли правило при ПОВТОРЕ записи боя, сыгранного движком версии `versiya`:
 * v43 и раньше — нет (повтор по старым правилам), v44 и новее — да.
 */
export function rashodnikiPoTekstuDlyaVersii(versiya: string | null | undefined): boolean {
  if (typeof versiya !== 'string') return true;
  const m = /\.v(\d+)(?:-|$)/.exec(versiya);
  if (m === null) return true;
  return Number(m[1]) >= RASHODNIKI_PO_TEKSTU_S_VERSII;
}

Таких флагов в движке несколько: правило «двух карт за ход с 10-го хода», новое поведение расходников и другие. Повтор старой записи проходит со старым набором правил, новый бой — с новым.

Грабли из эксплуатации

Детерминированный движок не избавляет от проблем на выкладке — он делает их очень заметными.

  • Смена версии движка днём. Однажды мы выложили изменение правил боя посреди дня, и у игроков с открытыми, но не перезагруженными вкладками бои начали расходиться с сервером. Теперь такие выпуски — только ночью и с объявлением «обновите игру».

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

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

Что ещё даёт детерминизм

Тесты. В пакете shared сейчас 270 тестовых файлов и около 2 700 тестов. Значительная часть из них проверяет не «работает ли эффект», а «не изменился ли старый бой»: комментарии вида «боец без комплекта переигрывается байт‑в‑байт как раньше» в коде — это не пожелание, а утверждение, которое проверяет тест.

Баланс симуляциями. Раз бой воспроизводим, баланс можно проверять пачками. Наш симулятор сводит каждую специализацию с каждой: 30 специализаций, 3 сида, каждый матч в обе стороны, чтобы убрать преимущество первого хода, — 5 400 боёв за прогон. Зеркальные бои (специализация против самой себя) должны давать ровно 50 на 50: это заодно проверяет, что ИИ одинаково честен к обеим сторонам.

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

Итог

  • Пошаговый карточный бой отлично подходит для серверной проверки: сид плюс журнал ходов — и бой воспроизводится на любой машине.

  • Детерминизм — это не только «нет Math.random». Это ещё и фиксированный порядок трат случайности, и запрет новым возможностям сдвигать поток для старых боёв.

  • Бойцов для проверки собирает сервер из своих данных. Расхождения его «куклы» с клиентской — главный источник ложных срабатываний.

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

  • Версия движка — часть протокола: клиент, сервер и записи боёв должны знать, по каким правилам их переигрывать.

Если интересно посмотреть, как всё это выглядит со стороны игрока, — «Зархолд» бесплатный и открывается в браузере: ru.zarhold.com. Вопросы по архитектуре задавайте в комментариях, ответим.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.