Durable ObjectsでTRPGを作る——AIゲームマスターと複数AIプレイヤーが同居するアーキテクチャ

AI GM(Claude)と3体のAIプレイヤー(Gemini・GPT・Grok)、人間1名が参加するD&D風TRPGをCloudflare Durable Objectsで実装する。1セッション=1オブジェクトという設計から、ターン制御・WebSocket・SQLite・Alarmsまで、DOの機能をTRPGという具体例で深掘りする。

·
  • Cloudflare
  • Durable Objects
  • Workers
  • TRPG
  • WebSocket
  • SQLite
  • AI
  • Claude
酒場のテーブルでTRPGを囲むAIキャラクター(Gemini・GPT・Grok)とAI GM、人間プレイヤーのイラスト。テーブル下にDurable ObjectとWebSocket・SQLite・Alarmsの接続図が描かれ、ゲームとインフラが一体化した構造を視覚的に表現している。

前回の記事でDurable Objects(DO)の概念を紹介した。今回は「実際に何かを作るときどう設計するか」を具体的に掘り下げてみる。

題材として選んだのはTRPGだ。ゲームマスターはClaude(AI)が担い、プレイヤーはGemini・GPT・Grokの3体のAIに加えて人間が1名参加する。D&D的なルールで、ターン制でシナリオが進む。

このシステムはDurable Objectsの特性を余さず使い切る設計になる。なぜか。複数の参加者が非同期に行動し、共有の「ゲーム状態」を整合性を持って更新し続けなければならない。サーバーレスが最も苦手としてきたユースケースだ。

登場人物とシステム全体像

まず参加者を整理する。

役割エージェント接続方法
ゲームマスターClaude (Anthropic API)Worker内から呼び出し
AIプレイヤー1Gemini (Google AI API)Worker内から呼び出し
AIプレイヤー2GPT (OpenAI API)Worker内から呼び出し
AIプレイヤー3Grok (xAI API)Worker内から呼び出し
人間プレイヤーブラウザ経由WebSocket接続

各AIは外部のLLM APIを呼び出すことで「発言」を生成する。人間プレイヤーはWebSocketで常時接続し、テキストを送受信する。これら全員の「行動」を受け取り、ゲーム状態を管理し、進行を制御するのがDurable Objectsの仕事だ。

アトムの設計:1セッション=1オブジェクト

前回の記事で「アトム(原子)」という設計思想を紹介した。1つのDurable Objectが何を表すかを決める最も重要な問いだ。

このTRPGシステムでのアトムは 「1ゲームセッション」 だ。

// WorkerからゲームセッションDOを取得する
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const sessionId = new URL(request.url).searchParams.get('session');
    const id = env.GAME_SESSION.idFromName(sessionId);
    const stub = env.GAME_SESSION.get(id);
    return stub.fetch(request);
  }
};

idFromName(sessionId) に同じセッションIDを渡せば、世界中どこからアクセスしても必ず同じオブジェクトにルーティングされる。東京の人間プレイヤーが送った「剣で攻撃する」という行動と、Cloudflareの別のデータセンターから呼び出されたGPTの「魔法を詠唱する」という行動が、同じDurable Object上でシリアルに処理される。これがDOの根幹だ。

SQLiteスキーマ:ゲーム状態の永続化

Durable ObjectのSQLiteにゲームの全状態を保持する。セッションがハイバネーション(スリープ)に入っても、ストレージの内容は永続する。

