公開日
2048
ランキング付きの 2048。スコアは信用せず、サーバーが手の列を再生して計算する
概要
ブラウザで遊べる 2048。スコアを登録するとランキングに載る。このサイトの中のミニゲームで、フロントエンドは TypeScript(Next.js)、API は Java(Spring Boot)で書いた。
ランキングのあるゲームでは、ブラウザが送ってくるスコアをそのまま信じると、コンソールや API の直叩きで好きな値を登録できてしまう。そこで、ブラウザからは押した方向の並び(手の列)だけを受け取り、サーバーが同じ手順でゲームを再生してスコアを計算する形にした。
なぜ作ったか
ゲームそのものが目的ではなく、「利用者の入力を信用しない API」を、小さな題材で一通り作って見せるため。書き込みのある API は、入力の検査、不正の防止、個人情報の扱い、費用の抑え方を避けて通れない。2048 は、ルールが短く、再生で結果が決まるので、この題材に向いていた。
技術
- フロントエンド:TypeScript、Next.js(静的に書き出す)、React
- API:Java 25、Spring Boot。AWS Lambda(SnapStart)で動かす。管理用の操作は、Spring を起動しない別の Lambda にした
- データベース:Aurora DSQL
- 配信と入口:CloudFront、API Gateway(HTTP API)。毎日の後片付けは EventBridge Scheduler から呼ぶ。インフラは AWS CDK で書いた
- 監視:CloudWatch のアラームとダッシュボード。通知は SNS
- テスト:TypeScript は
node:test、Java は Spock と Testcontainers、インフラは Jest
工夫とつまずき
スコアを信用しない:サーバーでの再生
ゲームを始めるとき、サーバーが乱数の種とセッションを渡す。登録のときにブラウザが送るのは、セッション、プレイヤー名、手の列(U D L R の文字列)だけ。スコアなど、ほかの項目が入っていれば、値にかかわらず登録を断る。サーバーは種と手の列から開始盤面を作り直し、手を先頭から順に適用して、スコア、最大のタイル、手数を自分で計算する。
手の列は、再生の前に長さ(1 手以上、50,000 手まで。0 手は遊んでいない登録として断る)と文字(U D L R だけ)を確かめ、外れていれば再生を始めずに断る。再生の途中で盤面が変わらない手(終局の後の手を含む)を見つけたら、そこで打ち切って登録を断る。
コンソールで盤面やスコアを書き換えても、サーバーは手の列しか見ないので、反映されない。API を直接叩いて好きなスコアを送ろうとしても、スコアを受け取る口が無い。
TypeScript と Java で同じ結果になる乱数と、共通の検査用データ
ブラウザ(TypeScript)でゲームを動かし、サーバー(Java)で再生するので、2 つの実装が 1 手も違わない結果を返す必要がある。乱数は mulberry32(状態は 32 ビットの符号なし整数)を、ライブラリに頼らず自分で書いた。タイルの出現は、乱数を 2 回使う(空きマスの何番目か、2 か 4 か)と仕様で固定した。
2 つの実装を合わせるために、ルールの仕様書、検査用のデータ(JSON)、Python で書いた参照実装を、リポジトリの testdata/game-2048/ に置き、両方のテストが同じデータを読む。データは参照実装が作り、作り直しても同じ結果になる。データは、乱数の出力、1 列を寄せる計算、1 手、開始盤面、再生の成功と失敗、ゲームオーバーまでの再生を含む。1 列を寄せる計算は、仕様から手で求めた表と、参照実装の出力を突き合わせた。
つまずいた点は、符号の扱い。JavaScript は Math.imul と >>> 0 で 32 ビットの符号なしの値を作るが、Java の int は符号付きで、そのままだと剰余の結果が変わる。Java 側は、乱数の値を long に直して符号なしで剰余を取る形にした。
防いだ不正と、防げない不正
設計の段階で、不正を表にして、止め方と応答を決めた。
- 同じセッションで 2 回登録する:使用済みにする更新を、未使用を条件にした 1 回の UPDATE にして、2 回目を断る(409)。同時に 2 本届いたときも、後のほうは 409 になる。Aurora DSQL は楽観的な同時実行制御で、後から確定するほうが失敗するので、失敗したらセッションを読み直して、使用済みなら 409 にする
- 古いセッションの使い回し:有効期限(24 時間)を過ぎたら断る(410)
- 人間には無理な速さで打つ:サーバーの時刻で、開始から登録までの時間が「手数 × 1 手の下限」に足りなければ断る(400)。下限の初期値は 50 ミリ秒で、実測して調整する
- セッションの大量作成、登録の連打:接続元ごとの回数の上限(直近 1 時間で開始 30 回、登録 10 回。429)と、ゲームの API だけにかけたルートの throttle(毎秒 5 回、バースト 10 回。初期値)。記事の API とは別のルートにして、ゲームへの連打が毎日の再ビルドを巻き込まないようにした
- 巨大なリクエスト:手の列は 50,000 手まで、本文は 64KB まで(413)。本文の長さの申告(Content-Length)は信用せず、上限を 1 バイト超えたところで読むのをやめる
- API Gateway のアドレスを直接叩く:CloudFront が付ける検証用のヘッダーが無ければ断る(403。既存の仕組み)
防げないものもある。乱数の種はブラウザに渡すので、この先に出るタイルは読める。自動でゲームを解くプログラムや、画面を操作するツールに遊ばせれば、正しい手の列ができあがる。サーバーから見ると、人が遊んだ手の列と区別がつかず、再生しても見分けられない。時間の下限、回数の上限、管理者による非表示で抑えるが、完全には防げないものとして受け入れた。ランキングに載るのが人の遊んだスコアだけだとは、保証できない。
却下した案もある。1 手ごとにサーバーがタイルを出す方式なら先読みは防げるが、1 ゲームで数百から数千回の API を呼ぶことになり、遅さと費用が見合わない。
管理用の操作と保持期間:HTTP の入口を作らない
不正なスコアを消す操作は、HTTP の API にしなかった。管理用の Lambda を 1 つ足し、AWS の IAM で呼び出しの権限(lambda:InvokeFunction)を持つ主体だけが、直接呼べるようにした。Function URL も API Gateway のルートも作らないので、外から呼べる経路がそもそも無い。HTTP の API にすると、既存の「CloudFront を通らないリクエストを断る」検査に例外を作る必要があり、その判定を誤ると穴になる。
操作は 4 つ:上位のスコアの一覧(手数とプレイ時間つき)、論理削除(ランキングから外し、理由を残す)、その取り消し、保持期間を過ぎたデータの後片付け。データベースには、この Lambda 専用のロールで接続し、ゲームのテーブルに必要な権限(スコアの読み取りと更新、セッションの読み取りと削除)だけを付けた。スコアを物理的に消す権限は無い。ログに出すのは、操作の種類、スコアの ID、件数だけ。
保持期間は、セッションと接続元のハッシュが 7 日、スコアはランキングを続ける間。EventBridge Scheduler が毎日(日本時間の 4 時 30 分)この Lambda を呼び、開始から 7 日を過ぎたセッションを消し、登録から 7 日を過ぎたスコアの接続元のハッシュを決まった値で上書きする(列は NOT NULL のため)。Aurora DSQL は 1 トランザクションで変えられる行の数に上限があるので、件数を区切って繰り返す。
セキュリティ
- 接続元の IP:
X-Forwarded-Forの先頭は利用者が書けるので使わない。CloudFront が付けるCloudFront-Viewer-Addressを使う。/api/*のオリジンリクエストポリシーでは、閲覧者のリクエストのヘッダーのうち届けるのをContent-Typeだけにし、CloudFront-Viewer-Addressを足した。X-Forwarded-Forなど CloudFront が付けるヘッダーは、このポリシーにかかわらず届くが、読まない。IP を含むヘッダーは、ハッシュの計算にだけ使い、保存もログへの出力もしない。ヘッダーが無い、値が 2 つある、形が正しくないときは、開始と登録を断る(400) - 接続元のハッシュ:UTC の日付と接続元(IPv4 はアドレス、IPv6 は先頭 64 ビット)から、ソルト付きの HMAC-SHA256 で作る。日付が変わると別の値になるので、日をまたいだ追跡には使えない。IPv6 を先頭 64 ビットにするのは、1 つの契約に /64 が割り当てられることが多く、下位のビットを変えて回数の上限を逃れられないようにするため。生の IP は保存しない。ソルトは SSM のパラメータ(SecureString)に置き、ゲームの機能フラグが有効なときだけ起動時に読む。短すぎる値なら起動を失敗させる
- 回数の上限は直近 1 時間で数える。ハッシュは日付で変わるので、1 時間の窓が日付をまたぐときは、前の日のハッシュの分も数える
- プレイヤー名:Unicode の NFKC で正規化し、制御文字、幅の無い文字、文字の向きを変える文字、私用領域の文字、文字に重ねる結合記号(異体字セレクタを含む)などを拒否する。長さは 1〜16 文字。NG ワードは、大文字と小文字、全角と半角、カタカナとひらがなの違いをそろえ、空白を除いた形でも照合する。NG ワードの一覧はリポジトリに置かず、SSM のパラメータ(SecureString)に置いて、外から見えないようにした。ソルトと同じく、ゲームの機能フラグが有効なときだけ起動時に読み、一覧が無ければ起動を失敗させる。表示は React のエスケープに任せ、HTML をそのまま差し込む書き方は使わない
- 名前の変更用のトークン:256 ビットの乱数。データベースに置くのはハッシュだけで、比較は定数時間で行う。返すのは登録の応答の 1 回だけで、ブラウザには保存せず、ページのメモリにだけ置く。名前を変えられるのは、登録直後の結果画面で、登録から 1 時間までに限った(サーバーでも期間を確かめる)
- Cookie は使わない。トークンは
Authorizationヘッダーでなく本文で送る。CloudFront の独自のオリジンリクエストポリシーにAuthorizationを入れる設定を、使っている CDK のバージョンが受け付けず、検査を迂回するとデプロイが落ちるおそれがあったため - ログに IP、トークン、プレイヤー名、手の列を出さない。応答や本文を表す型は、文字列にしても値が出ないようにした。ゲームの API を流したログにそれらが出ないことを、テストで確かめている
- 公開前の保護:機能フラグで、本番では API が 404 を返し、ゲームのページはビルドから外す。作りかけの書き込み API を、外から叩ける状態にしないため
費用を抑えた監視
個人のサイトなので、監視の費用が運用の費用を超えないようにした。WAF(Web ACL に月 5 ドルほどかかる)と、外形監視の canary(月 10 ドル以上)は、見積もりの段階で使わないことにした。大量のリクエストは、アラームで気づいて機能フラグで止める。
SLO の初期値は、可用性 99.5%(月)と、p95 の遅延 800ms。コールドスタートも含めて測る。ゲームの API のルートは 1 本なので、遅延と 5xx の率は API 全体(API Gateway のステージ)の値で見る。エンドポイントごとには、Lambda のログからメトリクスフィルターで、スコアの登録とランキングの取得の件数と 5xx を数える。ログに所要時間を出していないので、エンドポイントごとの遅延は取っていない。
アラームは 8 本。
- 可用性(1 時間):1 時間の 5xx の率が 7.2% を超えた(月の誤りの予算 0.5% を、14.4 倍の速さで使う率)。リクエストが 1 時間に 20 件未満のときは、1 件の 5xx で率が跳ねるので率を使わず、5xx が 5 件以上なら鳴らす(件数が少ないだけで、すべてが 5xx でも鳴らない、を防ぐ)
- 可用性(6 時間):6 時間の 5xx の率が 3% を超えた(予算を 6 倍の速さで使う率)。1 時間のアラームに届かない、ゆるい悪化を拾う。件数が少ないとき(6 時間に 120 件未満)の扱いは 1 時間のものと同じ
- 遅延:p95 が 800ms を超える時間が、3 時間続いた
- 4xx の急増:5 分で 100 件以上。HTTP API には 429 だけを数えるメトリクスが無いので、throttle の 429 も 4xx に含めて見る
- リクエストの件数:API 全体で 1 時間に 3,000 件以上(想定の月 5 万回の約 43 倍の速さ)。throttle の内に収まる連打は 4xx に出ないことがあるので、件数そのもので気づき、機能フラグで止める
- API の Lambda のエラーと throttle(それぞれ 1 本)
- 管理用の Lambda のエラー(毎日の後片付けの失敗に気づくため)。後片付けの呼び出しが失敗したときのやり直しは 2 回、1 時間までに絞り、続きは次の日の呼び出しに任せる。やり直しの後も失敗したときは、Lambda がその呼び出しの記録を、アラームと同じ通知用のトピックに送る(アラームは増やさない)
CloudWatch の無料枠は、アラームの本数でなくアラームメトリクスの数(10 まで)で数える。可用性の 2 本は、リクエストの件数と 5xx の 2 つから式で率を出すので 2 つずつ数え、8 本で 10 を使い切る。そのため、予算を超えるが 3% に届かない悪化は拾えない(3 日の窓のアラームを足すと 12 になり、無料枠を超える)。
通知は SNS のトピック 1 つにまとめた。ダッシュボード 1 つと合わせて CloudWatch の無料枠に収め、月額は 1.25 ドル以下の見積もり(2026 年 10 月 8 日の AWS の価格表。幅は DSQL の使用量の仮定による)。
数字
検査用データは testdata/game-2048/vectors.json のケースの数。テストは、2026 年 10 月 9 日に全件を実行したときに各ツール(Maven、node:test、Jest)が報告した件数で、ゲーム以外(記事の API、サイトのページ、インフラ全体)のテストも含む。
- 検査用データ:87 ケース(乱数 5、1 列を寄せる計算 14、1 手 12、開始盤面 5、再生の成功 7、ゲームオーバーまでの再生 3、再生の失敗 41)。TypeScript と Java が同じファイルを読む
- テスト:backend 1,159 件(うち
gameパッケージ 856 件。Testcontainers の PostgreSQL を使うものを含む)、frontend 459 件(うちlib/game-2048257 件)、infra 126 件。失敗と飛ばしたものは 0 件 - 手の列の上限:50,000 手
リンク
- ゲームのページ:公開前
- リポジトリ:準備中(このサイトのリポジトリ。今は非公開)
- 関連記事:準備中