Cloudflare Workersで創るP2Pゲーム記:チートを断つEloレーティング実装とアトミック検証 【CFW P2P心理戦ゲーム開発記】 #6

前回(第5回)は、ゲーム専用の戦績データテーブル buckshot_stats の作成と、拡張性を重視した「Get or Create」のロジックを実装しました。
ゲーム固有データ(戦績・レート)の実装編【CFW P2P心理戦ゲーム開発記】 #5

第6回となる今回は、対戦ゲームの醍醐味であり命とも言える「レーティング(Elo Rating)の具体的な計算・更新ロジック」の実装に突入します!

ただ数式をコードに落とし込むだけでなく、P2Pゲームにおいて極めて重要な「チート対策の設計思想」を交えながら、実際に2つのアカウントのレートが不整合なく同時に変動するかを実装・検証していきます。

実装全体像

開発フロー、データベース周りを作る

今回の実装個所は D1のランキング周りです


1. P2Pゲームにおけるチート対策の設計思想

今回開発しているゲームは、プレイヤー同士が直接通信を行う「P2P(Peer-to-Peer)」形式を採用します。メインサーバーがゲームの盤面(実弾と空砲の残り数やアイテムの使用など)を常時監視しないため、設計を誤ると簡単にチートが横行してしまいます。

最大のチートリスクは、「ブラウザ側のJavaScript(クライアント)が嘘の計算結果を報告してくること」です。

ピアツーピアでは、ゲームの捜査情報はサーバ側で確認できない

チート対策という面でピアツーピアとサーバクライアント型を比べると、ゲームの細かい操作とかの通信を各端末ごとにやらせる分通信コストを減らせるというピアツーピアのメリットが逆に、チート対策の困難さというデメリットになります。

✕ 絶対にやってはいけないNG設計

フロントエンド側で「俺のレートが上がって1600になったよ!」と計算し、バックエンドへ {"newRating": 1600} と送信する。これでは、開発者ツール(F12)で数値を書き換えられたら一発で終わりです。

ゲーム内の操作がわからない(設計によっては確認できたりしますが、純粋なピアツーピアでは困難)ため、チートかどうか検証できません。

◯ 今回採用する鉄壁のセキュア設計

フロントエンドからは「誰と戦って、自分が勝ったか負けたか」という最低限の『事実』だけを報告させ、具体的なEloレートの計算とDBへの書き込みは100%バックエンド(Cloudflare Workers)側で完結させます。

将来の完全版では、対戦した「双方のプレイヤー」からそれぞれ勝敗レポートを投げさせてサーバー側で答え合わせ(片方が『俺の勝ち』、もう片方も『俺の勝ち』と言ってきたら不正とみなして弾く)を行う形態を目指します。今回はその基盤となる「サーバーサイドでの厳密なレート計算とアトミックな更新」を実装します。


2. 徹底的なゲームロジックの分離と実装

将来、別のゲーム(テトリスやパズルなど)を追加したときにシステムが競合しないよう、ファイル名や関数名には明示的に buckshot(バックショット)の命名を散りばめて実装していきます。

2.1 Eloレーティングの数学的ロジック(src/utils/buckshotElo.ts)

チェスなどでも使われるEloレーティングの数式を純粋関数として定義します。
自分(\(A\))と相手(\(B\))のレートから、まず自分の勝率期待値 \(E_A\) を計算します。

\[E_A = \frac{1}{1 + 10^{(R_B – R_A) / 400}}\]

この期待値を元に、実際の勝敗(\(S_A\): 勝ち=1, 負け=0)を反映し、変動係数 \(K\)(今回は32を採用)を掛けて新しいレートを四捨五入(整数)で導き出します。

const BUCKSHOT_K_FACTOR = 32;

export function calculateBuckshotElo(myRating: number, opponentRating: number, isWin: boolean) {
  const myExpected = 1 / (1 + Math.pow(10, (opponentRating - myRating) / 400));
  const myActual = isWin ? 1 : 0;

  const nextMyRating = Math.round(myRating + BUCKSHOT_K_FACTOR * (myActual - myExpected));
  const nextOpponentRating = Math.round(opponentRating + BUCKSHOT_K_FACTOR * ( (1 - myActual) - (1 - myExpected) ));

  return { nextMyRating, nextOpponentRating };
}

2.2 勝敗反映ハンドラーの実装(src/handlers/buckshotHandler.ts)

フロントから送られてきた勝敗を元に、D1の batch() を用いて、「自分と相手のデータを1つのトランザクション内で同時に、安全に書き換える」 処理を構築します。片方の書き込みだけが成功してもう片方が失敗するようなデータの不整合(幽霊化)を完全に防ぎます。

// 主要ロジックの抜粋。POST /api/buckshot/match-result で呼び出す
const [myStats, opponentStats] = await Promise.all([
  repo.getOrCreateStats(myUserId),
  repo.getOrCreateStats(opponentUserId)
]);

