add-multiplayer
Add real-time or turn-based multiplayer to an existing browser game using PartyKit (Cloudflare Durable Objects). Scaffolds a room-based server, a NetworkManager client, EventBus events, GameState fields, Constants, and extends render_game_to_text(). Use when the user says "add multiplayer", "make th
By playableintelligence · 470 installs
npx skills add playableintelligence/game-creator --skill add-multiplayer
Source repository · Upstream listing
Add Multiplayer (PartyKit / Cloudflare Durable Objects)
Add real time or turn based multiplayer to an existing single player browser game. This skill scaffolds:
1. A PartyKit server (one Durable Object per room) deployed to Cloudflare's edge.
2. A client NetworkManager wired through EventBus that mirrors the existing playfun.js external service pattern.
3. Additive edits to EventBus , GameState , Constants , and render game to text() — 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 network:player joined and synchronize via network:state received .
Reference Files
architecture.md — event taxonomy, GameState schema, NetworkManager contract, Phaser vs Three.js placement notes.
partykit server.md — server templates ( realtime.ts and turn based.ts ), state shape, broadcast helpers, rate limiting.
client integration.md — MultiplayerClient , NetworkManager , RemotePlayerRegistry source, EventBus/GameState/Constants append patterns, render game to text extension.
deploy.md — npx partykit dev and npx partykit deploy walkthrough, capturing the deployed URL, .env handling, and client redeploy.
Core Principles
These are rules, not guidelines:
1. Single player must work offline. With the server unreachable, the game must boot, play, and reset normally. NetworkManager catches all connection errors and emits network:disconnected instead of throwing.
2. Additive edits only. Append to EventBus.js , GameState.js , Constants.js , main.js , and render game to text() under a // === Multiplayer === banner. Never rename, remove, or change existing fields.
3. 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).
4. Server is authoritative, but tolerant. The PartyKit room owns the canonical room state. Clients send intents; the server validates and broadcasts. In realtime mode validation is light (last write wins). In turn based mode validation is strict (rejects out of turn moves).
5. Backend agnostic client API. All partysocket calls go through MultiplayerClient . If a future user wants Colyseus or fly.io+ws, only MultiplayerClient.js changes — game code does not.
6. Default room is 'lobby' . No matchmaking UI in v1. Users override by emitting multiplayer:join room with a custom room id.
Prerequisites
An existing Phaser 3 or Three.js game scaffolded with this plugin (has src/core/EventBus.js , src/core/GameState.js , src/core/Constants.js , src/main.js with window.render game to text() ).
Node.js 18+.
A Cloudflare account for npx partykit deploy (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 $ARGUMENTS (or the current directory if no path given). Optional mode=realtime (default) or mode=turn based chooses the server template.
Step 0: Locate and read the game
Parse $ARGUMENTS for the game path and mode flag. If no path, use cwd. Verify it's a game by reading package.json and confirming Phaser or Three.js dependency.
Read these files in full before touching anything:
package.json — engine + scripts.
src/main.js — orchestrator, window.render game to text() , window.advanceTime() .
src/core/EventBus.js — exact event names already in use.
src/core/GameState.js — current state shape and reset() semantics.
src/core/Constants.js — config block conventions.
progress.md if present — pipeline context.
Then tell the creator one sentence confirming what you saw:
Game is <engine with <N events and a <player bird ship entity. I'll add a multiplayer layer that broadcasts the local <entity 's state at TICK RATE HZ 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
realtime (default) Action games, runners, dodgers, platformers, anything with continuous movement Local setInterval at TICK RATE HZ broadcasts the local entity's {x, y, [z], score, state} ; server fans out; clients render last known remote state
turn based Card games, board games, puzzles, anything with discrete moves EventBus events ( player:moved , player:played card ) forward as {type, payload} messages; server validates and broadcasts; clients apply on network:state received
If the user did not pass mode , infer from the game's existing events. If you see continuous position events ( bird:flap , player:moved , position updating physics), use realtime . If you see discrete actions ( card:played , move:submitted ), use turn based . State the choice and proceed.
Step 2: Scaffold the server
Create a sibling multiplayer server/ directory inside the game project. See partykit server.md for the full template content.
Create:
multiplayer server/partykit.json — manifest with name (use the game's directory name), main: "src/server.ts" , compatibilityDate .
multiplayer server/package.json — partykit dep, dev / deploy scripts.
multiplayer server/tsconfig.json — minimal TypeScript config that PartyKit accepts.
multiplayer server/src/server.ts — paste the appropriate template from partykit server.md ( realtime or turn based ).
multiplayer server/.gitignore — node modules , .partykit .
Run cd multiplayer server && npm install to install partykit (which provides partysocket for the client too via npm workspaces, but we'll add partysocket explicitly to the client).
Step 3: Scaffold the client
Create three new files. See client integration.md for the full source.
src/multiplayer/MultiplayerClient.js — backend agnostic interface around partysocket ( connect , send , onMessage , disconnect , isConnected ).
src/multiplayer/RemotePlayerRegistry.js — Map<playerId, RemotePlayer with upsert , remove , prune(staleMs) , list() .
src/systems/NetworkManager.js — wires MultiplayerClient ↔ EventBus, owns the broadcast tick (in realtime mode), handles reconnect with exponential backoff, emits network: events.
Add partysocket to the game's package.json deps:
Step 4: Append to existing core files
Make additive edits only. See architecture.md for full schemas and client integration.md for the exact append blocks.
src/core/EventBus.js — append under // === Multiplayer === banner:
src/core/GameState.js — append a multiplayer field with persistent ( roomId , playerId ) and transient ( connected , remotePlayers ) parts. Update reset() to clear only the transient parts so rejoin works after a game restart.
src/core/Constants.js — append a MULTIPLAYER block with SERVER URL (filled by Step 6), DEFAULT ROOM , MAX PLAYERS , TICK RATE HZ , reconnect backoff, stale player threshold, PROTOCOL VERSION . No magic numbers — every value is a named constant.
src/main.js — instantiate NetworkManager after EventBus + GameState, before the engine starts. Expose window. NETWORK MANAGER for tests. Extend window.render game to text() to additively include multiplayer: {...} and remotePlayers: [...] .
Step 5: Wire the local game into the network tick
Inspect existing events. The wiring depends on mode:
realtime : NetworkManager owns a setInterval at TICK RATE HZ . Each tick it reads the local entity from GameState and calls client.send({type: 'state', payload: {...}}) . No EventBus subscription needed — it just samples GameState. Add a single network:state received listener in the relevant scene/system that calls RemotePlayerRegistry.upsert() and triggers a re render.
turn based : NetworkManager subscribes to the game's existing move events (e.g., card:played , move:submitted ) and forwards them. The scene/system listens for network:state received 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 network:player joined , update positions on network:state received , destroy on network:player left . In Three.js games, the active orchestrator ( Game.js ) creates and updates remote player meshes.
See client integration.md for example scene patches for both engines.
Step 6: Deploy the server
Run the dev server first to confirm everything works locally:
This starts a local CF Worker emulator on http://127.0.0.1:1999 . In another terminal, set VITE MULTIPLAYER SERVER URL=http://127.0.0.1:1999 in <game path /.env and run the client ( cd <game path && npm run dev ).
For first time deployment, the user must authenticate with PartyKit. Always pass provider github — the default clerk flow is broken in 2026 (the dashboard.partykit.io callback was retired after Cloudflare absorbed PartyKit, and login hangs forever):
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 ~/.partykit/config.json . See deploy.md for the full walkthrough and troubleshooting.
After login, 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:
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
Add .env to .gitignore if not already present.
See deploy.md for the full walkthrough including offline first authentication and troubleshooting.
Step 7: Redeploy the client
Reuse the existing host detection logic (same as monetize game Step 5):
1. If .herenow/state.json exists → redeploy via ~/.agents/skills/here now/scripts/publish.sh dist/ .
2. Else if gh is configured and the repo has a GitHub Pages workflow → npx gh pages d dist .
3. Else if vercel is configured → vercel prod .
4. Else ask the user how they want to redeploy.
Always run npm run build first.
Step 8: Verify
Build cleanly:
Single player fallback (critical): with the partykit dev server stopped, reload http://localhost:3000 . The game must boot, play, and reset normally. Confirm network:disconnected 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 npx partykit dev in one terminal and npm run dev in another. Open two browser tabs at http://localhost:3000 . Confirm:
Both tabs fire network:connected (check console).
Each tab's window.render game to text() includes the other tab in remotePlayers .
Moving the local entity in tab A is reflected in tab B's remote player rendering within 1000 / TICK RATE HZ 2 ms.
Reconnect: kill the partykit dev server, wait, restart it. The client should reconnect within RECONNECT MAX BACKOFF MS and re emit network:connected .
Regression: existing tests/e2e/ .spec.js must still pass. Single player invariants (boot, score, game over, reset) must hold whether the server is up or down.
Step 9: Update progress.md
Append a Multiplayer section:
Output
Tell the user:
1. What was added — server in multiplayer server/ , client in src/multiplayer/ + src/systems/NetworkManager.js , additive edits to four core files.
2. The server