Add Multiplayer (PartyKit / Cloudflare Durable Objects)
Add real-time or turn-based multiplayer to an existing single-player browser game. This skill scaffolds:
- A PartyKit server (one Durable Object per room) deployed to Cloudflare's edge.
- A client wired through EventBus that mirrors the existing external-service pattern.
- Additive edits to , , , and — single-player gameplay must remain identical when the server is unreachable.
The default state is "single-player works." If the WebSocket connection fails, NetworkManager swallows the error and the game runs locally as before. When connected, remote players appear via
and synchronize via
.
Reference Files
- — event taxonomy, GameState schema, NetworkManager contract, Phaser vs Three.js placement notes.
- — server templates ( and ), state shape, broadcast helpers, rate limiting.
- — , , source, EventBus/GameState/Constants append patterns, extension.
- — and walkthrough, capturing the deployed URL, handling, and client redeploy.
Core Principles
These are rules, not guidelines:
- Single-player must work offline. With the server unreachable, the game must boot, play, and reset normally. NetworkManager catches all connection errors and emits instead of throwing.
- Additive edits only. Append to , , , , and under a banner. Never rename, remove, or change existing fields.
- EventBus is the only seam. NetworkManager talks to the rest of the game through events — no direct imports from scenes, systems, or entities into NetworkManager (or vice versa).
- Server is authoritative, but tolerant. The PartyKit room owns the canonical room state. Clients send intents; the server validates and broadcasts. In mode validation is light (last-write-wins). In mode validation is strict (rejects out-of-turn moves).
- Backend-agnostic client API. All calls go through . If a future user wants Colyseus or fly.io+ws, only changes — game code does not.
- Default room is . No matchmaking UI in v1. Users override by emitting with a custom room id.
Prerequisites
- An existing Phaser 3 or Three.js game scaffolded with this plugin (has , , , with
window.render_game_to_text()
).
- Node.js 18+.
- A Cloudflare account for (the CLI walks the user through login on first deploy; free tier is sufficient for prototyping).
Instructions
The user wants to add multiplayer to the game at
(or the current directory if no path given). Optional
(default) or
chooses the server template.
Step 0: Locate and read the game
Parse
for the game path and
flag. If no path, use cwd. Verify it's a game by reading
and confirming Phaser or Three.js dependency.
Read these files in full before touching anything:
- — engine + scripts.
- — orchestrator,
window.render_game_to_text()
, .
- — exact event names already in use.
- — current state shape and semantics.
- — config block conventions.
- if present — pipeline context.
Then tell the creator one sentence confirming what you saw:
Game is
with
events and a
entity. I'll add a multiplayer layer that broadcasts the local
's state at
and renders remote players from server broadcasts. Single-player will continue to work when the server is offline.
Step 1: Choose sync mode
Pick the server template:
| Mode | When to use | Wire model |
|---|
| (default) | Action games, runners, dodgers, platformers, anything with continuous movement | Local at broadcasts the local entity's {x, y, [z], score, state}
; server fans out; clients render last-known remote state |
| Card games, board games, puzzles, anything with discrete moves | EventBus events (, ) forward as messages; server validates and broadcasts; clients apply on |
If the user did not pass
, infer from the game's existing events. If you see continuous-position events (
,
, position-updating physics), use
. If you see discrete actions (
,
), use
. State the choice and proceed.
Step 2: Scaffold the server
Create a sibling
directory inside the game project. See
for the full template content.
Create:
multiplayer-server/partykit.json
— manifest with (use the game's directory name), , .
multiplayer-server/package.json
— dep, / scripts.
multiplayer-server/tsconfig.json
— minimal TypeScript config that PartyKit accepts.
multiplayer-server/src/server.ts
— paste the appropriate template from ( or ).
multiplayer-server/.gitignore
— , .
Run
cd multiplayer-server && npm install
to install
(which provides
for the client too via npm workspaces, but we'll add
explicitly to the client).
Step 3: Scaffold the client
Create three new files. See
for the full source.
src/multiplayer/MultiplayerClient.js
— backend-agnostic interface around (, , , , ).
src/multiplayer/RemotePlayerRegistry.js
— Map<playerId, RemotePlayer>
with , , , .
src/systems/NetworkManager.js
— wires MultiplayerClient ↔ EventBus, owns the broadcast tick (in mode), handles reconnect with exponential backoff, emits events.
bash
cd <game-path> && npm install partysocket
Step 4: Append to existing core files
Make additive edits only. See
for full schemas and
for the exact append blocks.
js
// === 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',
— append a
field with persistent (
,
) and transient (
,
) parts. Update
to clear only the transient parts so rejoin works after a game restart.
— append a
block with
(filled by Step 6),
,
,
, reconnect backoff, stale-player threshold,
. No magic numbers — every value is a named constant.
— instantiate NetworkManager after EventBus + GameState, before the engine starts. Expose
window.__NETWORK_MANAGER__
for tests. Extend
window.render_game_to_text()
to additively include
and
.
Step 5: Wire the local game into the network tick
Inspect existing events. The wiring depends on mode:
: NetworkManager owns a
at
. Each tick it reads the local entity from
and calls
client.send({type: 'state', payload: {...}})
. No EventBus subscription needed — it just samples GameState. Add a single
listener in the relevant scene/system that calls
RemotePlayerRegistry.upsert()
and triggers a re-render.
: NetworkManager subscribes to the game's existing move events (e.g.,
,
) and forwards them. The scene/system listens for
and applies the validated remote move. Local optimistic UI is allowed, but the server is the source of truth.
In Phaser games, remote-player rendering happens in the active GameScene — instantiate sprites on
, update positions on
, destroy on
. In Three.js games, the active orchestrator (
) creates and updates remote-player meshes.
See
for example scene patches for both engines.
Step 6: Deploy the server
Run the dev server first to confirm everything works locally:
bash
cd <game-path>/multiplayer-server && npx partykit dev
This starts a local CF Worker emulator on
. In another terminal, set
VITE_MULTIPLAYER_SERVER_URL=http://127.0.0.1:1999
in
and run the client (
cd <game-path> && npm run dev
).
For first-time deployment, the user must authenticate with PartyKit.
Always pass — the default
flow is broken in 2026 (the dashboard.partykit.io callback was retired after Cloudflare absorbed PartyKit, and login hangs forever):
bash
cd <game-path>/multiplayer-server && npx partykit login --provider github
This uses GitHub's device-code OAuth flow. The CLI prints a code; the user visits
https://github.com/login/device
, pastes it, and authorizes. Credentials persist in
. See
for the full walkthrough and troubleshooting.
After login, deploy:
bash
cd <game-path>/multiplayer-server && npx partykit deploy
Capture the deployed URL from the output (format:
https://<project>.<cloudflare-username>.partykit.dev
). The TLS cert may take 30-60 seconds to provision after the deploy reports success.
Update three places with the deployed URL:
- →
- →
VITE_MULTIPLAYER_SERVER_URL=https://...
- →
VITE_MULTIPLAYER_SERVER_URL=https://your-project.your-username.partykit.dev
Add
to
if not already present.
See
for the full walkthrough including offline-first authentication and troubleshooting.
Step 7: Redeploy the client
Reuse the existing host detection logic (same as
Step 5):
- If exists → redeploy via
~/.agents/skills/here-now/scripts/publish.sh dist/
.
- Else if is configured and the repo has a GitHub Pages workflow → .
- Else if is configured → .
- Else ask the user how they want to redeploy.
Step 8: Verify
Build cleanly:
bash
cd <game-path> && npm run build
cd multiplayer-server && npm run build # if a build script exists
Single-player fallback (critical): with the partykit dev server stopped, reload
. The game must boot, play, and reset normally. Confirm
fired and no uncaught errors in the console.
If the game depends on the server to start, you violated Principle 1 — revise.
Two-tab smoke test: start
in one terminal and
in another. Open two browser tabs at
. Confirm:
- Both tabs fire (check console).
- Each tab's
window.render_game_to_text()
includes the other tab in .
- Moving the local entity in tab A is reflected in tab B's remote-player rendering within ms.
Reconnect: kill the partykit dev server, wait, restart it. The client should reconnect within
and re-emit
.
Regression: existing
must still pass. Single-player invariants (boot, score, game-over, reset) must hold whether the server is up or down.
Step 9: Update
markdown
## 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.
Output
Tell the user:
- What was added — server in , client in +
src/systems/NetworkManager.js
, additive edits to four core files.
- The server URL —
https://<project>.<user>.partykit.dev
. Already wired into Constants and .
- How to test locally —
cd multiplayer-server && npx partykit dev
then , open two tabs.
- The single seam for backend swaps — point at
src/multiplayer/MultiplayerClient.js
. Future Colyseus or fly.io migration only changes that one file.
- Costs — free on Cloudflare's Workers free tier (100k requests/day, 1GB DO storage). Mention the user owns the deployed Cloudflare project; PartyKit deploys to their CF account, not OpusGameLabs'.
Example Usage
Default (realtime mode in current directory)
Result: detects engine, scaffolds
with the realtime template, creates client networking files, deploys server, redeploys client, prints play URL and server URL.
Turn-based explicit
/add-multiplayer ./examples/card-game --mode=turn-based
Result: uses the turn-based server template; NetworkManager forwards the game's existing move events instead of running a position-broadcast tick.
Verbose dry-run for inspection
/add-multiplayer --dry-run
Result: prints the full file list and patches without writing or deploying. Useful for review before committing.
Troubleshooting
redirects to dashboard.partykit.io/patience
and never completes
Cause: The default
provider was retired after Cloudflare absorbed PartyKit; the dashboard the OAuth callback expects is gone.
Fix: Use
npx partykit login --provider github
instead — GitHub device-code flow, prints a code, you paste at
https://github.com/login/device
. Credentials persist in
.
Remote players don't appear even though the connection succeeded
Cause: Welcome-race — the WebSocket
arrived before the scene's
registered its
listener. The events fired into the void.
Fix: After registering the listener, seed from
gameState.multiplayer.remotePlayers
directly. See
→ "Welcome-race gotcha" for the idempotent pattern.
Two tabs connect but never see each other
Cause: They joined different rooms (random room IDs from URL parsing) or the server's broadcast logic excludes the sender by default.
Fix: Check the room id in
window.render_game_to_text().multiplayer.roomId
on both tabs — it should be the same (default
). If different, audit
for stray query-string parsing. The server template's
room.broadcast(message, [sender.id])
excludes the sender, which is correct — each client renders only
remote players, not itself.
Remote players appear stuck at last position when a peer closes the tab
Cause: did not fire (browser killed the tab without a clean close), or the client did not run
RemotePlayerRegistry.prune()
.
Fix: The server's
handler is the canonical "player left" signal. Additionally, NetworkManager runs
RemotePlayerRegistry.prune(STALE_PLAYER_MS)
on every tick — verify this is wired. If a remote player has not sent state in
, prune emits
even without an explicit close.
Game lags or stutters when many remote players are present
Cause: Either too-high
(you're broadcasting and rendering 60 times per second) or the scene re-creates remote-player sprites every frame instead of reusing them.
Fix: Lower
to 20 (default) or 10 for slow games. Confirm scenes maintain a
and only update positions on
, never recreate.
Single-player tests fail after adding multiplayer
Cause: NetworkManager throws or blocks game boot when the server is unreachable. This violates Principle 1.
Fix: Audit
MultiplayerClient.connect()
and
— both must catch all errors, log a warning, emit
, and return. The constructor and
must never throw out of
.
snapshot tests fail
Cause: Tests use exact
on the output; you added new top-level fields.
Fix: Regenerate baselines — additions are intentional and backward-compatible. The fields added are
(object) and
(array, may be empty).
Cloudflare deploy succeeds but the WebSocket fails in the browser
Cause: Mixed content (HTTP page → WSS server) or the server URL was written without the
scheme.
Fix: Confirm
Constants.MULTIPLAYER.SERVER_URL
is the full
URL.
derives the WSS URL by replacing the scheme. The deployed game must also be served over HTTPS for the WSS connection to succeed (here.now and GitHub Pages both serve HTTPS by default).
Free tier rate limit hit
Cause: Many concurrent rooms or chatty clients.
Fix: Cloudflare's Workers free tier allows 100k requests/day. Each client tick at 20 Hz is one request — that's
for a single 24/7 player. For prototyping you'll never hit this; for production, lower
or upgrade to Workers Paid ($5/mo flat for 10M requests/day).
Tips
Run
once per game. If you later change modes, edit
multiplayer-server/src/server.ts
directly — both templates are checked in and the switch is small.
The default
room is suitable for a single open room. To support private rooms, emit
with a room id from a URL query string or invite code. NetworkManager listens for that event and reconnects to the new room.
For graduation to a more featureful backend (matchmaking, schema sync, server-authoritative physics), the only file that needs to change is
src/multiplayer/MultiplayerClient.js
. Replace the
calls with Colyseus's
client; keep the same public API (
,
,
,
,
).
The server runs in your Cloudflare account, not OpusGameLabs'. Costs and quotas accrue to you. PartyKit itself is open source and free; you only pay Cloudflare's pass-through pricing (free tier is generous).