export class GameSession extends DurableObject {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);

    // スキーマ初期化(初回起動時のみ実行)
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS game_state (
          key   TEXT PRIMARY KEY,
          value TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS players (
          id        TEXT PRIMARY KEY,
          name      TEXT NOT NULL,
          type      TEXT NOT NULL,  -- 'human' | 'ai'
          model     TEXT,           -- 'claude' | 'gemini' | 'gpt' | 'grok'
          hp        INTEGER NOT NULL DEFAULT 20,
          max_hp    INTEGER NOT NULL DEFAULT 20,
          class     TEXT NOT NULL,
          inventory TEXT NOT NULL DEFAULT '[]',
          status    TEXT NOT NULL DEFAULT 'active'
        );

        CREATE TABLE IF NOT EXISTS action_log (
          id         INTEGER PRIMARY KEY AUTOINCREMENT,
          turn       INTEGER NOT NULL,
          player_id  TEXT NOT NULL,
          action     TEXT NOT NULL,
          result     TEXT,
          created_at INTEGER NOT NULL
        );

        CREATE TABLE IF NOT EXISTS turn_order (
          position  INTEGER PRIMARY KEY,
          player_id TEXT NOT NULL
        );
      `);
    });
  }
}

blockConcurrencyWhile() はコンストラクタ内でのマイグレーション実行に使う重要なパターンだ。これが終わるまで一切のリクエスト処理をブロックし、スキーマが確実に準備できてから動作を開始することを保証する。

ゲームログ(action_log)はすべてのターンの行動と結果を蓄積する。セッションが何日にもわたっても、過去の全履歴がSQLiteに残る。これをGMのClaudeに渡すことで、文脈を踏まえた質の高いナレーションが生成できる。

シングルスレッドがターン制を自然に解決する

TRPGのターン制実装で最も厄介なのは「同時行動の防止」だ。複数のプレイヤーが同時にアクションを送ってきたとき、どちらを先に処理するかを正確に制御する必要がある。

通常のWebサーバーであれば、ミューテックスやトランザクションロックが必要になる。しかしDurable Objectsはシングルスレッドで動作するため、複数のリクエストが届いても自動的にキューイングされ、一度に1つずつ処理される。ターン制の排他制御がアーキテクチャに最初から組み込まれているようなものだ。

async submitAction(playerId: string, action: string): Promise<void> {
  // このメソッドは同時に1つしか実行されない
  const currentTurn = this.getCurrentPlayer();

  if (currentTurn.id !== playerId) {
    throw new Error(`今は ${currentTurn.name} のターンです`);
  }

  // アクションを記録
  const turn = this.getTurnNumber();
  this.ctx.storage.sql.exec(
    `INSERT INTO action_log (turn, player_id, action, created_at)
     VALUES (?, ?, ?, ?)`,
    turn, playerId, action, Date.now()
  );

  // GMにアクション結果を生成させる
  const result = await this.callGM(action, playerId);

  // 結果を保存してターンを進める
  this.ctx.storage.sql.exec(
    `UPDATE action_log SET result = ? WHERE player_id = ? AND turn = ?`,
    result, playerId, turn
  );

  this.advanceTurn();
  this.broadcastToAll({ type: 'turn_result', playerId, action, result });
}

「誰かがアクションを送信している最中に別の誰かも送信してきた」場合、後から来たリクエストは前のリクエストの処理が完了するまで待機する。ロックを書かなくていい。これがDOのシングルスレッド性の恩恵だ。

WebSocket:人間プレイヤーとのリアルタイム通信

人間プレイヤーはブラウザからWebSocketで接続し、他の参加者の行動をリアルタイムで受け取る。

async fetch(request: Request): Promise<Response> {
  if (request.headers.get('Upgrade') === 'websocket') {
    const [client, server] = Object.values(new WebSocketPair());

    // WebSocket Hibernation APIを使って接続を受け付ける
    this.ctx.acceptWebSocket(server, ['human-player']);

    return new Response(null, { status: 101, webSocket: client });
  }
  // ...
}

// WebSocketメッセージ受信ハンドラ
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
  const data = JSON.parse(message);

  if (data.type === 'action') {
    await this.submitAction('human', data.action);
  }
}

// 全接続クライアントへブロードキャスト
broadcastToAll(payload: object): void {
  const connections = this.ctx.getWebSockets('human-player');
  const message = JSON.stringify(payload);
  for (const ws of connections) {
    ws.send(message);
  }
}

ここで重要なのは ctx.acceptWebSocket() を使っている点だ。これがWebSocket Hibernation APIで、人間プレイヤーが接続しっぱなしでも誰もメッセージを送っていない間はDurable Objectがスリープし、課金が停止する。メッセージが届いた瞬間だけ起動するため、コスト効率が非常に高い。

ゲームセッションが夜中に放置されていても、人間プレイヤーのタブが開いたままでも、課金は発生しない。

Alarms API:AIプレイヤーのターンタイムアウトと自動進行

AIプレイヤーのターンになったとき、外部LLM APIの応答には数秒かかることがある。また万が一APIがタイムアウトした場合にゲームが止まることは避けたい。Alarms APIはここで輝く。

// AIのターンが始まったらアラームをセット(30秒のタイムアウト)
async startAITurn(player: Player): Promise<void> {
  // 30秒後にアラームが発火するようにセット
  await this.ctx.storage.setAlarm(Date.now() + 30_000);

  // AIに行動を生成させる
  try {
    const action = await this.callAIPlayer(player);
    await this.submitAction(player.id, action);

    // 成功したらアラームをキャンセル
    await this.ctx.storage.deleteAlarm();
  } catch (error) {
    // エラー時はアラームが自動で発火してターンをスキップする
    console.error(`${player.name} のAPI呼び出し失敗:`, error);
  }
}

// アラームハンドラ:タイムアウト時に強制的にターンを進める
async alarm(): Promise<void> {
  const current = this.getCurrentPlayer();
  const skipMessage = `${current.name} は行動を決めかねているようだ……(タイムアウト)`;

  this.ctx.storage.sql.exec(
    `INSERT INTO action_log (turn, player_id, action, result, created_at)
     VALUES (?, ?, 'タイムアウト', ?, ?)`,
    this.getTurnNumber(), current.id, skipMessage, Date.now()
  );

  this.advanceTurn();
  this.broadcastToAll({ type: 'timeout', player: current.name, message: skipMessage });

  // 次のターンを開始
  await this.processTurn();
}

alarm() ハンドラはDurable Objectがハイバネーション状態にあっても、指定した時刻が来れば自動的にオブジェクトを起動して実行する。これはCronジョブとは本質的に異なる。特定のオブジェクト(特定のゲームセッション)に紐付いたスケジュールであり、アラームはそのオブジェクトのストレージに保存される。

ゲームマスターAI(Claude)の呼び出し

GMのClaudeは各ターンのアクションを受け取り、ナレーション・ダイス判定・結果描写を生成する。重要なのは、過去の全ログをコンテキストとして渡すことだ。

async callGM(action: string, playerId: string): Promise<string> {
  // 直近20ターンのログをSQLiteから取得
  const recentLog = this.ctx.storage.sql.exec(
    `SELECT p.name, l.action, l.result
     FROM action_log l
     JOIN players p ON l.player_id = p.id
     ORDER BY l.turn DESC
     LIMIT 20`
  ).toArray();

  // 全プレイヤーの現在状態を取得
  const playerStates = this.ctx.storage.sql.exec(
    `SELECT name, class, hp, max_hp, inventory FROM players WHERE status = 'active'`
  ).toArray();

  const systemPrompt = `
あなたはD&Dのダンジョンマスターです。
現在のプレイヤー状態: ${JSON.stringify(playerStates)}
直近の行動履歴: ${JSON.stringify(recentLog)}

アクションの結果を描写してください。ダイス判定が必要な場合は結果を決定し、
物語として面白い展開になるよう判定してください。150字以内で端的に。
  `;

  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': this.env.ANTHROPIC_API_KEY,
    },
    body: JSON.stringify({
      model: 'claude-opus-4-5',
      max_tokens: 300,
      system: systemPrompt,
      messages: [{ role: 'user', content: action }],
    }),
  });

  const data = await response.json();
  return data.content[0].text;
}

ここで注目したいのは、SQLiteへのアクセスがI/O待ちを発生させない点だ。通常のサーバーレス関数では外部DBへの接続が数十ミリ秒のレイテンシーを生むが、Durable ObjectsのSQLiteはコンピュートと同一スレッドに存在するため実質ゼロに近い。20ターン分のログをフェッチする処理がほぼ無コストで行える。

AIプレイヤーの思考プロセス

3体のAIプレイヤーはそれぞれキャラクターを演じる。TRPGらしさを出すために、各AIには自分のキャラクターシートと、他のプレイヤーの直近の行動を渡す。

async callAIPlayer(player: Player): Promise<string> {
  // 各AIのAPIエンドポイントとモデルをマッピング
  const apiConfig = {
    gemini: { url: 'https://generativelanguage.googleapis.com/...', key: this.env.GEMINI_KEY },
    gpt:    { url: 'https://api.openai.com/v1/chat/completions',    key: this.env.OPENAI_KEY },
    grok:   { url: 'https://api.x.ai/v1/chat/completions',          key: this.env.GROK_KEY },
  };

  const recentActions = this.ctx.storage.sql.exec(
    `SELECT p.name, l.action, l.result
     FROM action_log l JOIN players p ON l.player_id = p.id
     ORDER BY l.turn DESC LIMIT 5`
  ).toArray();

  const prompt = `
あなたは${player.name}(${player.class})を演じるプレイヤーです。
HP: ${player.hp}/${player.max_hp}
所持品: ${player.inventory}
直近の展開: ${JSON.stringify(recentActions)}

キャラクターとして自然な行動を1つ宣言してください(50字以内)。
  `;

  // モデルごとのAPI呼び出し実装
  return this.callExternalLLM(apiConfig[player.model], prompt);
}

AIプレイヤーたちはそれぞれ異なるモデルで動くが、Durable Object側から見ればどれも「外部のfetch呼び出し」に過ぎない。これがWorkersの設計の美しさで、どのLLM APIも均一に扱える。

ハイバネーションとゲームの継続性

ゲームが終わった後、翌日に続きをプレイする場合を考える。

セッション終了後、人間プレイヤーがブラウザを閉じると、WebSocket接続が切れる。Durable ObjectはWebSocket Hibernation APIを使っているため、アクティブな接続がなければ数秒でハイバネーション状態に移行し、その後メモリから解放される。

しかしSQLiteのデータは残る。翌日、人間プレイヤーが同じセッションIDでアクセスすると、Durable Objectが再起動し、コンストラクタが再実行される。SQLiteから全ゲーム状態が復元され、昨日の続きから何事もなかったかのようにゲームが再開する。

コードでこの復元を書く必要はない。「ストレージは永続し、メモリは一時的」というDOの仕様がそのままゲームのセーブ・ロード機能として機能する。

複数セッションのスケーリング

このTRPGシステムを公開してユーザーが増えてきたとする。100のゲームセッションが同時進行するとき、それは100個のDurable Objectが独立して動いているだけだ。セッション同士は完全に分離されており、あるセッションの処理が別のセッションを遅延させることはない。

グローバルな調整役のサーバーも、ロードバランサーの設定変更も不要だ。idFromName(sessionId) の呼び出しが増えるだけで、スケーリングはCloudflareのインフラが自動的に処理する。

これがDurable Objectsが「サーバーレスの強みを失わずにステートを持てる」と言われる理由だ。

アーキテクチャ全体図

ブラウザ(人間プレイヤー)
    │ WebSocket

Worker(エントリーポイント)
    │ RPC / fetch

GameSession DO ← SQLite(HP・ログ・ターン状態など)
    ├── GMターン  → Anthropic API(Claude)
    ├── AIターン1 → Google AI API(Gemini)
    ├── AIターン2 → OpenAI API(GPT)
    ├── AIターン3 → xAI API(Grok)
    └── Alarms    → タイムアウト・自動進行

シンプルだ。DBサーバーもキューもセッション管理サービスも存在しない。GameSession DOが1つあれば、このゲームは成立する。

設計上の注意点

実際に作るときに気をつけるべき点もある。

外部API呼び出しのレイテンシー は積み上がる。GMのClaude、3体のAIプレイヤー、それぞれのAPI呼び出しが1ターンあたり発生する。1回2〜5秒かかるとすれば、フルターン完了まで最悪20秒以上になりうる。これはTRPGという性質上許容範囲ではあるが、ユーザーへの進捗通知(「Geminiが考え中…」など)のストリーミング配信を早期に実装したほうがいい。

コンテキスト長の管理 も重要だ。行動ログをすべてGMのClaudeに渡すと、セッションが長くなるにつれてコンテキストウィンドウを圧迫する。SQLiteのSQLで「直近Nターン」や「重要なイベントのみ」を絞り込む処理を設計段階から組み込んでおく必要がある。

単一オブジェクトのスループット上限 も覚えておく必要がある。1オブジェクトあたり秒間500〜1,000リクエストが目安だが、このTRPGシステムでは5人の参加者が順番に行動するだけなので、まず問題にならない。

まとめ

このTRPGシステムを通して、Durable Objectsの機能がどう有機的に組み合わさるかが見えてきた。

SQLiteがゲーム状態の永続化を担い、シングルスレッド性がターン制の排他制御を無償で提供し、WebSocket Hibernationがリアルタイム通信のコストを抑え、Alarms APIがタイムアウト処理を自動化する。そのすべてが1つのオブジェクト内に収まり、外部インフラを必要としない。

「1ゲームセッション=1オブジェクト」という設計判断が、このシステムの複雑さの大部分を吸収している。ゲームの状態とその状態を操作するコードが物理的に同居することで、一貫性・レイテンシー・コストの3つを同時に最適化できる。

Durable Objectsは「サーバーレスだが、状態が必要な局面にだけ現れる常駐プロセス」だと言い換えられるかもしれない。その性質はTRPGのようなターン制のリアルタイムアプリに、驚くほど自然にフィットする。

Last updated