サーバーコード不要の公平なリーダーボード:検証済みラン、sus パッケージ、プレイヤープロフィール
要点まとめ
サーバーコード不要でサーバー検証型リーダーボードを構築:使い捨てのランチケット、再生可能な sus パッケージ、サーバー管理通貨、プレイヤープロフィール。
新しいリーダーボードの最初の1週間は、たいてい同じ結末を迎えます。正直なトップ10とそれ以外のプレイヤーの間に、2,147,483,647 点を持つプレイヤーが紛れ込んでいるのです。これは符号付き32ビット整数が保持できる最大値です。そのランを実際にプレイした人はいません。メモリエディタか傍受プロキシが書き込んだもので、ほとんどのリーダーボードではゲームクライアントが唯一の証人であり、同時に審判でもあるからです。
今回のリリースは、この弱点に二つの方向から取り組みます。Validated Actions は何をカウントするかの判断をサーバーに移し、新しい sus ランパッケージ は疑わしいランをすべて、再生可能な完全な証拠ファイルとして保存します。同時に、リーダーボードはより個性的になります。すべてのエントリにアバター、フレーム、最大3つのバッジを含むプレイヤープロフィールが付き、コスメティックはギフトコードでアンロックできるようになりました。以下では、各要素の仕組み、その裏付けとなる数値、Unity と Godot の動作するコード、そして実際に頼る前に知っておいてほしい制約を紹介します。
クライアントが報告するスコアを信頼できない理由
従来のスコア送信は1回のリクエストだけです。プレイヤー ID、スコア、以上。そのランについてサーバーが知っていることはすべて、嘘をつく動機が最も強いデバイスから来ています。よくある対策には、どれも同じ欠陥があります。
- クライアントでリクエストに署名する。 署名鍵はビルドに同梱されて出荷されます。IL2CPP バイナリや GDScript のエクスポートから鍵を取り出すのは午後のひと仕事で、それ以降は偽造リクエストが完全に正当なものに見えます。
- メモリ上のスコアを難読化する。 カジュアルなメモリエディタを遅らせることはできますが、HTTP ボディを書き換えるプロキシはメモリレイアウトにまったく触れません。
- クライアントで妥当性をチェックする。 デバイス上で動くものは、デバイス上でパッチを当てて無効化できます。
堅牢な答えは、何をカウントするかをサーバーが決めることです。その教科書的な形が権威サーバーによるシミュレーションで、ゲームロジックは自分で管理するハードウェア上で動き、クライアントは入力だけを送ります。数千のランが同時に走るテンポの速いアクションゲームでは、これは数週間のネットコード作業と恒久的なホスティング費用を意味します。ほとんどのインディーチームに完全なシミュレーションは必要ありません。必要なのは、もっと安価な三つの保証です。ランがいつ始まったか、どのルールが適用されるか、事後にどんな証拠が存在するかを、サーバーが把握していること。まさにこの隙間を埋めるのが Validated Actions です。
検証済みランの仕組み
検証済みランは4つのステップで構成され、重要な2つはサーバーが握っています。
- ラン開始。 ゲームが
StartRunを呼び出します。サーバーは署名付きの使い捨てチケットを発行し、プレイヤー、API キー、そして任意で1つのリーダーボードに紐付けます。チケットにはサーバーが選んだ Seed(0 から 2,147,483,646)と有効期限が含まれます。有効期限はデフォルトで2時間、60秒から6時間の範囲で設定できます。 - プレイ。 ゲームはサーバーの Seed で乱数を初期化し、プレイヤーの入力をコンパクトなバイトログに記録します。
- ラン終了。 ゲームはスコア、任意のステージ、任意の獲得値、入力ログを渡して
SubmitValidatedを呼び出します。SDK はログの SHA-256 ハッシュを送信するため、ログ本体は当面デバイス上に残ります。 - サーバーチェック。 サーバーはチケットを検証し、所要時間を自ら計測(チケット発行から送信まで)したうえで、あなたのルールを適用します。何かを書き込むのは、その後だけです。
チケットは全リージョンを通じて一度だけカウントされます。拒否されたランもチケットを消費するため、同じチケットで少しずつ低いスコアを再送してしきい値を探ることはできません。Seed はサーバーが選び、プレイヤーごとの1時間あたりのチケット数も制限される(デフォルト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 キーごとに1つのルールセットがあり、ダッシュボードでフォームまたは 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 |
| 120秒で 150,000 点(毎秒 1,250 点) | 422 SCORE_RATE_TOO_HIGH |
| 240秒で 190,000 点 | 受理、ただし sus(ソフト上限 180,000 を超過) |
| 同じチケットの2回目 | 422 TICKET_CONSUMED |
サーバーは決まった順序(ステージ、スコア、ステージルール、所要時間、毎秒スコア、獲得値、最後にソフトしきい値)でチェックし、最初の失敗が結果になります。スコアルールはボード付きのランにのみ適用されます。ボードなしのランでも、ステージと所要時間のルールはチェックされます。
ルールが固まったら、ボードを「検証済みの送信のみ」に切り替えます。以降、そのボードへの通常の SubmitScore は 403 VALIDATED_SUBMIT_REQUIRED で拒否され、他のボードは引き続き通常の送信を受け付けます。ルールセットが空でも、検証済み送信のみのボードでは、サーバーチケット、一度限りの使用、プレイヤーへの紐付け、1時間あたりの上限、保存されたログハッシュが保証されます。
ソフトしきい値と sus ラン
ハードルールは拒否します。ソフトしきい値はランに印を付けるだけで、最初はここから始めるべきです。1週間ソフト制限で運用すれば、数値をハードな拒否に変える前に、実際のプレイがどう見えるかがわかります。ランが sus になるのは、受理されたうえで少なくとも1つのソフトしきい値を超えた場合です。拒否されたランや技術的なエラーが sus になることはありません。送信結果にはシンプルな sus フラグが含まれ、どのしきい値に引っかかったかはサーバー側に留まります。
面白いのはその先です。今回のアップデートで、サーバーはすべての sus ランについて sus パッケージを保持します。これはプレイヤーのベストスコア、Top N、その後のスコア変動とは無関係です。荒唐無稽なランを1回投稿してから普通のランを投稿するチーターは、もはやより良いエントリの下に証拠を埋もれさせることはできません。
開始コンテキスト:ランが何から始まったか
リプレイが役に立つのは、まったく同じ開始条件を再現できる場合だけです。そこで 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 がチケットに保存されます。送信は開始時のルールバージョンに対してチェックされるため、ランの途中で上限を厳しくしても、そのランの判定が変わることはありません。ルール編集が探りの手段として悪用されないよう、変更は API キーごとに1時間あたり30回までに制限されています。
sus パッケージの中身
各パッケージはラン ID の下に保存され、レビュー担当者が必要とするものをすべてまとめています。開始コンテキスト、紐付けられたルール、結果、ラン前後のサーバー管理値、入力ログ(Top N ランと同様に SDK が自動アップロード)です。ダッシュボードのレビュータブで 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 を自前の決定論的シミュレーションに入力すれば、スコアを再現できるかどうかがはっきりします。リプレイ、判定、制裁はあなたの手に残ります。サーバーは印を付けてアーカイブするだけで、ゲームコードを実行することは決してありません。
クライアントが書き込めない通貨
チートの対象になるのはリーダーボードだけではありません。サーバー管理値を使えば、同じルールセットの中で gold や gems のようなカウンターを定義できます。それらを変更できるのは受理された検証済みランだけで、残高を設定するアプリエンドポイントも SDK メソッドも存在しません。上の JSON のルールは次のように読めます。
maxPerRun: 500:800 ゴールドを獲得したと主張するランはEARNED_ABOVE_MAXで拒否されます。minPerRun: -1000:1回のランで最大 1,000 ゴールドまで消費できます。残高を超える消費はINSUFFICIENT_BALANCEで失敗します。dailyCap: 5000:UTC 日ごとのプラスの加算は拒否されるのではなく、切り詰められます。今日すでに 4,800 を獲得したプレイヤーが 500 ゴールドのランを終えると、加算されるのは 200 です。
レスポンスには変更されたキーごとに requested と credited が示されるため、ゲームはコインを黙って失わせる代わりに「1日の上限に達しました」と表示できます。購入のルールは単純で、消費が完全に計上された場合(Unity と Unreal では IsFullyCredited)にのみアイテムを付与します。これはアップグレードショップをクライアント側のデフォルト値から切り離すのと同じ考え方です。デバイスは要求でき、付与できるのはサーバーだけです。
Cloud Save はこれまでどおり動作し、ミラーになります。受理されたランのたびにサーバーの値をオフライン表示用にセーブへコピーし、起動時には GetState でそのコピーを上書きし、セーブの値を残高として送り返すことは決してしないでください。
すべてのリーダーボードエントリにプレイヤープロフィール
今回のアップデートの後半は、数字ではなくボード上の人に焦点を当てています。Top、Around、Rank のすべてのリーダーボードエントリに、avatarId、frameId、最大3つの badges を含むプロフィールが付くようになりました。
{ "position": 1, "username": "Gravedigger", "score": 15000,
"profile": { "avatarId": "avatar.zombie_07", "frameId": "frame.gold", "badges": ["badge.supporter"] } }
サーバーはプロフィールを表示名と一緒にユーザー名キャッシュから読み取るため、トップリストでエントリごとの追加ルックアップは発生しません。プロフィールの変更は、遅くとも10分以内にすべてのサーバーに反映されます。
画像ではなく ID のカタログ
各プロジェクトはダッシュボードでコスメティックカタログを管理します。エントリには ID(例:avatar.zombie_07)、タイプ(avatar、frame、badge)、locked フラグがあります。無料のエントリはどのプレイヤーでも選べ、ロックされたエントリはアンロックしたプレイヤーだけが選べます。サーバーが保存するのは ID だけで、画像は保存しません。ゲームが各 ID を独自のスプライトに対応付けるので、アートはビルド内に残り、新しいフレームの追加はカタログに1行足すだけで済みます。
Godot でボードの1行を描画するコードは次のとおりです。
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 付きのギフトコード、またはダッシュボードでの手動操作によって行います。1人のプレイヤーが持てるアンロックは最大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 で、バッジが3つを超えると 400 INVALID_BADGES で失敗します。これにより、イベント報酬、Discord のプレゼント企画、サポーター特典を、専用のアンロックエンドポイントなしにそれぞれ1つのギフトコードで実現できます。旧 API の名残の1つは完全に廃止されました。スコア送信の metadata パラメータは一度も保存されたことがなく、現在は非推奨です。プレイヤーごとの情報はプロフィールに置いてください。
スコア上限は API キー単位に
一つのアカウントで複数のゲームや環境を運用するスタジオにとって、小さいながら実際の影響がある変更です。スコア上限はAPI キー単位で適用されるようになりました。各キーはプランが許す数までスコア行を保持でき(1行は1つのボード上の1人のプレイヤー)、これはそのキーの全ボードの合計です。キーを追加するごとに、それぞれが満額の枠を得ます。既存の行はいつでも更新できます。キーが満杯のときに 403 になりうるのは、プレイヤーがあるボードに初めて送るスコアだけです。
公平なリーダーボードのためのベストプラクティス
- ソフトから始めて、ハードにする。 1週間
softしきい値だけで運用し、レビュータブで sus ランを確認してから、正当なランの最高値に20から30パーセントの安全マージンを上乗せした数値でハードルールに変えます。 - ランが本当に始まった時点で
StartRunを呼ぶ。 時計はチケットから動き始めます。メニュー画面や40秒のロード画面の前で開始すると、minDurationSecondsは意味を失います。 - 入力ログは小さく、決定論的に保つ。 固定の入力フレームをデルタエンコーディングで記録します。証拠はログ1つあたり 32 KB が上限ですが、毎秒30入力フレーム、1フレーム1バイトの10分間のランなら余裕で収まります。
- 開始コンテキストですべてをバージョン管理する。
simulationVersionとcontentDigestがないと、先月のパッケージが今月のバランス調整に対してリプレイされ、誤った理由で失敗する可能性があります。 - セーブはソースではなく、ミラーとして扱う。 残高はサーバーから Cloud Save へ流れ、決して逆流しません。
できないこと
はっきり言っておきます。ルールの範囲内でもっともらしい値を報告する改造クライアントは、依然として受理されます。Validated Actions はチーターが得られる利益を制限し、疑わしいランをレビュー可能にしますが、チートを不可能にするわけではありません。サーバーは範囲、タイミング、一度限りの使用をチェックして証拠をアーカイブしますが、ゲームコードを実行したりリプレイしたりはせず、証拠に基づく自動判定もまだありません。Validated Actions はクラウド専用で、セルフホストの simpleServer では SDK が NOT_SUPPORTED を返します。すべてのランにはサインイン済みのプレイヤーが必要で、リーダーボード上のランには表示名も必要です。
プランごとの容量
この記事で紹介した機能はすべて、FREE を含むすべてのプランで利用できます。増えるのは容量だけです。
| FREE | BASIC | PRO | ENTERPRISE | |
|---|---|---|---|---|
| アカウントごと、UTC 1時間あたりの検証済みラン | 300 | 3,000 | 20,000 | 200,000 |
| API キーごとのサーバー管理値 | 8 | 16 | 32 | 64 |
| Top N ランの証拠スロット | 50 | 500 | 2,500 | 25,000 |
| 保存される sus パッケージ | 10 | 100 | 1,000 | 10,000 |
| sus パッケージの保持期間 | 14日 | 30日 | 90日 | 180日 |
| API キーごとのコスメティックカタログ | 50 | 200 | 500 | 1,000 |
| API キーごとのスコア行 | 5,000 | 25,000 | 200,000 | 2,500,000 |
sus パッケージの上限に達しても、新しい sus ランは受理され sus として報告されます。アーカイブされないだけです。保存済みのパッケージが新しいものに押し出されることはありません。
はじめよう
Unity、Godot、Unreal 向けの最新 SDK に更新し、ボードを1つ選んで、ソフトしきい値だけのルールセットを追加してください。StartRun の呼び出しに ValidatedRunContext を加え、すでにゲームに含まれているアバターとフレームでコスメティックカタログを埋めましょう。Validated Actions 機能ページとリーダーボード機能ページではルールと上限をまとめており、クイックスタートでは3つのエンジンすべてを順に解説しています。次のリーダーボードを、公平かつ個性的なものにする準備はできましたか?horizOn を無料で試すか、API ドキュメントをご覧ください。