mcpField
A
add-multiplayer

add-multiplayer

github.com/playableintelligence開発350608 回インストール22 週間前に更新
リポジトリ

add-multiplayer について

既存の Phaser 3 または Three.js のブラウザーゲームに、リアルタイムまたはターン制のマルチプレイヤー機能を追加するスキルです。EventBus 経由でネットワークを接続し、サーバー停止中もシングルプレイヤーで遊べる設計にします。

主な機能

  1. 01PartyKit のルームサーバーを構築
  2. 02リアルタイムとターン制の同期
  3. 03EventBus 経由でネットワークを接続
  4. 04オフラインでもシングルプレイを維持
  5. 05リモートプレイヤーの状態とイベントを処理

活用シーン

  1. 01ブラウザーゲームにマルチプレイヤールームを追加
  2. 02アクションゲームでプレイヤー位置を同期
  3. 03カードやボードゲームの手を中継

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/playableintelligence/game-creator --skill add-multiplayer

インストール後、Claude Code で/add-multiplayerを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

architecture.md
client-integration.md
deploy.md
partykit-server.md

詳細

マルチプレイヤーの追加(PartyKit/Cloudflare Durable Objects)

既存のシングルプレイヤーのブラウザーゲームに、リアルタイムまたはターン制のマルチプレイヤー機能を追加します。このスキルでは、次の要素を生成します。

  1. Cloudflare のエッジにデプロイされる PartyKit サーバー(ルームごとに Durable Object を 1 つ)。
  2. 既存の playfun.js の外部サービスパターンを踏襲し、EventBus を介して接続するクライアント NetworkManager。
  3. EventBus、GameState、Constants、render_game_to_text() への追加のみの編集。サーバーに接続できない場合も、シングルプレイヤーのゲームプレイは変わりません。

デフォルトでは「シングルプレイヤーとして動作する」状態です。WebSocket 接続に失敗すると、NetworkManager はエラーを処理し、ゲームはこれまでどおりローカルで動作します。接続されると、network:player-joined によってリモートプレイヤーが表示され、network:state-received によって同期されます。

参照ファイル

  • architecture.md — イベントの分類、GameState のスキーマ、NetworkManager の契約、Phaser と Three.js の配置に関する注意事項。
  • partykit-server.md — サーバーテンプレート(realtime.ts と turn-based.ts)、状態の形状、ブロードキャストヘルパー、レート制限。
  • client-integration.md — MultiplayerClient、NetworkManager、RemotePlayerRegistry のソース、EventBus/GameState/Constants への追記パターン、render_game_to_text の拡張。
  • deploy.md — npx partykit dev と npx partykit deploy の手順、デプロイした URL の取得、.env の処理、クライアントの再デプロイ。

基本原則

以下はガイドラインではなく、ルールです。

  1. シングルプレイヤーはオフラインでも動作すること。 サーバーに接続できない場合でも、ゲームは通常どおり起動、プレイ、リセットできる必要があります。NetworkManager はすべての接続エラーを捕捉し、例外をスローする代わりに network:disconnected を発行します。
  2. 追加のみの編集にすること。 EventBus.js、GameState.js、Constants.js、main.js、render_game_to_text() には、// === Multiplayer === バナーの下に追記します。既存のフィールドの名前変更、削除、変更は行いません。
  3. ゲームとの接続点は EventBus のみとすること。 NetworkManager はイベントを介してゲームの他の部分と通信します。シーン、システム、エンティティから NetworkManager(またはその逆)を直接インポートしてはいけません。
  4. サーバーが権威を持ちつつ、柔軟に対応すること。 PartyKit のルームが正規のルーム状態を管理します。クライアントは意図を送信し、サーバーが検証してブロードキャストします。realtime モードの検証は簡易的です(後勝ち)。turn-based モードの検証は厳密で、手番外の操作を拒否します。
  5. クライアント API はバックエンドに依存しないこと。 partysocket の呼び出しはすべて MultiplayerClient を経由します。将来 Colyseus や fly.io+ws を使う場合、変更するのは MultiplayerClient.js だけで、ゲームコードは変更しません。
  6. デフォルトのルームは 'lobby' とすること。 v1 にはマッチメイキング UI はありません。ユーザーはカスタムのルーム ID を指定して multiplayer:join-room を発行することで、ルームを変更できます。

