自作のゲームにランキングを付けました。ブラウザで動くゲームから送られてくる値は、いくらでも書き換えられます。 それを前提に、どこまでやるかを決めた記録です。
5件/分
同じ相手からの上限
1時間なら60件
12字
名前の長さ
ASCII のみ・ゲーム内は3文字
50点
送る下限
自己ベスト更新時のみ
71点
通しの確認で送った記録
自動操作で実際に遊んだ
作り方の要:弾かずに「保存して無効にする」
最初に決めたのはここです。ありえない値が来たとき、エラーを返して捨てるのではなく、保存したうえで無効の印を付けます。
弾いて捨てる
保存して無効にする(採用)
返すのは 200 のままで、accepted: false と理由を返します。ゲーム側は「ランキングに載らなかった」とだけ出せば済みます。
$ curl -X POST … -d '{"name":"CHEAT","score":999999,"playMs":5000,
"detail":{"heightM":9000,"coins":500}}'
{"ok":true,"accepted":false,"reason":"高さ 9000m は 5秒では届かない"}「ありえない値」は、ゲームの数値から計算する
線引きは勘ではなく、そのゲームの数値から出します。 このゲームは壁を跳んで登るので、登る速さの上限が決まっています。跳び続けても毎秒 5.5m を超えられません。
const seconds = input.playMs / 1000
if (height > seconds * 5.5 + 30) return `高さ ${height}m は ${Math.round(seconds)}秒では届かない`
if (coins > seconds * 2 + 10) return `コイン ${coins}枚は ${Math.round(seconds)}秒では取れない`
if (input.score > height + coins * 150 + 50) return `スコア ${input.score} は 高さ${height}m・コイン${coins}枚では出ない`
余裕(+30 や +10)を足しているのは、正当な記録を落とさないためです。ここを攻めても不正は止まりません(上限ぎりぎりの値を送られたら通ります)。目的は「桁違いの値を表から外す」ことだけです。
⚠️ ゲームの数値を変えたら、この式も見直す
遊びの数値(跳ぶ速さ・コインの点数)を変えると、上限も変わります。式はゲームごとに1か所へ置き、ゲーム側の数値と対応していることをコメントに書いておくのが現実的でした。
残りの3つ:連投・送信元・名前
- 1
連投を止める
同じ相手から 1分5件・1時間60件 まで。超えたら 429 を返す - 2
送信元を確かめる
他サイトからの POST はOriginで拒否する。ゲームは同じドメインから配信しているので、これで困らない - 3
名前を絞る
ASCII の英数字と_ . -と空白だけ・12字まで。HTML も URL も入らない。NG ワードは既定の名前に置き換える
IP アドレスは保存していません。連投の判定に使うのは HMAC でハッシュにした値だけです。
送るのは「自己ベストを更新したときだけ」
サーバー側の対策より、こちらのほうが表の見た目に効きました。
プレイのたびに送ると、数秒で終わった記録まで表に並びます。 このゲームは1プレイが30〜90秒と短く、失敗も多いので、そのままだと大半が低い記録になります。
そこで、送信は「自己ベストを更新して、かつ50点以上」のときだけにしました。結果として、送信の回数も1プレイごとではなく、上達したときだけになります。
// 🔴 送るのは自己ベストを更新したときだけ(短い記録で表を埋めない)
if (_isNewBest && Session.Score >= 50) SendScore();
エディターからは送らない
作っている最中に何度も遊ぶので、エディターから本番の表に試し撃ちが残ると面倒です。送信のコードは #if UNITY_EDITOR で切り、エディターではログに出すだけにしました。
EventSystem の無いシーンで、名前を入力させる
このプロジェクトのシーンには EventSystem がありません(入力は自分で読んでいるため、外してあります)。つまり uGUI のボタンは押せません。
名前の入力をどうするかで、2つ決めました。
- 3文字を
+/-で選ぶ形にする。 WebGL のスマホでは文字入力が期待どおりに出ず、画面に日本語フォントも積んでいないためです - 押した場所が、その文字の枠に入っているかを自分で判定する
bool Hit(Text text, Vector2 screenPoint)
{
if (text == null || !text.gameObject.activeInHierarchy) return false;
return RectTransformUtility.RectangleContainsScreenPoint(text.rectTransform, screenPoint, _camera);
}
押した座標は、新しい Input System から取ります(Input.mousePosition は使えません)。タイトルでのタップは、先に名前のボタンを見て、当たっていなければゲームを始めるという順番にしました。
うまくいかない
- uGUI の Button を置く(EventSystem が無いので押せない)
- タップでいきなりゲームが始まり、名前を変えられない
- 文字入力を出す(スマホの WebGL で出ない)
この形にした
- 文字の枠に当たっているかを自分で判定する
- 名前のボタンに当たったときはゲームを始めない
- 3文字を選ぶだけ。英数字なので追加のフォントも要らない
通しの確認は、自動で遊ばせて行った
人が遊んで確かめると、失敗したときに何が起きたか分かりません。自動操作のブラウザで実際に遊ばせて、送信まで通ることを確かめました。
{ "method": "GET", "status": 200, "body": "{\"scores\":[]}" }
{ "method": "POST", "status": 200, "body": "{\"ok\":true,\"accepted\":true,\"rank\":1}" }
{ "method": "GET", "status": 200, "body": "…\"name\":\"AAA\",\"score\":71…" }画面には RANKED #1 が出て、紹介ページの一覧にも並びました。確認に使った記録は、そのあと消しています。

まとめ
- ブラウザゲームのランキングで不正は防げない。ありえない値を保存して無効にする、連投を止める、ページに「参考記録」と書く
- 「ありえない」の線引きはゲームの数値から計算する。余裕を持たせて、正当な記録を落とさない
- 送るのは自己ベスト更新時だけ。表の中身と送信の回数が、これだけで変わる
- 作っている最中の試し撃ちが本番に残らないよう、エディターからは送らない
- EventSystem が無いなら、押した座標が枠に入っているかを自分で判定すればボタンは作れる