Claude Code と一緒に DEBUG-ZERO のサーバを設計したら、知らなかった技術を知った
はじめに
自作のカードゲーム DEBUG-ZERO(数字カードと四則演算で目標値をゼロにするゲーム)のオンライン対戦サーバを設計している。
最近 Cloudflare Workers を触り始めたのだけど、「リアルタイム通信どうすればいいんだろう」という部分の設計に自信がなかったので、Claude Code に設計を任せながら進めてみた。
結果として、自分では辿り着かなかったアーキテクチャが出てきて、知らなかった技術をいくつか知ることになった。その過程を整理する。
最初に壁にぶつかった:Workers はステートレス
「Cloudflare Workers で WebSocket 対戦を作りたい」と Claude Code に伝えたところ、最初に返ってきた指摘がこれだった。
Workers はリクエストごとに起動するステートレスな実行環境なので、WebSocket の長時間接続を管理するには別の仕組みが必要です。
言われてみれば確かに、という話なのだが、自分では最初にそこを考えられていなかった。
- 接続が続いている間、どこかに「誰が接続しているか」を保持する必要がある
- ゲームの状態(手札・場・ターン)を複数の接続にまたがって共有する必要がある
Workers 単体では揮発するので、ここで Durable Objects というものが必要だと言われた。
Durable Objects を初めて知った
正直に言うと、Durable Objects はこの設計を通じて初めて知った。
調べてみると、Workers に紐づく永続オブジェクトで、同じ ID に対して常に同一インスタンスが保証される、という仕組みだった。「ルームIDに対してゲーム状態を保持し続ける」という用途にぴったりで、Cloudflare がまさにこのユースケースのために用意していたものだと理解した。
Claude Code が提案してきた構成のイメージはこうなる:
ルームごとに Durable Object を1つ立てる方針。これでゲーム状態が独立するので、ルームが増えても状態が混ざらない。
「接続管理とゲームロジックは分けましょう」
もう一つ、Claude Code が強めに主張してきたのがこの分離だった。
WebSocket の接続ハンドリングとゲームのドメインロジックを一箇所に書くと、後で辛くなります。MessageRouter を挟んで両者を分離しましょう。
ws/
├── ConnectionManager 接続IDとプレイヤーIDのマッピング
├── MessageRouter 受信メッセージを各サービスにルーティング
└── Broadcaster 対象プレイヤー・全員・観戦者への配信
game/
├── GameEngine ゲーム状態遷移の中核(applyAction)
├── ActionValidator 行動の合法性チェック
└── PhaseController フェーズ遷移制御
MessageRouter がメッセージの種別を見て適切なサービスに投げる。ゲームロジック側は WebSocket のことを知らなくていい。これは言われなければやらなかったと思う。
メッセージ設計:visibility フィールドが面白かった
メッセージの種別は client:*(クライアントからの要求)と server:*(サーバからの通知)で名前空間を分ける提案が来た。
// クライアント → サーバ(要求)
interface ClientMessage {
id: string; // 重複検知用UUID
type: ClientMessageType;
roomId: string;
senderId: string;
payload: unknown;
}
type ClientMessageType =
| "client:action"
| "client:join"
| "client:leave";
// サーバ → クライアント(通知)
interface ServerMessage {
id: string;
type: ServerMessageType;
roomId: string;
payload: unknown;
visibility: "all" | "player" | "spectator";
targetPlayerId?: string;
}
type ServerMessageType =
| "server:action_result"
| "server:state_sync"
| "server:error";
特に visibility フィールドは「なるほど」と思った。「手札は本人にしか送らない」という制御をメッセージ設計レベルで表現している。Broadcaster がこれを見て配信先を絞るので、ゲームロジック側が配信先を意識しなくて済む。
通信フローのシーケンス
接続・アクション送信・再接続の3つのフロー。
再接続は全量同期で割り切る
再接続時の状態同期について、Claude Code は「差分同期は複雑になりがちなので、まず全量同期から始めましょう」という提案だった。
接続が切れた後に再接続したとき、その時点のゲーム状態を全量で送る。シンプルで、接続が途切れたことで手番がスキップされることもない。差分同期は後から考えればいい、という割り切りは納得感があった。
Hono との組み合わせ
フレームワークは Hono を使う予定。Workers との相性のよさはもともと知っていたが、WebSocket のアップグレード処理も素直に書けるという点は Claude Code に教えてもらった。ルーティングは Hono に任せて、WebSocket のハンドリングは別モジュールに委ねる形にする。
まとめ
Claude Code に設計を任せながら進めた結果、理解できたことをまとめる。
- Cloudflare Workers でリアルタイム対戦を実現するには Durable Objects が必要。これは自分では気づかなかった
- 接続管理とゲームロジックの分離は、MessageRouter を挟むことで両者が互いを知らなくてよくなる
visibilityをメッセージに持たせると、手札のような「本人にだけ見せる情報」の制御が設計に乗る- 全量同期で割り切ると、ネットワーク不安定への対処がシンプルになる
AI に設計を任せると「知らなかったことを知る」という副産物がある、というのが今回一番の発見だった。これから実装に入るので、Durable Objects 周りの知見が増えたら続きを書く予定。