Честные таблицы лидеров без серверного кода: валидированные забеги, sus-пакеты и профили игроков
Коротко о главном
Создайте таблицы лидеров с серверной валидацией без серверного кода: одноразовые тикеты, sus-пакеты для Replay, серверная валюта и профили игроков.
Первая неделя новой таблицы лидеров обычно заканчивается одинаково: где-то между честной десяткой лучших и остальной таблицей оказывается игрок с 2 147 483 647 очками, максимальным значением, которое помещается в знаковое 32-битное целое. Этот забег никто не проходил. Его записал редактор памяти или перехватывающий Proxy, потому что в большинстве таблиц лидеров игровой клиент одновременно единственный свидетель и судья.
Этот релиз закрывает слабое место с двух сторон. Validated Actions переносит решение о том, что засчитывается, на сервер, а новые sus-пакеты забегов сохраняют каждый подозрительный забег как полное дело, которое можно воспроизвести. Заодно таблицы лидеров становятся более личными: каждая запись теперь содержит профиль игрока с аватаром, рамкой и до трёх значков, а косметику можно разблокировать подарочными кодами. Ниже разберём, как работает каждая часть и какие цифры за ней стоят, приведём рабочий код для Unity и Godot и честно опишем ограничения, о которых стоит знать, прежде чем на это полагаться.
Почему результатам от клиента нельзя доверять
Классическая отправка результата состоит из одного запроса: ID игрока, очки, и всё. Всё, что сервер знает об этом забеге, приходит с устройства, которое больше всех заинтересовано во лжи. У привычных контрмер один и тот же изъян:
- Подпись запроса на клиенте. Ключ подписи поставляется внутри вашего билда. Извлечь его из бинарника IL2CPP или экспорта GDScript можно за один вечер, и с этого момента поддельные запросы выглядят абсолютно валидными.
- Обфускация очков в памяти. Это замедляет простые редакторы памяти, но Proxy, который переписывает тело HTTP-запроса, вообще не трогает раскладку вашей памяти.
- Проверки правдоподобности на клиенте. Всё, что выполняется на устройстве, на устройстве же можно и вырезать патчем.
Надёжный ответ: решает сервер. Хрестоматийный вариант такого подхода называется авторитарной симуляцией: игровая логика работает на железе, которое вы контролируете, а клиент отправляет только ввод. Для быстрого экшена с тысячами одновременных забегов это означает недели работы над Netcode плюс постоянный счёт за хостинг. Большинству инди-команд полная симуляция не нужна. Им нужны три более дешёвые гарантии: сервер знает, когда начался забег, какие правила к нему применяются и какие доказательства остаются после него. Именно этот пробел и закрывает Validated Actions.
Как работает валидированный забег
Валидированный забег состоит из четырёх шагов, и сервер контролирует два самых важных:
- Старт забега. Игра вызывает
StartRun. Сервер выдаёт подписанный одноразовый тикет, привязанный к игроку, API-ключу и, при необходимости, к одной таблице лидеров. Тикет содержит Seed, выбранный сервером (от 0 до 2 147 483 646), и срок действия: по умолчанию 2 часа, настраивается от 60 секунд до 6 часов. - Игра. Игра инициализирует генератор случайных чисел серверным Seed и записывает ввод игрока в компактный байтовый лог.
- Конец забега. Игра вызывает
SubmitValidatedс очками, необязательным этапом, необязательными заработанными значениями и логом ввода. SDK отправляет хеш SHA-256 лога, так что сам лог пока остаётся на устройстве. - Проверка на сервере. Сервер проверяет тикет, сам измеряет длительность (от выдачи тикета до отправки) и применяет ваши правила. Только после этого он что-либо записывает.
Тикет засчитывается ровно один раз во всех регионах. Отклонённый забег тоже сжигает свой тикет, поэтому никто не сможет прощупать ваши пороги, повторяя тот же тикет со слегка меньшими очками. Поскольку Seed выбирает сервер, а число тикетов на игрока в час ограничено (по умолчанию 60), фарм удачного Seed становится медленным и заметным.
var va = ValidatedActionsManager.Instance;
// Run start: single-use ticket plus server seed
ValidatedRun run = await va.StartRun("weekly");
if (run == null) { Debug.Log(va.LastErrorCode); return; } // e.g. RUN_RATE_LIMITED
var random = new System.Random(run.seed);
// ... play, record the inputs into inputLog (byte[]) ...
// Run end: the SDK hashes the log and sends the SHA-256 with the ticket
ValidatedSubmitResult result = await va.SubmitValidated(18250, inputLog);
if (result == null)
{
// e.g. DURATION_TOO_SHORT; HasActiveRun tells you if the ticket survived
Debug.Log($"{va.LastErrorCode}, run kept: {va.HasActiveRun}");
return;
}
Debug.Log($"Rank {result.rank}, {result.durationSeconds}s measured by the server, sus: {result.sus}");
Тот же сценарий есть в Godot (Horizon.validatedActions.startRun и submitValidated) и в Unreal (Horizon->ValidatedActions), для каждого движка в SDK есть полный пример.
Правила, которые знает только сервер
Каждый API-ключ получает один набор правил, который редактируется в Dashboard в виде формы или JSON. Правила никогда не попадают в ответы приложению или в сообщения об ошибках: клиент узнаёт только машиночитаемый код. Вот реалистичный набор правил для аркады с волнами:
{
"formatVersion": 1,
"ticketLifetimeSeconds": 3600,
"maxRunsPerPlayerPerHour": 30,
"defaults": {
"maxScore": 250000,
"minDurationSeconds": 45,
"maxScorePerSecond": 900.0,
"stages": {
"wave_10": { "maxScore": 40000, "minDurationSeconds": 60 }
},
"soft": { "maxScore": 180000, "maxScorePerSecond": 600.0 }
},
"leaderboards": {
"speedrun": { "minScore": 95000, "minDurationSeconds": 95 }
},
"values": {
"gold": { "maxPerRun": 500, "minPerRun": -1000, "dailyCap": 5000 }
}
}
Прогоним через него несколько отправок на таблице weekly:
| Отправленный забег | Ответ сервера |
|---|---|
| 9 999 999 очков | 422 SCORE_ABOVE_MAX |
| 21 000 очков через 3 секунды после выдачи тикета | 422 DURATION_TOO_SHORT |
| 150 000 очков за 120 секунд (1250 в секунду) | 422 SCORE_RATE_TOO_HIGH |
| 190 000 очков за 240 секунд | принят, но sus (выше мягкого максимума 180 000) |
| тот же тикет повторно | 422 TICKET_CONSUMED |
Сервер проверяет в фиксированном порядке (этап, очки, правило этапа, длительность, очки в секунду, заработанные значения, затем мягкие пороги), и решает первое же нарушение. Правила по очкам действуют только для забегов с таблицей; у забега без таблицы всё равно проверяются правила этапа и длительности.
Когда правила настроены, переключите таблицу в режим «Только проверенные отправки». С этого момента обычный SubmitScore в эту таблицу отклоняется с 403 VALIDATED_SUBMIT_REQUIRED, а все остальные таблицы продолжают принимать обычные отправки. Даже с пустым набором правил таблица только для валидированных отправок гарантирует серверные тикеты, одноразовое использование, привязку к игроку, часовые лимиты и сохранённый хеш лога.
Мягкие пороги и sus-забеги
Жёсткие правила отклоняют. Мягкие пороги только помечают забег, и начинать стоит именно с них: неделя с мягкими лимитами покажет, как выглядит реальная игра, прежде чем вы превратите цифры в жёсткие отказы. Забег считается sus, если он был принят и превысил хотя бы один мягкий порог. Отклонённые забеги и технические ошибки никогда не бывают sus. Результат отправки содержит простой флаг sus; какой именно порог сработал, остаётся на сервере.
Самое интересное начинается дальше. С этим обновлением сервер хранит sus-пакет для каждого sus-забега независимо от лучшего результата игрока, Top N или последующих изменений очков. Читер, который публикует один абсурдный забег, а затем нормальный, больше не сможет похоронить улики под более удачной записью.
Стартовый контекст: с чего начался забег
Replay полезен, только если можно воспроизвести точные начальные условия. Поэтому при StartRun сервер теперь записывает стартовый контекст: действующую версию правил, реальные байты Cloud Save игрока (с SHA-256 и ревизией), значения, которыми владеет сервер, Seed и время старта. Игра может добавить свою часть истории:
var context = new ValidatedRunContext(
gameVersion: Application.version, // e.g. "1.4.2"
contentVersion: "levels-2026-10",
simulationVersion: "sim-7",
replayFormatVersion: "input-v3",
contentDigest: ValidatedActionsManager.ComputeInputLogHash(levelBytes),
initialState: serializedLevelState); // bytes the simulation starts from
ValidatedRun run = await ValidatedActionsManager.Instance.StartRun("weekly", context);
Серверные и клиентские значения строго разделены, и те и другие входят в канонический текст, SHA-256 которого хранится в тикете. Отправка также проверяется по версии правил на момент старта, поэтому ужесточение лимита во время идущего забега никогда не меняет вердикт по нему. Чтобы правки правил нельзя было использовать для зондирования, изменения ограничены 30 в час на один API-ключ.
Что содержит sus-пакет
Каждый пакет хранится под ID забега и объединяет всё, что нужно проверяющему: стартовый контекст, привязанные правила, результат, серверные значения до и после забега и лог ввода (SDK загружает его автоматически, так же как для забегов из Top N). Во вкладке Review в Dashboard вы фильтруете по sus и экспортируете пакет как ZIP-файл:
<runId>.hzn-va-package.zip
├── manifest.json format horizon.validated-actions.package v1
├── start-context.txt canonical start context, hashed on the ticket
├── rules.json the rule version the run was checked against
├── cloud-save.bin the player's save at run start
├── initial-state.bin the game's initial simulation state
├── input-log.bin the recorded inputs
└── SHA256SUMS
При каждом экспорте сервер заново вычисляет все контрольные суммы и сообщает результат в manifest.integrity и в заголовке X-Package-Integrity (ok или mismatch). Если отдельная часть не укладывается в лимит размера, сохраняется только её контрольная сумма, а часть помечается как OMITTED_SIZE_LIMIT, так что вы всегда знаете, чего не хватает и почему.
Подайте cloud-save.bin, initial-state.bin, Seed и input-log.bin в свою детерминированную симуляцию, и результат либо воспроизведётся, либо нет. Replay, вердикты и санкции остаются за вами; сервер помечает и архивирует, но никогда не запускает код вашей игры.
Валюта, которую клиент не может записать
Таблицы лидеров не единственное, на чём выгодно читерить. С серверными значениями вы определяете счётчики вроде gold или gems в том же наборе правил. Изменяют их только принятые валидированные забеги: нет ни эндпоинта приложения, ни метода SDK, который устанавливает баланс. Правило из JSON выше читается так:
maxPerRun: 500: забег, который заявляет 800 золота, отклоняется сEARNED_ABOVE_MAX.minPerRun: -1000: за забег можно потратить до 1000 золота; попытка потратить больше баланса завершается ошибкойINSUFFICIENT_BALANCE.dailyCap: 5000: положительные начисления за сутки UTC обрезаются, а не отклоняются. Игрок, который сегодня уже заработал 4800 и завершает забег на 500 золота, получает 200.
Ответ показывает requested и credited для каждого затронутого ключа, так что игра может вывести «дневной лимит достигнут», а не терять монеты молча. Для покупок правило простое: выдавайте предмет, только если трата была проведена полностью (IsFullyCredited в Unity и Unreal). Та же логика стоит за переносом магазинов улучшений с клиентских значений по умолчанию: устройство может запросить, выдаёт только сервер.
Cloud Save работает как прежде и становится зеркалом. После каждого принятого забега копируйте серверные значения в сохранение для офлайн-отображения, при запуске перезаписывайте эту копию через GetState и никогда не отправляйте значение из сохранения обратно как баланс.
Профили игроков в каждой записи таблицы лидеров
Вторая половина обновления посвящена не цифрам, а людям в таблице. Каждая запись таблицы лидеров в Top, Around и Rank теперь содержит профиль с avatarId, frameId и до трёх badges:
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
Сервер читает профиль вместе с отображаемым именем из своего кэша имён пользователей, поэтому топ-список не требует дополнительного запроса на каждую запись. Изменения профиля появляются на всех серверах не позднее чем через 10 минут.
Каталог ID, а не изображений
Каждый проект ведёт в Dashboard каталог косметики. Запись содержит ID (например, avatar.zombie_07), тип (avatar, frame или badge) и флаг locked. Бесплатные записи может выбрать любой игрок, заблокированные только те игроки, которые их разблокировали. Сервер хранит только ID, никогда не изображения: ваша игра сопоставляет каждый ID со своими спрайтами, так что арт остаётся в вашем билде, а новая рамка стоит одной строки в каталоге.
Отрисовка строки таблицы в Godot выглядит так:
var entries := await Horizon.leaderboard.getTop(10, true, "weekly")
for entry in entries:
var row := RowScene.instantiate()
row.set_rank(entry.position, entry.username, entry.score)
if entry.profile.hasAvatar():
row.set_avatar(AVATARS.get(entry.profile.avatarId, DEFAULT_AVATAR))
if entry.profile.hasFrame():
row.set_frame(FRAMES.get(entry.profile.frameId))
for badge_id in entry.profile.badges:
row.add_badge(BADGES.get(badge_id))
$Board.add_child(row)
Неизвестные ID (например, после удаления записи из каталога) стоит просто отображать как «не задано»: игроки сохраняют устаревший ID, пока не изменят профиль, и ничего не ломается.
Разблокировки через подарочные коды
Разблокировки записывает только сервер, сейчас через подарочные коды с grants или вручную в Dashboard. У игрока может быть до 25 разблокировок. Активация кода с grants возвращает выданные ID и сбрасывает закэшированный профиль, так что следующий GetProfile показывает новый предмет как доступный:
var result := await Horizon.giftCodes.redeem("SPOOKY-FRAME-2026")
if not result.is_empty() and not result["grantedUnlocks"].is_empty():
await Horizon.playerProfile.getProfile() # reloads catalog and unlocks
if Horizon.playerProfile.isAvailable("frame.spooky"):
await Horizon.playerProfile.setProfile("avatar.zombie_07", "frame.spooky", ["badge.supporter"])
Выбор заблокированного предмета без разблокировки завершается ошибкой 403 COSMETIC_LOCKED, а больше трёх значков приводит к 400 INVALID_BADGES. Так награды за ивенты, розыгрыши в Discord и бонусы для поддерживающих превращаются в один подарочный код каждый, без собственного эндпоинта для разблокировок. Один пережиток старого API ушёл окончательно: параметр metadata при отправке очков никогда не сохранялся и теперь объявлен устаревшим. Информация об игроке должна жить в профиле.
Лимиты очков теперь считаются на API-ключ
Небольшое изменение с реальными последствиями для студий, у которых в одном аккаунте несколько игр или окружений: лимит очков теперь действует на каждый API-ключ. Каждый ключ может хранить столько строк результатов, сколько позволяет тариф (одна строка означает одного игрока в одной таблице), суммарно по всем таблицам этого ключа, и каждый дополнительный ключ получает собственную полную квоту. Существующие строки всегда можно улучшить; только первый результат игрока в таблице может получить 403, если ключ заполнен.
Лучшие практики для честных таблиц лидеров
- Начинайте мягко, затем ужесточайте. Неделю работайте только с порогами
soft, изучите sus-забеги во вкладке Review и превратите цифры в жёсткие правила с запасом от 20 до 30 процентов выше лучшего честного забега. - Вызывайте
StartRun, когда забег действительно начинается. Часы запускаются вместе с тикетом. Старт в меню или перед 40-секундным экраном загрузки делаетminDurationSecondsбессмысленным. - Держите логи ввода маленькими и детерминированными. Записывайте фиксированные кадры ввода с дельта-кодированием. Доказательства ограничены 32 КБ на лог, и 10-минутный забег при 30 кадрах ввода в секунду и 1 байте на кадр помещается с запасом.
- Версионируйте всё в стартовом контексте. Без
simulationVersionиcontentDigestпакет прошлого месяца может воспроизводиться с балансом этого месяца и провалиться не по той причине. - Считайте сохранение зеркалом, а не источником. Балансы текут с сервера в Cloud Save и никогда обратно.
Чего это не делает
Скажем прямо. Модифицированный клиент, который сообщает правдоподобные значения в рамках ваших правил, по-прежнему будет принят: Validated Actions ограничивает, сколько читер может выиграть, и делает подозрительные забеги доступными для проверки, но не делает читерство невозможным. Сервер проверяет границы, тайминг и одноразовость и архивирует доказательства, но не запускает и не воспроизводит код вашей игры, а автоматического вердикта по доказательствам пока нет. Validated Actions работает только в облаке; на self-hosted simpleServer SDK возвращают NOT_SUPPORTED. Каждому забегу нужен вошедший в систему игрок, а забегам в таблице лидеров также нужно отображаемое имя.
Ёмкость по тарифам
Все функции из этой статьи доступны на любом тарифе, включая FREE. Растёт только ёмкость:
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| Валидированные забеги на аккаунт в час UTC | 300 | 3000 | 20 000 | 200 000 |
| Серверные значения на API-ключ | 8 | 16 | 32 | 64 |
| Слоты доказательств для забегов из Top N | 50 | 500 | 2500 | 25 000 |
| Хранимые sus-пакеты | 10 | 100 | 1000 | 10 000 |
| Срок хранения sus-пакетов | 14 дней | 30 дней | 90 дней | 180 дней |
| Каталог косметики на API-ключ | 50 | 200 | 500 | 1000 |
| Строки результатов на API-ключ | 5000 | 25 000 | 200 000 | 2 500 000 |
Когда квота sus-пакетов исчерпана, новые sus-забеги по-прежнему принимаются и помечаются как sus, просто не архивируются. Сохранённые пакеты никогда не вытесняются новыми.
С чего начать
Обновитесь до последней версии SDK для Unity, Godot или Unreal, выберите одну таблицу и добавьте набор правил только с мягкими порогами. Добавьте ValidatedRunContext в вызов StartRun, а затем заполните каталог косметики аватарами и рамками, которые уже есть в вашей игре. Страница функции Validated Actions и страница функции таблицы лидеров кратко описывают правила и лимиты, а quickstart проводит через все три движка. Готовы сделать следующую таблицу лидеров одновременно честной и личной? Попробуйте horizOn бесплатно или изучите документацию API.