前提条件

  • このプラグインを使って構成された既存の Phaser 3 または Three.js ゲーム(src/core/EventBus.js、src/core/GameState.js、src/core/Constants.js、window.render_game_to_text() を含む src/main.js があること)。
  • Node.js 18 以降。
  • npx partykit deploy に必要な Cloudflare アカウント(初回のデプロイ時に CLI がログイン手順を案内します。プロトタイピングには無料プランで十分です)。

手順

ユーザーは $ARGUMENTS のゲーム(パスが指定されていない場合は現在のディレクトリのゲーム)にマルチプレイヤー機能を追加したいと考えています。省略可能な --mode=realtime(デフォルト)または --mode=turn-based で、使用するサーバーテンプレートを選択します。

ステップ 0:ゲームを特定して読み込む

$ARGUMENTS を解析し、ゲームのパスと --mode フラグを取得します。パスが指定されていない場合は cwd を使用します。package.json を読み込み、Phaser または Three.js の依存関係があることを確認して、ゲームであることを検証します。

何かを変更する前に、以下のファイルをすべて読み込みます。

  • package.json — エンジンとスクリプト。
  • src/main.js — オーケストレーター、window.render_game_to_text()、window.advanceTime()。
  • src/core/EventBus.js — 現在使用されている正確なイベント名。
  • src/core/GameState.js — 現在の状態の形状と reset() のセマンティクス。
  • src/core/Constants.js — 設定ブロックの規約。
  • progress.md(存在する場合)— パイプラインのコンテキスト。

確認した内容を、次のように一文で制作者に伝えます。

ゲームは <engine> で、イベント数は <N>、エンティティは <player|bird|ship> です。ローカルの <entity> の状態を TICK_RATE_HZ でブロードキャストし、サーバーからのブロードキャストに基づいてリモートプレイヤーを描画するマルチプレイヤー層を追加します。サーバーがオフラインでも、シングルプレイヤーは引き続き動作します。

ステップ 1:同期モードを選択する

サーバーテンプレートを選びます。

モード使用する場面通信モデル
realtime(デフォルト)アクションゲーム、ランナー、回避ゲーム、プラットフォーマーなど、継続的に移動するゲームローカルの setInterval が TICK_RATE_HZ でローカルエンティティの {x, y, [z], score, state} をブロードキャストし、サーバーが転送します。クライアントは最後に受信したリモート状態を描画します
turn-basedカードゲーム、ボードゲーム、パズルなど、手が離散的なゲームEventBus イベント(player:moved、player:played-card)を {type, payload} メッセージとして転送します。サーバーが検証してブロードキャストし、クライアントは network:state-received で適用します

ユーザーが --mode を指定していない場合は、ゲームの既存イベントから推定します。継続的な位置イベント(bird:flap、player:moved、位置を更新する物理演算)が見られる場合は realtime を使用します。離散アクション(card:played、move:submitted)が見られる場合は turn-based を使用します。選択したモードを伝えてから作業を進めます。

ステップ 2:サーバーをスキャフォールドする

ゲームプロジェクト内に、ゲームと同階層の multiplayer-server/ ディレクトリを作成します。テンプレートの全文は partykit-server.md を参照してください。

以下を作成します。

  • multiplayer-server/partykit.json — name(ゲームのディレクトリ名を使用)、main: "src/server.ts"、compatibilityDate を含むマニフェスト。
  • multiplayer-server/package.json — partykit 依存関係と dev/deploy スクリプト。
  • multiplayer-server/tsconfig.json — PartyKit が受け付ける最小限の TypeScript 設定。
  • multiplayer-server/src/server.ts — partykit-server.md にある適切なテンプレート(realtime または turn-based)を貼り付けます。
  • multiplayer-server/.gitignore — node_modules、.partykit。

cd multiplayer-server && npm install を実行して partykit をインストールします(npm ワークスペース経由でクライアント用の partysocket も提供されますが、クライアントには partysocket を明示的に追加します)。

