← 記事一覧に戻る
2026-07-03

Claude Fable 5にスマホゲームのオンライン対戦を実装させた

Claude Fable 5 が再公開されたので、また何か作らせてみることにしました。前回は回路図エディタでしたが、今回はこのサイトに置いてある3Dドライブゲーム(/drive3d_mobile)にオンライン対戦を足してもらいました。スマホ2台で、4桁のルームコードを伝え合うだけで対戦できます。

問題:サーバがRaspberry Pi

このサイトは自宅のRaspberry Piで動いています。ゲームサーバとして毎秒何十回も位置情報を中継するような仕事をさせたら、他のページごと死にます。

そこでClaudeに出した条件は「できればスマホ端末同士で直接通信してほしい」。返ってきた設計はこうでした。

ルーム情報はFlaskプロセスのメモリ上のdictに置くだけ(10分で自動破棄)。SDP交換が済んだら即削除されるので、サーバに残るものは何もありません。Piの負荷は実測でほぼゼロです。

同期の設計:「自機のことは自分の端末が決める」

P2P対戦で面倒なのは、遅延がある中で「どっちの画面を信じるか」です。Claudeの採った方針はシンプルでした。

これなら遅延があっても「自分は避けたのに死んだ」という理不尽が起きにくい。判定が両端末で完全一致はしませんが、落ちたら負けというルール上、勝敗判定(自分の落下)は各自が自分の分だけ宣言するので矛盾しません。

その後の改善で、DataChannelは2本に分けました。位置更新は順序なし・再送なしのチャンネル(古いパケットはシーケンス番号で捨てる)、弾・爆発・勝敗などのイベントは信頼性ありのチャンネル。1本の信頼性チャンネルに全部載せると、パケットロス時に位置更新まで巻き添えで遅延する(head-of-line blocking)ためです。

会話しながら育てた機能

最初のP2P対戦が動いたあとは、思いつくまま注文を投げて育てていきました。

気に入っているのは縮む足場の実装です。足場の縮小は「ラウンド開始からの経過時間」だけから決定的に計算されるので、縮小のための同期通信が1バイトも要らない。P2Pゲームのギミックはこういう作り方をするのかと感心しました。

ハマったところ

Jinjaテンプレートと ES6 テンプレートリテラルの衝突。 ゲームのHTMLはFlask(Jinja)経由で配信されているので、JavaScriptに ${{...}} のようなコードを書くと、Jinjaが {{ }} を自分の構文として解釈してサイトごと500エラーになります。一度これでゲームページが丸ごと落ちました。しかもClaude自身が「JinjaだからJSに {{ は書けない」という注意コメントをコードに残そうとして、そのコメント内の {{ でまた500を出すというオチ付き。

見えないCPU車が弾を食っていた。 対戦モードではソロ用のCPU車を非表示にしていたのですが、当たり判定は生きたままでした。ローカルの画面分割対戦では「たまに弾が消える」程度で誰も気づかなかったのが、オンライン化でCPU車の配置乱数が端末ごとに違うため「自分の画面では当たったのに相手の画面では外れている」という形で顕在化。オンライン化は隠れバグの検出器にもなります。

STUNだけではつながらない相手がいる。 TURN中継サーバを立てればほぼ確実につながりますが、中継トラフィックがPiを通るなら本末転倒なのでSTUNのみにしました。キャリア回線同士(対称NAT)だと稀に接続に失敗します。同じWi-Fiなら確実につながるので、個人サイトのゲームとしてはこれで十分と判断。

iPhoneのSafariは全画面にできない。 実機で遊ぶと、Safariの下のツールバーが消えずゲームが全画面になりません。調べてもらったところ、これは実装の問題ではなくiPhoneのSafariがページからのFullscreen APIを提供していないという仕様でした(動画だけは全画面にできる。iPadやAndroidのChromeでは使える)。つまりSafariのタブ内で完全全画面は、どんなサイトでも原理的に不可能。

対策は3段構えになりました。

「全画面にして」という雑な注文に対して、「iPhoneではAPIが存在しないので、ホーム画面追加へ誘導する案内をiPhoneのSafariで開いたときだけ表示する」という落とし所まで含めて返ってくるのは、制約の説明込みで任せられて楽でした。

まとめ

対戦相手がいる方はぜひ /drive3d_mobile からどうぞ。