ゲーム固有データ(戦績・レート)の実装編【CFW P2P心理戦ゲーム開発記】 #5

前回(第4回)は、ユーザーの「身元(users)」と「ログイン方法(user_auths)」の実装をクリーンに行いました。
前回:D1データベース実装:ユーザデータ管理 【CFW P2P心理戦ゲーム開発記】 #4

第5回となる今回は、いよいよ今回のメインゲームであるバックショット専用のデータテーブル「buckshot_stats」の実装に突入します!

ここで重要になるのが、「将来、新しいゲーム(例:メモリマージ)を追加したときに、既存のシステムを1ミリも壊さずに拡張できるか?」という思想です。リレーショナルデータベースにおける「1対1関係」の美しい扱い方を見ていきましょう。

ゲームデータベースの設計。拡張性を考え、ユーザのUUIDを主キーとしてリレーショナルデータベースを設計する

1. マイグレーションファイルの作成

第4回のクリーンな運用手順に則り、新しくマイグレーションファイル(テーブル作成SQL)を作成します。 今回は2つ目の変更なので、先頭の連番は 0002_ になります。

# node環境なのでnpxをつけて以下を実行すると勝手に連番でsqlファイルを作成してくれます
# migrations/0002_create_buckshot_stats.sql が作られる。
npx wrangler d1 migrations create DB create_buckshot_stats

中身は、以下のような感じで第3回に設計したゲーム情報を保持するようにします。

CREATE TABLE IF NOT EXISTS buckshot_stats (
    user_id TEXT PRIMARY KEY,
    elo_rating INTEGER NOT NULL DEFAULT 1500,  -- レーティング(初期値1500)
    wins INTEGER NOT NULL DEFAULT 0,          -- 勝ち数
    losses INTEGER NOT NULL DEFAULT 0,        -- 負け数
    winning_streak INTEGER NOT NULL DEFAULT 0, -- 連勝数
    
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
設計のポイント:なぜ user_id が PRIMARY KEY なのか?

通常、テーブルを作るときは id INTEGER PRIMARY KEY AUTOINCREMENT のような通し番号の列を作りたくなりますが、今回はそれをしていません。
users(人間)に対して buckshot_stats(戦績)は「1対1」の関係だからです。「1人のユーザーは、1つしか戦績を持たない」というルールであれば、users テーブルの id をそのまま主キー(PRIMARY KEY)にしてしまうのが最もシンプルでデータが軽くなります。

ユーザとゲームデータの対応

2. ゲームデータは「いつ」作るべきか?

ここで一つ、設計上の大きな分岐点があります。 「新しくアカウントを作った瞬間、この戦績レコードも一緒に作るべきか?」 という問題です。

結論から言うと、マルチゲーム展開(認証やユーザは統一でゲーム追加ごとに専用テーブルを作る)のが最強です。

もし、アカウント作成時に buckshot_stats も同時に作ってしまうと、将来ゲームが100個に増えたとき、ユーザーが登録するたびに「遊ぶかどうかもわからない100個のゲームの空テーブル」へ一斉にインサートが走ることになり、システムがめちゃくちゃ重くなってしまいます。

今回は、「戦績を取得しようとした時、データがまだ無ければその場で初期値(1500)のレコードを滑り込ませる」というスマートなロジックで実装します。


3. コードへの落とし込み

設計に沿ってファイルを実装・追記していきます。

3.1 型定義の追加 (src/types/database.d.ts)

database.d.ts(タイプスクリプト側での構造体定義)に、新しくバックショットの型定義を追記します。

export interface BuckshotStats {
  user_id: string;
  elo_rating: number;
  wins: number;
  losses: number;
  winning_streak: number;
}

3.2 バックショット専用リポジトリの作成 (src/repositories/buckshotRepository.ts)

生SQLを閉じ込める場所です。「取得(無ければ作成)」と「勝敗の更新」の2つの関数を用意します。

import { D1Database } from '@cloudflare/workers-types';
import { BuckshotStats } from '../types/database';

export class BuckshotRepository {
  constructor(private db: D1Database) {}

  // 戦績を取得する
  async getOrCreateStats(userId: string): Promise<BuckshotStats> {
    // まずデータを検索
    let stats = await this.db
      .prepare('SELECT * FROM buckshot_stats WHERE user_id = ?')
      .bind(userId)
      .first<BuckshotStats>();

    // もし無ければ、初期値でレコードをインサート
    if (!stats) {
      await this.db
        .prepare('INSERT OR IGNORE INTO buckshot_stats (user_id) VALUES (?)')
        .bind(userId)
        .run();

      // インサートした初期データを再取得
      stats = await this.db
        .prepare('SELECT * FROM buckshot_stats WHERE user_id = ?')
        .bind(userId)
        .first<BuckshotStats>();
    }

    return stats!;
  }

  // 試合結果を反映する(勝敗数、連勝数、レートの変動)
  async updateMatchResult(userId: string, isWin: boolean, newRating: number): Promise<void> {
    if (isWin) {
      await this.db
        .prepare(`
          UPDATE buckshot_stats 
          SET elo_rating = ?, wins = wins + 1, winning_streak = winning_streak + 1 
          WHERE user_id = ?
        `)
        .bind(newRating, userId)
        .run();
    } else {
      await this.db
        .prepare(`
          UPDATE buckshot_stats 
          SET elo_rating = ?, losses = losses + 1, winning_streak = 0 
          WHERE user_id = ?
        `)
        .bind(newRating, userId)
        .run();
    }
  }
}

// 必要に応じて追加予定ですが、基本操作はこんな感じでいいと思います

3.3 ハンドラーの作成 (src/handlers/buckshotHandler.ts)

APIの司令塔です。戦績の取得エンドポイントを実装します。(文字コードはutf-8で統一しています)

import { BuckshotRepository } from '../repositories/buckshotRepository';

export async function handleGetStats(request: Request, env: any): Promise<Response> {
  const url = new URL(request.url);
  const userId = url.searchParams.get('userId');

  if (!userId) {
    return new Response(JSON.stringify({ success: false, error: 'Missing userId' }), { status: 400 });
  }

  const repo = new BuckshotRepository(env.DB);
  const stats = await repo.getOrCreateStats(userId);

  return new Response(JSON.stringify({ success: true, stats }), {
    headers: { 'Content-Type': 'application/json; charset=utf-8' }
  });
}

4. エントリーポイント(index.ts)への接続

仕上げに、新設したエンドポイント(GET /api/buckshot/stats)を全体のルーティングに繋ぎ込みます。

// 今回実装分の概要だけ
import { handleGoogleLogin } from "./handlers/authHandler";
// バックショット用ハンドラーをインポート
import { handleGetStats } from "./handlers/buckshotHandler";

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    // 認証用エンドポイントとか

    // バックショット戦績取得エンドポイント
    if (url.pathname === "/api/buckshot/stats" && request.method === "GET") {
      try {
        return await handleGetStats(request, env);
      } catch (err: any) {
        console.error("[GetStats Error]:", err);
        return new Response(JSON.stringify({ success: false, error: "Internal Server Error" }), { status: 500 });
      }
    }

    // 確認コードとか
  }
}