ステップ 3:クライアントをスキャフォールドする

新しいファイルを 3 つ作成します。ソースの全文は client-integration.md を参照してください。

  • src/multiplayer/MultiplayerClient.js — partysocket を使用した、バックエンドに依存しないインターフェース(connect、send、onMessage、disconnect、isConnected)。
  • src/multiplayer/RemotePlayerRegistry.js — upsert、remove、prune(staleMs)、list() を備えた Map<playerId, RemotePlayer>。
  • src/systems/NetworkManager.js — MultiplayerClient と EventBus を接続し、(realtime モードでは)ブロードキャストのティックを管理し、指数バックオフによる再接続を処理して、network:* イベントを発行します。

ゲームの package.json の依存関係に partysocket を追加します。

cd <game-path> && npm install partysocket
ステップ 4:既存のコアファイルに追記する

追加のみの編集にしてください。完全なスキーマについては architecture.md を、正確な追記ブロックについては client-integration.md を参照してください。

src/core/EventBus.js — // === Multiplayer === バナーの下に追記します。

// === Multiplayer ===
NETWORK_CONNECTED: 'network:connected',
NETWORK_DISCONNECTED: 'network:disconnected',
NETWORK_PLAYER_JOINED: 'network:player-joined',
NETWORK_PLAYER_LEFT: 'network:player-left',
NETWORK_STATE_RECEIVED: 'network:state-received',
MULTIPLAYER_JOIN_ROOM: 'multiplayer:join-room',
MULTIPLAYER_LEAVE_ROOM: 'multiplayer:leave-room',

src/core/GameState.js — 永続部分(roomId、playerId)と一時部分(connected、remotePlayers)を持つ multiplayer フィールドを追記します。ゲームの再起動後に再参加できるよう、reset() は一時部分のみをクリアするよう更新します。

src/core/Constants.js — SERVER_URL(ステップ 6 で設定)、DEFAULT_ROOM、MAX_PLAYERS、TICK_RATE_HZ、再接続のバックオフ、プレイヤーの古さのしきい値、PROTOCOL_VERSION を含む MULTIPLAYER ブロックを追記します。マジックナンバーは使わず、すべての値に名前付き定数を使用します。

src/main.js — EventBus と GameState の後、エンジンの起動前に NetworkManager をインスタンス化します。テスト用に window.__NETWORK_MANAGER__ を公開します。window.render_game_to_text() を拡張し、multiplayer: {...} と remotePlayers: [...] を追加で含めます。

ステップ 5:ローカルゲームをネットワークのティックに接続する

既存のイベントを確認します。接続方法はモードによって異なります。

realtime:NetworkManager が TICK_RATE_HZ で setInterval を管理します。各ティックで GameState からローカルエンティティを読み取り、client.send({type: 'state', payload: {...}}) を呼び出します。EventBus のサブスクリプションは不要です。単に GameState をサンプリングします。関連するシーン/システムに network:state-received リスナーを 1 つ追加し、RemotePlayerRegistry.upsert() を呼び出して再レンダリングを行います。

turn-based:NetworkManager は、ゲームに既存の移動イベント(例:card:played、move:submitted)をサブスクライブして転送します。シーン/システムは network:state-received をリッスンし、検証済みのリモート移動を適用します。ローカルで楽観的 UI を使用できますが、正しい状態の基準となるのはサーバーです。

Phaser のゲームでは、アクティブな GameScene でリモートプレイヤーをレンダリングします。network:player-joined でスプライトをインスタンス化し、network:state-received で位置を更新し、network:player-left で破棄します。Three.js のゲームでは、アクティブなオーケストレーター(Game.js)がリモートプレイヤーのメッシュを作成し、更新します。

両方のエンジンのシーンパッチ例については、client-integration.md を参照してください。

ステップ 6:サーバーをデプロイする

まず開発サーバーを実行して、すべてがローカルで正常に動作することを確認します。

cd <game-path>/multiplayer-server && npx partykit dev