const { nextMyRating, nextOpponentRating } = calculateBuckshotElo(
  myStats.elo_rating, opponentStats.elo_rating, isMyWin
);

// D1の batch を用いてアトミックに同時更新
await env.DB.batch([
  env.DB.prepare(`UPDATE buckshot_stats SET elo_rating = ?, wins = wins + ?, losses = losses + ?, winning_streak = ? WHERE user_id = ?`)
    .bind(nextMyRating, isMyWin ? 1 : 0, isMyWin ? 0 : 1, isMyWin ? myStats.winning_streak + 1 : 0, myUserId),
  env.DB.prepare(`UPDATE buckshot_stats SET elo_rating = ?, wins = wins + ?, losses = losses + ?, winning_streak = ? WHERE user_id = ?`)
    .bind(nextOpponentRating, isMyWin ? 0 : 1, isMyWin ? 1 : 0, isMyWin ? 0 : opponentStats.winning_streak + 1, opponentUserId)
]);

3. モック対戦を行いスコアが正常に計算できるか確認

対戦相手がいないと検証できないので、ゲームデータだけのダミーを作成しました

検証のため新規でdummyユーザを登録した

前回と同様、ゲームテーブルを設定し2ユーザの準備が完了しました。

検証のため新規でdummyユーザを登録し,ゲームテーブル等を作成することで検証相手を作った

ダミーの情報チェック

4.検証

検証用のエンドポイントを作り、変数として対戦ユーザや勝敗データを渡して更新できるか確認してみました。

https://(略)/mock-match
?myUserId=<自身のuid>//
&opponentUserId=<ダミーのuid>
&isMyWin=true

私が勝利した場合のスコア変動を検証してみることができます。今回は両方ともレートが初期値なので 1500 vs 1500なので計算式に従えば16と計算されていれば成功です。

対戦結果を送り、計算できたか確認した例

対戦結果:レート計算が完了

対戦後のスコアをそれぞれ確認してみるとちゃんと16ずつ更新されていることが確認できました。

対戦後のスコア変動

対戦後のスコア変動

5. まとめ

今回は、対戦ゲームのバックエンドの核となる「チート対策を施したEloレーティングの計算とD1へのトランザクション実装」を完了しました。

Windows×Wrangler固有のデータベース参照の癖や、強固な外部キー制約に阻まれる場面もありましたが、バックエンド開発ならではの最高に面白い経験でした。

これで、ゲームを運用するための「ユーザーデータベースの基礎フェーズ(フェーズ1)」は完全に完成です!

関連記事

CloudFlare Workersセットアップ
Cloudflare Workersの始め方:Wranglerによるローカル環境構築と世界公開の手順
【第2回】データベースを作る
D1データベース作成とテスト環境構築の手順 【CFW P2P心理戦ゲーム開発記】 #2
【第3回】データベーススキーマ設計
D1データベーススキーマの設計:リレーショナルデータベースとは? 【CFW P2P心理戦ゲーム開発記】 #3
【第4回】ユーザデータベース実装
D1データベース実装:ユーザデータ管理 【CFW P2P心理戦ゲーム開発記】 #4

PR

もう一つの選択肢:VPSで自由なゲームサーバー構築

本連載ではCloudflare Workersを活用したP2P実装を進めていますが、もし環境の制約がなく、
「もっと使い慣れた言語で自由にゲームサーバーを立てたい」
「WebSocketなどの常駐プロセスをガッツリ回したい」

という場合は、VPS上に独自のシグナリングサーバーを構築するのも強力な正攻法です。

「テキストを読んで知識として知っていること」と、「実際に手元でLinuxサーバー(Ubuntuなど)を叩いて構築した経験」とでは、バックエンドへの理解の深さに大きな差が生まれます。

最近の海外サービスは「最初は無料・格安で普及させ、定着したタイミングで一気に値上げや制限強化に踏み切る」という戦略が多く、個人開発での新規参入や継続運用のハードルが高くなりがちです。

その点、日本の老舗である さくらのインターネット(さくらのVPS) は価格面でも運用の面でも圧倒的な安定感があり、個人的にとても信頼して推しています。

「海外サービスの急な仕様変更に振り回されたくない」「自分のインフラ拠点を国内に1つ持っておきたい」という方は、ぜひ一度触ってみてください!

次回予告

バックエンド側の強固な土台がカンペキに仕上がったので、次回からはいよいよメインとなる「フロントエンド・ゲーム画面およびWebRTCリアルタイム通信」の実装フェーズへと駒を進めていきます!

プレイヤー同士がサーバーを介さずにブラウザ間で直接殴り合う、P2Pゲーム開発の最も重要な部分をシンプルな構成から試していこうと思います。

次回:【第7回】Cloudflare Workersで創るP2Pゲーム記:WebRTC開発ロードマップと伝言板シグナリング構想

ここまで読んでいただきありがとうございます。 では、次の記事で。 lumenHero