4.1 データベースの初期化エラー

windowsで開発していると、データベース登録が成功していると出ても、powershell側から読み込めないという既知のバグがあるようです。

error文の例:
Invoke-RestMethod : {“success”:false,”error”:”Internal Server Error”}
FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeRestMethodCommand

そこで、管理者の認証をしたうえで、初期化時に明示的にデータベースの実態を作成するsetup.tsとwebviewで確認できるinspect.tsを用意することにしました。(管理者認証キーはローカルは 隠しファイルに、実環境では環境変数として保存することで安全に管理する予定です)

// 抜粋:setup.tsで実行するクエリの抜粋(ここまでで紹介したuserとか)
try {
    // 実行したいSQLを配列にまとめておく
    const sqlStatements = [
      `CREATE TABLE IF NOT EXISTS users (
        id TEXT PRIMARY KEY,
        display_name TEXT NOT NULL,
        created_at TEXT DEFAULT (datetime('now', 'localtime'))
      );`,

      `CREATE TABLE IF NOT EXISTS user_auths (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        provider TEXT NOT NULL,
        provider_user_id TEXT NOT NULL,
        secret_hash TEXT,
        FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
        UNIQUE (provider, provider_user_id)
      );`,

      `CREATE TABLE IF NOT EXISTS buckshot_stats (
        user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
        elo_rating INTEGER NOT NULL DEFAULT 1500,
        wins INTEGER NOT NULL DEFAULT 0,
        losses INTEGER NOT NULL DEFAULT 0,
        winning_streak INTEGER NOT NULL DEFAULT 0
      );`
    ];

5. 登録したユーザの確認

indexに管理者ログイン後のみ見れるページを設定し、登録したユーザ一覧と特定uuidのユーザのゲームデータベース確認ができるエンドポイントを設計しました。 このときの登録したユーザ一覧にはuuidと表示名および登録日時しか返さないようにしているため、最悪の場合不正アクセスがあっても大丈夫名のようにしています。

登録ユーザの確認ページ(前回登録した仮ユーザ)

仮登録ユーザのデータベースチェック。JSON形式で帰ってくることを確認

仮登録ユーザのデータベース確認結果

一意なUUIDでそれぞれ取得できることを確認しました。その後、ダミー戦績データを登録させ確認すると以下のようになります。

仮登録ユーザのデータベースチェック。JSON形式で帰ってくることを確認

仮登録ユーザのゲーム戦績追加後のデータベース確認結果

ゲームデータは今後ほかのゲームが追加されても、このstats_Tetris(テトリス用テーブル)とかを追加していくだけで拡張可能です。


6. まとめ

今回は、ゲームデータの保存部分のDBを作ってみました。win環境でのwrangler開発は初めてだったのでデータベースの参照で少し躓きましたが、うまく実装できたので良かったです。

次回でデータベースの基礎を完成させていきたいと思います。

関連記事

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つ持っておきたい」という方は、ぜひ一度触ってみてください!

次回予告:バックエンド編開発フェーズ1完了

今後進める内容としては、以下のようにレーティングのDB側を完成させ、その後フロントエンドに進んでいこうと思います。

  1. 対戦結果を処理する「レーティングロジック」をバックエンドに組み込む
  2. プレイヤー同士を繋ぐ「WebRTCやゲーム画面(フロントエンド)」の実装に進む

(ちなみにWebRTCって「バック・ミドル・フロントのどこ?」となりがちですが、シグナリングという一瞬の仲介をバックエンドで行い、繋がった後のリアルタイムなP2P通信ロジックはユーザーのブラウザ側、つまりフロントエンドです。)

次回:レーティングの計算と登録関数の実装
関連記事は、2026年6月15日に公開予定 (あと1日)

ここまで読んでいただきありがとうございます。

では、次の記事で。 lumenHero