これにより、http://127.0.0.1:1999 でローカルの CF Worker エミュレーターが起動します。別のターミナルで、<game-path>/.env に VITE_MULTIPLAYER_SERVER_URL=http://127.0.0.1:1999 を設定し、クライアントを実行します(cd <game-path> && npm run dev)。

初回デプロイ時には、ユーザーが PartyKit で認証する必要があります。必ず --provider github を渡してください。デフォルトの clerk フローは 2026 年現在、正常に動作しません(Cloudflare による PartyKit の吸収後、dashboard.partykit.io のコールバックは廃止され、ログインがいつまでも完了しません)。

cd <game-path>/multiplayer-server && npx partykit login --provider github

これは GitHub のデバイスコード OAuth フローを使用します。CLI にコードが表示されます。ユーザーは https://github.com/login/device にアクセスしてコードを貼り付け、認証を許可します。認証情報は ~/.partykit/config.json に保存されます。詳しい手順とトラブルシューティングについては、deploy.md を参照してください。

ログイン後、デプロイします。

cd <game-path>/multiplayer-server && npx partykit deploy

出力からデプロイ済み URL を取得します(形式:https://<project>.<cloudflare-username>.partykit.dev)。デプロイ成功の報告後、TLS 証明書の発行には 30~60 秒かかる場合があります。

デプロイ済み URL を以下の 3 か所に設定します。

  1. src/core/Constants.js → MULTIPLAYER.SERVER_URL
  2. <game-path>/.env → VITE_MULTIPLAYER_SERVER_URL=https://...
  3. <game-path>/.env.example → VITE_MULTIPLAYER_SERVER_URL=https://your-project.your-username.partykit.dev

.env がまだ .gitignore に含まれていない場合は追加します。

オフライン優先の認証やトラブルシューティングを含む詳しい手順については、deploy.md を参照してください。

ステップ 7:クライアントを再デプロイする

既存のホスト検出ロジックを再利用します(monetize-game のステップ 5 と同じです)。

  1. .herenow/state.json が存在する場合 → ~/.agents/skills/here-now/scripts/publish.sh dist/ で再デプロイします。
  2. gh が設定されており、リポジトリに GitHub Pages ワークフローがある場合 → npx gh-pages -d dist を実行します。
  3. vercel が設定されている場合 → vercel --prod を実行します。
  4. それ以外の場合は、どの方法で再デプロイするかユーザーに確認します。

必ず最初に npm run build を実行してください。

ステップ 8:検証する

ビルドが正常に完了すること:

cd <game-path> && npm run build
cd multiplayer-server && npm run build  # if a build script exists

シングルプレイヤーのフォールバック(重要): partykit 開発サーバーを停止した状態で、http://localhost:3000 を再読み込みします。ゲームが正常に起動し、プレイでき、リセットできる必要があります。network:disconnected が発生し、コンソールに捕捉されていないエラーがないことを確認します。サーバーがないとゲームが起動しない場合は、原則 1 に違反しています。修正してください。

2 つのタブでのスモークテスト: 1 つのターミナルで npx partykit dev を起動し、別のターミナルで npm run dev を起動します。http://localhost:3000 を 2 つのブラウザータブで開きます。次の点を確認します。

  • 両方のタブで network:connected が発生する(コンソールで確認)。
  • 各タブの window.render_game_to_text() の remotePlayers に、もう一方のタブが含まれている。
  • タブ A でローカルエンティティを移動すると、1000 / TICK_RATE_HZ * 2 ミリ秒以内に、タブ B のリモートプレイヤーの描画に反映される。

再接続: partykit 開発サーバーを停止し、しばらく待ってから再起動します。クライアントが RECONNECT_MAX_BACKOFF_MS 以内に再接続し、network:connected が再度発生することを確認します。

リグレッション: 既存の tests/e2e/*.spec.js が引き続きすべて合格する必要があります。サーバーの稼働中・停止中のどちらでも、シングルプレイヤーの不変条件(起動、スコア、ゲームオーバー、リセット)が維持される必要があります。

ステップ 9:progress.md を更新する

## Multiplayer セクションを追記します。

## Multiplayer

- **Backend**: PartyKit (Cloudflare Durable Objects)
- **Server URL**: https://<project>.<user>.partykit.dev
- **Mode**: realtime | turn-based
- **Max players per room**: 4
- **Tick rate**: 20 Hz (realtime mode)
- **Default room**: lobby
- **Known limitations (v1)**: no matchmaking UI, no spectator mode, no persistent accounts, server-side rate limiting only.

出力

ユーザーに次の内容を伝えます。

  1. 追加した内容 — multiplayer-server/ のサーバー、src/multiplayer/ と src/systems/NetworkManager.js のクライアント、4 つのコアファイルへの追加編集。
  2. サーバー URL — https://<project>.<user>.partykit.dev。Constants と .env にはすでに設定されています。
  3. ローカルでのテスト方法 — cd multiplayer-server && npx partykit dev を実行し、続いて npm run dev を実行して、2 つのタブを開きます。
  4. バックエンドの切り替え箇所 — src/multiplayer/MultiplayerClient.js を示します。今後 Colyseus または fly.io に移行する場合も、変更するのはこのファイルだけです。
  5. 費用 — Cloudflare Workers の無料枠で無料です(1 日あたり 100k リクエスト、DO ストレージ 1GB)。デプロイした Cloudflare プロジェクトはユーザーが所有することを伝えます。PartyKit は OpusGameLabs のアカウントではなく、ユーザーの CF アカウントにデプロイされます。

使用例

デフォルト(現在のディレクトリでリアルタイムモード)
/add-multiplayer

結果:エンジンを検出し、リアルタイムテンプレートを使用して multiplayer-server/ を生成し、クライアントのネットワークファイルを作成し、サーバーをデプロイしてからクライアントを再デプロイし、プレイ URL とサーバー URL を出力します。

ターン制を明示する場合
/add-multiplayer ./examples/card-game --mode=turn-based

結果:ターン制サーバーテンプレートを使用します。NetworkManager は位置情報をブロードキャストするティックを実行せず、ゲーム既存の移動イベントを転送します。

詳細表示のドライランで確認する場合
/add-multiplayer --dry-run

結果:書き込みやデプロイを行わずに、ファイル一覧とパッチの全内容を出力します。コミット前のレビューに便利です。

トラブルシューティング

npx partykit login が dashboard.partykit.io/patience にリダイレクトされ、完了しない

原因: Cloudflare が PartyKit を吸収した後、デフォルトの clerk プロバイダーは廃止されました。OAuth コールバックが想定するダッシュボードは存在しません。 対処法: 代わりに npx partykit login --provider github を使用します。GitHub のデバイスコードフローでコードが表示されるので、https://github.com/login/device に貼り付けます。認証情報は ~/.partykit/config.json に保存されます。

接続に成功しているのにリモートプレイヤーが表示されない

原因: ウェルカムイベントの競合です。シーンの create() で NETWORK_PLAYER_JOINED リスナーが登録される前に、WebSocket の welcome が届きました。イベントはどこにも届かずに発生しました。 対処法: リスナーを登録した後、gameState.multiplayer.remotePlayers から直接初期化します。冪等なパターンについては client-integration.md の「Welcome-race gotcha」を参照してください。

2 つのタブが接続するものの、互いを認識しない

原因: 異なるルームに参加している(URL の解析でランダムなルーム ID が割り当てられている)か、サーバーのブロードキャスト処理がデフォルトで送信者を除外しています。 解決方法: 両方のタブで window.render_game_to_text().multiplayer.roomId のルーム ID を確認してください。同じ値(デフォルトは 'lobby')である必要があります。異なる場合は、NetworkManager.connect() に余計なクエリー文字列の解析処理がないか確認してください。サーバーテンプレートの room.broadcast(message, [sender.id]) は送信者を除外していますが、これは正しい動作です。各クライアントが描画するのは、自分自身ではなく リモート プレイヤーだけです。

ピアがタブを閉じると、リモートプレイヤーが最後の位置で止まる

原因: onClose が呼び出されなかった(ブラウザーが正常に閉じずにタブを終了した)か、クライアントで RemotePlayerRegistry.prune() が実行されていません。 解決方法: サーバーの onClose ハンドラーは、正規の「プレイヤー退出」シグナルです。さらに、NetworkManager は毎ティック RemotePlayerRegistry.prune(STALE_PLAYER_MS) を実行します。これが正しく組み込まれていることを確認してください。リモートプレイヤーから STALE_PLAYER_MS の間ステートが届かなければ、明示的な切断がなくても prune が network:player-left を発行します。

リモートプレイヤーが多いとゲームの動作が遅くなったり、カクついたりする

原因: TICK_RATE_HZ が高すぎる(1 秒間に 60 回ブロードキャストと描画を行っている)か、シーンがリモートプレイヤーのスプライトを再利用せず、フレームごとに作り直しています。 解決方法: TICK_RATE_HZ を 20(デフォルト)またはゲームの動作が遅い場合は 10 に下げてください。シーンが Map<playerId, sprite> を保持し、network:state-received で位置だけを更新し、スプライトを再作成しないことを確認してください。

マルチプレイヤー機能の追加後、シングルプレイヤーテストに失敗する

原因: サーバーに到達できない場合に NetworkManager が例外を投げるか、ゲームの起動を妨げています。これは原則 1 に反します。 解決方法: MultiplayerClient.connect() と NetworkManager.init() を確認してください。どちらもすべてのエラーを捕捉し、警告を記録して network:disconnected を発行し、処理を戻す必要があります。コンストラクターと init() は、main.js に例外を伝播させてはなりません。

render_game_to_text() のスナップショットテストに失敗する

原因: テストで出力に対して完全一致の toEqual を使用しており、新しいトップレベルフィールドを追加しています。 解決方法: ベースラインを再生成してください。追加は意図したものであり、後方互換性があります。追加されたフィールドは multiplayer(オブジェクト)と remotePlayers(空の場合もある配列)です。

Cloudflare へのデプロイは成功するが、ブラウザーで WebSocket 接続に失敗する

原因: 混在コンテンツ(HTTP ページから WSS サーバーへの接続)か、サーバー URL に https:// スキームが付いていません。 解決方法: Constants.MULTIPLAYER.SERVER_URL に https://...partykit.dev という完全な URL が設定されていることを確認してください。partysocket はスキームを置き換えて WSS URL を導出します。WSS 接続を成功させるには、デプロイしたゲームも HTTPS で配信する必要があります(here.now と GitHub Pages はどちらもデフォルトで HTTPS を使用します)。

無料プランのレート制限に達した

原因: 同時に存在するルームが多すぎるか、クライアントの通信頻度が高すぎます。 解決方法: Cloudflare Workers の無料プランでは、1 日あたり 100k リクエストが許可されています。20 Hz で動作するクライアントの各ティックは 1 リクエストです。そのため、プレイヤー 1 人が 24 時間休まずプレイすると、1.7M / day になります。プロトタイピングで上限に達することはありませんが、本番環境では TICK_RATE_HZ を下げるか、Workers Paid にアップグレードしてください(1 日あたり 10M リクエストで月額 $5 の定額)。

ヒント

ゲームごとに /add-multiplayer を1回実行してください。後からモードを変更する場合は、multiplayer-server/src/server.ts を直接編集してください。両方のテンプレートはリポジトリに含まれており、切り替えは簡単です。

デフォルトの 'lobby' ルームは、単一の公開ルームに適しています。プライベートルームをサポートするには、URL のクエリー文字列または招待コードから取得したルーム ID を指定して、multiplayer:join-room を送信してください。NetworkManager はそのイベントをリッスンし、新しいルームに再接続します。

より機能豊富なバックエンド(マッチメイキング、スキーマ同期、サーバー権限の物理演算)に移行する場合、変更が必要なのは src/multiplayer/MultiplayerClient.js だけです。partysocket の呼び出しを Colyseus の colyseus.js クライアントに置き換え、同じパブリック API(connect、send、onMessage、disconnect、isConnected)を維持してください。

サーバーは OpusGameLabs ではなく、お客様の Cloudflare アカウントで実行されます。費用と利用枠はお客様に発生します。PartyKit 自体はオープンソースで無料です。お支払いいただくのは Cloudflare の従量制料金のみです(無料枠には十分な余裕があります)。

コメント