
Add Multiplayer
- 170 installs
- 305 repo stars
- Updated May 25, 2026
- opusgamelabs/game-creator
Add real-time or session-based multiplayer networking, sync, and matchmaking to opusgamelabs game-creator titles.
About
Implements multiplayer for game-creator projects by configuring networking stacks, synchronizing player state, managing sessions or rooms, and integrating backend services so co-op or competitive play works reliably.
- State sync
- Room hosting
- Netcode setup
- Latency handling
- Matchmaking
Add Multiplayer by the numbers
- 170 all-time installs (skills.sh)
- +13 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #99 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/opusgamelabs/game-creator --skill add-multiplayerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 170 |
|---|---|
| repo stars | ★ 305 |
| Last updated | May 25, 2026 |
| Repository | opusgamelabs/game-creator ↗ |
What it does
Add real-time or session-based multiplayer networking, sync, and matchmaking to opusgamelabs game-creator titles.
Files
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.tsandturn-based.ts), state shape, broadcast helpers, rate limiting.client-integration.md—MultiplayerClient,NetworkManager,RemotePlayerRegistrysource, EventBus/GameState/Constants append patterns,render_game_to_textextension.deploy.md—npx partykit devandnpx partykit deploywalkthrough, capturing the deployed URL,.envhandling, 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.jswithwindow.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 andreset()semantics.src/core/Constants.js— config block conventions.progress.mdif 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 atTICK_RATE_HZand 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 withname(use the game's directory name),main: "src/server.ts",compatibilityDate.multiplayer-server/package.json—partykitdep,dev/deployscripts.multiplayer-server/tsconfig.json— minimal TypeScript config that PartyKit accepts.multiplayer-server/src/server.ts— paste the appropriate template frompartykit-server.md(realtimeorturn-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 aroundpartysocket(connect,send,onMessage,disconnect,isConnected).src/multiplayer/RemotePlayerRegistry.js—Map<playerId, RemotePlayer>withupsert,remove,prune(staleMs),list().src/systems/NetworkManager.js— wires MultiplayerClient ↔ EventBus, owns the broadcast tick (inrealtimemode), handles reconnect with exponential backoff, emitsnetwork:*events.
Add partysocket to the game's package.json deps:
cd <game-path> && npm install partysocketStep 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:
// === 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` — 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:
cd <game-path>/multiplayer-server && npx partykit devThis 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):
cd <game-path>/multiplayer-server && npx partykit login --provider githubThis 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:
cd <game-path>/multiplayer-server && npx partykit deployCapture 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:
cd <game-path> && npm run build
cd multiplayer-server && npm run build # if a build script existsSingle-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 inremotePlayers. - Moving the local entity in tab A is reflected in tab B's remote-player rendering within
1000 / TICK_RATE_HZ * 2ms.
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:
## 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:
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 URL — https://<project>.<user>.partykit.dev. Already wired into Constants and .env. 3. How to test locally — cd multiplayer-server && npx partykit dev then npm run dev, open two tabs. 4. The single seam for backend swaps — point at src/multiplayer/MultiplayerClient.js. Future Colyseus or fly.io migration only changes that one file. 5. 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)
/add-multiplayerResult: detects engine, scaffolds multiplayer-server/ 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-basedResult: 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-runResult: prints the full file list and patches without writing or deploying. Useful for review before committing.
Troubleshooting
npx partykit login redirects to dashboard.partykit.io/patience and never completes
Cause: The default clerk 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 ~/.partykit/config.json.
Remote players don't appear even though the connection succeeded
Cause: Welcome-race — the WebSocket welcome arrived before the scene's create() registered its NETWORK_PLAYER_JOINED listener. The events fired into the void. Fix: After registering the listener, seed from gameState.multiplayer.remotePlayers directly. See client-integration.md → "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 'lobby'). If different, audit NetworkManager.connect() 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: onClose did not fire (browser killed the tab without a clean close), or the client did not run RemotePlayerRegistry.prune(). Fix: The server's onClose 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 STALE_PLAYER_MS, prune emits network:player-left even without an explicit close.
Game lags or stutters when many remote players are present
Cause: Either too-high TICK_RATE_HZ (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 TICK_RATE_HZ to 20 (default) or 10 for slow games. Confirm scenes maintain a Map<playerId, sprite> and only update positions on network:state-received, 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 NetworkManager.init() — both must catch all errors, log a warning, emit network:disconnected, and return. The constructor and init() must never throw out of main.js.
render_game_to_text() snapshot tests fail
Cause: Tests use exact toEqual on the output; you added new top-level fields. Fix: Regenerate baselines — additions are intentional and backward-compatible. The fields added are multiplayer (object) and remotePlayers (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 https:// scheme. Fix: Confirm Constants.MULTIPLAYER.SERVER_URL is the full https://...partykit.dev URL. partysocket 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 1.7M / day for a single 24/7 player. For prototyping you'll never hit this; for production, lower TICK_RATE_HZ or upgrade to Workers Paid ($5/mo flat for 10M requests/day).
Tips
Run/add-multiplayeronce per game. If you later change modes, editmultiplayer-server/src/server.tsdirectly — both templates are checked in and the switch is small.
>
The default'lobby'room is suitable for a single open room. To support private rooms, emitmultiplayer:join-roomwith 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 issrc/multiplayer/MultiplayerClient.js. Replace thepartysocketcalls with Colyseus'scolyseus.jsclient; keep the same public API (connect,send,onMessage,disconnect,isConnected).
>
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).
Multiplayer Architecture
How the multiplayer layer fits into the game-creator architecture (EventBus, GameState, Constants, render_game_to_text, single-orchestrator).
Layered View
┌────────────────────────────────────────────────────────────────┐
│ Phaser Scene / Three.js Game.js (orchestrator) │
│ • renders local entity │
│ • renders remote players from RemotePlayerRegistry │
│ • emits local game events (e.g. player:moved, card:played) │
└────────────────────┬───────────────────────────────────────────┘
│ EventBus only — no direct imports
▼
┌────────────────────────────────────────────────────────────────┐
│ src/systems/NetworkManager.js │
│ • subscribes to local game events (turn-based) │
│ OR samples GameState at TICK_RATE_HZ (realtime) │
│ • forwards via MultiplayerClient │
│ • on receive → updates RemotePlayerRegistry │
│ • emits network:* events for the rest of the game │
└────────────────────┬───────────────────────────────────────────┘
│ wraps partysocket
▼
┌────────────────────────────────────────────────────────────────┐
│ src/multiplayer/MultiplayerClient.js │
│ • backend-agnostic API: connect / send / onMessage / │
│ disconnect / isConnected │
│ • the only file that knows about partysocket │
└────────────────────┬───────────────────────────────────────────┘
│ WSS
▼
┌────────────────────────────────────────────────────────────────┐
│ PartyKit Room (Cloudflare Durable Object, one per roomId) │
│ • onConnect / onMessage / onClose │
│ • room.broadcast(message, [excludeIds]) │
│ • per-connection rate limiting │
└────────────────────────────────────────────────────────────────┘The RemotePlayerRegistry lives client-side as a plain Map. Scenes/systems read from it (or listen to network:* events) to render remote entities. They never call MultiplayerClient or the server directly.
Event Taxonomy
All events use the existing domain:action form. Append these constants to src/core/EventBus.js under a // === Multiplayer === banner.
| Constant | Value | Direction | Payload | Purpose |
|---|---|---|---|---|
NETWORK_CONNECTED | network:connected | NM → game | { roomId, playerId } | Fired once MultiplayerClient.connect() succeeds |
NETWORK_DISCONNECTED | network:disconnected | NM → game | `{ reason: 'closed' \ | 'error' \ |
NETWORK_PLAYER_JOINED | network:player-joined | NM → game | { playerId, name? } | Server reported a new peer |
NETWORK_PLAYER_LEFT | network:player-left | NM → game | { playerId } | Server reported a peer left or peer pruned by stale-timeout |
NETWORK_STATE_RECEIVED | network:state-received | NM → game | { playerId, state } | Remote state delta, shape mode-dependent (see below) |
MULTIPLAYER_JOIN_ROOM | multiplayer:join-room | game → NM | { roomId } | Request to leave current room and join roomId |
MULTIPLAYER_LEAVE_ROOM | multiplayer:leave-room | game → NM | {} | Request to disconnect cleanly |
The game emits MULTIPLAYER_JOIN_ROOM to switch rooms (e.g., a private match). NetworkManager listens, calls client.disconnect(), then client.connect(newRoomId).
state shape per mode
realtime mode — state carries position and minimal kinematics:
{
x: number,
y: number,
z?: number, // 3D games only
vx?: number, // optional velocity for client-side interpolation
vy?: number,
vz?: number,
score: number,
alive: boolean,
ts: number, // server tick timestamp (ms since epoch)
}The wire schema is open. Games may extend state with their own fields (e.g. rotation for top-down games, weapon for shooters). The server's isValidState only enforces the required core (x, y, score, alive) — unknown fields pass through to all peers untouched. This lets games add gameplay-specific state without forking the skill.
Coordinate space convention. Broadcast positions in design pixels (the canvas-independent logical coordinate system the game's Constants are written in), not raw canvas pixels. Receivers multiply by their own PX when applying. This makes the wire format independent of each client's window size / DPR — without it, two clients with different viewports see each other's positions at proportionally wrong locations. The local entity's tank.x is typically in canvas pixels; divide by PX before sending and multiply by PX on receive.
turn-based mode — state carries the move and the resulting authoritative state:
{
type: string, // 'move' | 'card-played' | game-defined action name
payload: object, // game-defined move data
resultingState: object, // server's authoritative post-move state
turn: number, // monotonic move counter
ts: number,
}GameState Schema
Append to src/core/GameState.js:
multiplayer: {
// Persistent across reset() — preserved so /reset rejoins the same room
roomId: null,
playerId: null,
// Transient — cleared by reset()
connected: false,
remotePlayers: {}, // { [playerId]: { x, y, z?, score, name?, lastSeenTs } }
}reset() semantics:
reset() {
// ...existing single-player resets...
if (this.multiplayer) {
this.multiplayer.connected = false;
this.multiplayer.remotePlayers = {};
}
// roomId and playerId persist so the next session rejoins automatically
}Constructor ordering footgun. Many GameState classes call this.reset() from the constructor. If you naively put this.multiplayer = {...} after the this.reset() call, the first reset runs against undefined and crashes. Two safe patterns:
1. Initialize this.multiplayer = {...} before calling this.reset() in the constructor, OR 2. Guard the multiplayer block with if (this.multiplayer) (shown above) so the first reset is a no-op until the field exists.
Pattern 2 is simpler when patching existing GameState files additively.
remotePlayers is owned by RemotePlayerRegistry; GameState is the canonical home so render_game_to_text() can serialize it with one read.
Constants Schema
Detect the file's export convention before patching. Two shapes are common:
- Umbrella export —
export const Constants = { GAME: {...}, PLAYER: {...} }. Append a new top-level key inside the object:MULTIPLAYER: {...}. Consumers referenceConstants.MULTIPLAYER.X. - Per-block named exports —
export const GAME = {...}; export const PLAYER = {...};. Append a new sibling export:export const MULTIPLAYER = {...}. Consumers referenceMULTIPLAYER.Xdirectly.
Both are valid game-creator outputs. Read src/core/Constants.js first to decide which pattern to use, then mirror it in NetworkManager.js's import (import { Constants } from '...' vs import { MULTIPLAYER } from '...'). Don't force one onto the other.
The block to append (using umbrella shape; trivially adapts to per-block):
MULTIPLAYER: {
// Filled by the deploy step. Falls back to localhost in dev (see main.js).
SERVER_URL: 'https://<project>.<user>.partykit.dev',
DEFAULT_ROOM: 'lobby',
// Caps
MAX_PLAYERS: 4,
MAX_MESSAGE_BYTES: 4096,
// Realtime tick
TICK_RATE_HZ: 20, // 50ms broadcast cadence
STATE_INTERPOLATE_MS: 100, // smoothing window for remote positions
// Reconnect
RECONNECT_BACKOFF_MS: 1000,
RECONNECT_MAX_BACKOFF_MS: 16000,
RECONNECT_MAX_ATTEMPTS: 10,
// Stale player eviction (no state received for this long → emit player-left)
STALE_PLAYER_MS: 5000,
// Wire protocol version — bump on breaking server/client changes
PROTOCOL_VERSION: 1,
}The dev fallback in main.js reads import.meta.env.VITE_MULTIPLAYER_SERVER_URL first, then falls back to Constants.MULTIPLAYER.SERVER_URL. This lets local dev point at http://127.0.0.1:1999 without committing dev URLs to source.
NetworkManager Contract
Public API:
class NetworkManager {
constructor(eventBus, gameState, constants);
async init(roomId = constants.MULTIPLAYER.DEFAULT_ROOM);
destroy(); // disconnects, clears intervals, removes listeners
isConnected(); // boolean
getPlayerId(); // string | null
getRoomId(); // string | null
}Lifecycle:
1. Constructor stores references; subscribes to MULTIPLAYER_JOIN_ROOM and MULTIPLAYER_LEAVE_ROOM. Does not throw. 2. init(roomId) calls MultiplayerClient.connect(roomId). On success, emits NETWORK_CONNECTED, starts the broadcast tick (realtime mode only), and starts the STALE_PLAYER_MS prune interval. On failure, catches the error, emits NETWORK_DISCONNECTED { reason: 'error' }, and schedules a reconnect with exponential backoff. 3. MultiplayerClient.onMessage callback dispatches by message.type:
welcome { playerId, peers }→ setgameState.multiplayer.playerId, seedremotePlayerswith existing peers, emitNETWORK_CONNECTED.player-joined { playerId, name? }→ registry.upsert, emitNETWORK_PLAYER_JOINED.player-left { playerId }→ registry.remove, emitNETWORK_PLAYER_LEFT.state { playerId, state }→ registry.upsert, emitNETWORK_STATE_RECEIVED.
4. destroy() clears intervals, calls client.disconnect(), and removes EventBus listeners.
Error handling rule (Principle 1): every try/catch catches Error and logs a warning. The class never throws out of any public method. Single-player gameplay must work even if init() is called and fails.
Phaser Integration Pattern
In the active GameScene (the one that owns the local entity):
create() {
// ...existing creation...
this.remoteSprites = new Map(); // playerId → Phaser.Sprite
eventBus.on(Events.NETWORK_PLAYER_JOINED, ({ playerId }) => {
const sprite = this.add.sprite(0, 0, 'player');
sprite.setAlpha(0.7); // visual hint that this is a remote player
this.remoteSprites.set(playerId, sprite);
});
eventBus.on(Events.NETWORK_STATE_RECEIVED, ({ playerId, state }) => {
const sprite = this.remoteSprites.get(playerId);
if (!sprite) return; // sprite created on join only — race condition tolerant
// Wire format is design-pixel coordinates; multiply by PX to get the
// local canvas pixels. Without this, remote players land at the wrong
// position on any client whose DPR/viewport differs from the sender's.
sprite.setPosition(state.x * PX, state.y * PX);
if (typeof state.alive === 'boolean') sprite.setVisible(state.alive);
});
eventBus.on(Events.NETWORK_PLAYER_LEFT, ({ playerId }) => {
const sprite = this.remoteSprites.get(playerId);
if (sprite) {
sprite.destroy();
this.remoteSprites.delete(playerId);
}
});
}
shutdown() {
this.remoteSprites.forEach(s => s.destroy());
this.remoteSprites.clear();
// ...EventBus.off() for all subscriptions...
}For client-side interpolation in realtime mode, store { targetX, targetY, lastUpdateTs } on each sprite and lerp toward target in the scene's update() loop using STATE_INTERPOLATE_MS. See client-integration.md.
Three.js Integration Pattern
In the orchestrator (typically src/core/Game.js):
this.remoteMeshes = new Map(); // playerId → THREE.Object3D
eventBus.on(Events.NETWORK_PLAYER_JOINED, ({ playerId }) => {
const mesh = createRemotePlayerMesh(); // game-specific
mesh.userData.playerId = playerId;
this.scene.add(mesh);
this.remoteMeshes.set(playerId, mesh);
});
eventBus.on(Events.NETWORK_STATE_RECEIVED, ({ playerId, state }) => {
const mesh = this.remoteMeshes.get(playerId);
if (!mesh) return;
mesh.position.set(state.x, state.y, state.z ?? 0);
});
eventBus.on(Events.NETWORK_PLAYER_LEFT, ({ playerId }) => {
const mesh = this.remoteMeshes.get(playerId);
if (mesh) {
this.scene.remove(mesh);
mesh.geometry?.dispose();
mesh.material?.dispose();
this.remoteMeshes.delete(playerId);
}
});Three.js cleanup is more involved than Phaser — always dispose geometries and materials in network:player-left to avoid GPU memory leaks.
Sequence: Two clients in one room (realtime)
Client A PartyKit Room Client B
│ │ │
├── connect(room=lobby) ──────►│ │
│ (welcome: A-id, peers=[]) ◄┤ │
│ │◄──────── connect(room=lobby) ┤
│ │ (welcome: B-id, peers=[A-id])►
│ (player-joined: B-id) ◄────┤── (broadcast except B) ──────│
│ │ │
├── tick (state {x,y,...}) ───►│── (broadcast except A) ─────►│
│ │ (state from A) ◄──── │
│ │ │
│ │◄───── tick (state {...}) ────┤
│ (state from B) ◄───────────┤── (broadcast except B) ──────│
│ │ │
├── close ─────────────────────► │
│ ├──── (player-left: A-id) ────►│welcome is sent only to the joining socket and includes the current peer list. player-joined is broadcast to existing peers. player-left is broadcast on onClose.
Sequence: Turn-based move
Client A (mover) PartyKit Room Client B (waiter)
│ │ │
├── send {type:'move', │ │
│ payload: {card: 'K♠'}} ►│ │
│ │ validate(move) │
│ │ ├── valid → applyToRoomState │
│ │ ├── invalid → reject{reason} │
│ │ │
│ ◄── state { │ │
│ playerId: A, │ │
│ resultingState: ..., │ ── broadcast (incl. sender) ►│
│ turn: N+1 } ───────────┤ │
│ │ │Turn-based broadcasts include the sender — they need to confirm the server accepted their move. Validation rejection comes back as {type: 'reject', reason} to the sender only.
Client Integration
Source code for the three new client-side files plus the additive patches to existing core files.
File: src/multiplayer/MultiplayerClient.js
The single seam between game code and the wire protocol. If a future user wants Colyseus or fly.io+ws, only this file changes.
// src/multiplayer/MultiplayerClient.js
// Backend-agnostic WebSocket client. Wraps partysocket.
// Public API: connect, send, onMessage, onOpen, onClose, disconnect, isConnected.
// All errors are caught and routed through onClose/onError callbacks — never thrown.
import PartySocket from 'partysocket';
export class MultiplayerClient {
constructor() {
this.socket = null;
this.handlers = {
message: () => {},
open: () => {},
close: () => {},
error: () => {},
};
}
connect({ host, room }) {
if (this.socket) this.disconnect();
try {
this.socket = new PartySocket({ host, room });
this.socket.addEventListener('open', () => this.handlers.open());
this.socket.addEventListener('close', (e) => this.handlers.close(e));
this.socket.addEventListener('error', (e) => this.handlers.error(e));
this.socket.addEventListener('message', (e) => {
let parsed = null;
try { parsed = JSON.parse(e.data); } catch { return; }
this.handlers.message(parsed);
});
} catch (err) {
console.warn('[MultiplayerClient] connect failed', err);
this.handlers.error(err);
}
}
send(message) {
if (!this.isConnected()) return false;
try {
this.socket.send(JSON.stringify(message));
return true;
} catch (err) {
console.warn('[MultiplayerClient] send failed', err);
return false;
}
}
onMessage(cb) { this.handlers.message = cb; }
onOpen(cb) { this.handlers.open = cb; }
onClose(cb) { this.handlers.close = cb; }
onError(cb) { this.handlers.error = cb; }
isConnected() {
return this.socket?.readyState === 1; // WebSocket.OPEN
}
disconnect() {
if (!this.socket) return;
try { this.socket.close(); } catch { /* ignore */ }
this.socket = null;
}
}partysocket already implements automatic reconnect — but we run our own reconnect logic in NetworkManager so we can emit clear events and apply our own backoff. To disable partysocket's reconnect and rely solely on ours, pass startClosed: true to the constructor and call socket.reconnect() manually; for v1 we let partysocket handle reconnect and just observe via open/close events.
File: src/multiplayer/RemotePlayerRegistry.js
Owns the Map<playerId, RemotePlayer> that the rest of the game reads. Keeps gameState.multiplayer.remotePlayers in sync (passed in by NetworkManager).
// src/multiplayer/RemotePlayerRegistry.js
// Tracks remote players keyed by playerId. Mirrors entries into gameState.multiplayer.remotePlayers
// so render_game_to_text() can serialize with one read. Provides stale-eviction.
export class RemotePlayerRegistry {
constructor(gameState) {
this.gameState = gameState;
}
upsert(playerId, partial) {
const existing = this.gameState.multiplayer.remotePlayers[playerId] ?? {};
this.gameState.multiplayer.remotePlayers[playerId] = {
...existing,
...partial,
lastSeenTs: Date.now(),
};
}
remove(playerId) {
delete this.gameState.multiplayer.remotePlayers[playerId];
}
has(playerId) {
return playerId in this.gameState.multiplayer.remotePlayers;
}
list() {
return Object.entries(this.gameState.multiplayer.remotePlayers).map(([id, p]) => ({ id, ...p }));
}
// Returns playerIds pruned (so caller can emit player-left events).
prune(staleMs) {
const now = Date.now();
const pruned = [];
for (const [id, p] of Object.entries(this.gameState.multiplayer.remotePlayers)) {
if (now - (p.lastSeenTs ?? 0) > staleMs) {
pruned.push(id);
delete this.gameState.multiplayer.remotePlayers[id];
}
}
return pruned;
}
clear() {
this.gameState.multiplayer.remotePlayers = {};
}
}File: src/systems/NetworkManager.js
Wires MultiplayerClient ↔ EventBus ↔ RemotePlayerRegistry. Owns the broadcast tick (realtime mode) and the prune interval. Never throws out of any public method.
// src/systems/NetworkManager.js
// Single source of truth for network I/O. Subscribes to game events, forwards to server,
// dispatches server messages to EventBus + RemotePlayerRegistry.
// Single-player must work when init() fails — every error is caught and surfaced via
// network:disconnected. Gameplay never depends on the server being reachable.
import { Events } from '../core/EventBus.js';
// Adapt this import to match the game's Constants.js export shape:
// - Umbrella: import { Constants } from '../core/Constants.js' → Constants.MULTIPLAYER.X
// - Per-block: import { MULTIPLAYER } from '../core/Constants.js' → MULTIPLAYER.X
// (Most game-creator scaffolds use per-block. Read Constants.js first.)
import { Constants } from '../core/Constants.js';
import { MultiplayerClient } from '../multiplayer/MultiplayerClient.js';
import { RemotePlayerRegistry } from '../multiplayer/RemotePlayerRegistry.js';
const MODE_REALTIME = 'realtime';
const MODE_TURN_BASED = 'turn-based';
export class NetworkManager {
// mode: 'realtime' | 'turn-based'
// sampler: () => object — only used in realtime mode; returns the local player's state slice
// moveEvents: string[] — only used in turn-based mode; EventBus event names whose payloads to forward
constructor({ eventBus, gameState, mode, sampler, moveEvents = [] }) {
this.eventBus = eventBus;
this.gameState = gameState;
this.mode = mode;
this.sampler = sampler ?? (() => null);
this.moveEvents = moveEvents;
this.client = new MultiplayerClient();
this.registry = new RemotePlayerRegistry(gameState);
this.tickInterval = null;
this.pruneInterval = null;
this.reconnectAttempts = 0;
this.reconnectTimer = null;
this.intentionalDisconnect = false;
this.boundHandlers = {};
}
async init(roomId = Constants.MULTIPLAYER.DEFAULT_ROOM) {
this.gameState.multiplayer.roomId = roomId;
this._wireClient();
this._wireGameEvents();
this._connect(roomId);
}
destroy() {
this.intentionalDisconnect = true;
this._stopTick();
this._stopPrune();
this._cancelReconnect();
this._unwireGameEvents();
this.client.disconnect();
this.registry.clear();
this.gameState.multiplayer.connected = false;
}
isConnected() {
return this.client.isConnected();
}
getPlayerId() {
return this.gameState.multiplayer.playerId;
}
getRoomId() {
return this.gameState.multiplayer.roomId;
}
// ---- private ----
_wireClient() {
this.client.onOpen(() => {
this.reconnectAttempts = 0;
this._cancelReconnect();
});
this.client.onMessage((msg) => {
switch (msg?.type) {
case 'welcome': return this._onWelcome(msg);
case 'player-joined': return this._onPlayerJoined(msg);
case 'player-left': return this._onPlayerLeft(msg);
case 'state': return this._onState(msg);
case 'turn': return this._onTurn(msg);
case 'reject': console.warn('[NetworkManager] server rejected', msg.reason); return;
default: return;
}
});
this.client.onClose(() => this._onSocketClosed());
this.client.onError(() => { /* close will follow */ });
}
_wireGameEvents() {
this.boundHandlers.joinRoom = ({ roomId } = {}) => {
if (!roomId || roomId === this.gameState.multiplayer.roomId) return;
this.gameState.multiplayer.roomId = roomId;
// Not connected → just connect, nothing to hand off.
if (!this.client.isConnected()) {
this._connect(roomId);
return;
}
// Already connected — defer the new connect to _onSocketClosed via
// pendingRoomId so we don't race the old socket's async close event.
// Without this, a delayed close fires AFTER intentionalDisconnect is
// already cleared, gets misclassified as an error, emits a spurious
// network:disconnected, and schedules a duplicate reconnect.
this.pendingRoomId = roomId;
this.intentionalDisconnect = true;
this.client.disconnect(); // _onSocketClosed performs the handoff
};
this.boundHandlers.leaveRoom = () => this.destroy();
this.eventBus.on(Events.MULTIPLAYER_JOIN_ROOM, this.boundHandlers.joinRoom);
this.eventBus.on(Events.MULTIPLAYER_LEAVE_ROOM, this.boundHandlers.leaveRoom);
if (this.mode === MODE_TURN_BASED) {
this.boundHandlers.move = (payload) => {
if (!this.client.isConnected()) return;
this.client.send({ type: 'move', payload });
};
for (const evt of this.moveEvents) {
this.eventBus.on(evt, this.boundHandlers.move);
}
}
}
_unwireGameEvents() {
this.eventBus.off(Events.MULTIPLAYER_JOIN_ROOM, this.boundHandlers.joinRoom);
this.eventBus.off(Events.MULTIPLAYER_LEAVE_ROOM, this.boundHandlers.leaveRoom);
if (this.mode === MODE_TURN_BASED) {
for (const evt of this.moveEvents) {
this.eventBus.off(evt, this.boundHandlers.move);
}
}
}
_connect(roomId) {
// Guard `import.meta` for non-Vite contexts (some test runners, SSR, plain
// Node). Without the typeof check, this throws a SyntaxError at parse time
// in environments where import.meta isn't a thing.
const envHost = (typeof import.meta !== 'undefined' && import.meta.env?.VITE_MULTIPLAYER_SERVER_URL) || null;
const host = envHost ?? Constants.MULTIPLAYER.SERVER_URL;
if (!host) {
console.warn('[NetworkManager] no SERVER_URL configured — multiplayer disabled');
this.gameState.multiplayer.connected = false;
this.eventBus.emit(Events.NETWORK_DISCONNECTED, { reason: 'error' });
return;
}
try {
this.client.connect({ host, room: roomId });
} catch (err) {
console.warn('[NetworkManager] connect threw', err);
this._scheduleReconnect();
}
}
_onSocketClosed() {
this._stopTick();
this._stopPrune();
this.gameState.multiplayer.connected = false;
this.registry.clear();
this.eventBus.emit(Events.NETWORK_DISCONNECTED, { reason: this.intentionalDisconnect ? 'closed' : 'error' });
if (!this.intentionalDisconnect) this._scheduleReconnect();
this.intentionalDisconnect = false;
}
_scheduleReconnect() {
if (this.reconnectTimer) return;
if (this.reconnectAttempts >= Constants.MULTIPLAYER.RECONNECT_MAX_ATTEMPTS) {
console.warn('[NetworkManager] reconnect attempts exhausted');
return;
}
const base = Constants.MULTIPLAYER.RECONNECT_BACKOFF_MS;
const max = Constants.MULTIPLAYER.RECONNECT_MAX_BACKOFF_MS;
const delay = Math.min(base * Math.pow(2, this.reconnectAttempts), max);
this.reconnectAttempts += 1;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._connect(this.gameState.multiplayer.roomId ?? Constants.MULTIPLAYER.DEFAULT_ROOM);
}, delay);
}
_cancelReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
_onWelcome({ playerId, peers = [] }) {
this.gameState.multiplayer.playerId = playerId;
this.gameState.multiplayer.connected = true;
for (const peer of peers) {
this.registry.upsert(peer.playerId, { name: peer.name, ...(peer.state ?? {}) });
this.eventBus.emit(Events.NETWORK_PLAYER_JOINED, { playerId: peer.playerId, name: peer.name });
}
this.eventBus.emit(Events.NETWORK_CONNECTED, {
roomId: this.gameState.multiplayer.roomId,
playerId,
});
if (this.mode === MODE_REALTIME) this._startTick();
this._startPrune();
}
_onPlayerJoined({ playerId, name }) {
this.registry.upsert(playerId, { name });
this.eventBus.emit(Events.NETWORK_PLAYER_JOINED, { playerId, name });
}
_onPlayerLeft({ playerId }) {
this.registry.remove(playerId);
this.eventBus.emit(Events.NETWORK_PLAYER_LEFT, { playerId });
}
_onState({ playerId, state }) {
if (playerId === this.gameState.multiplayer.playerId) return; // never render self
this.registry.upsert(playerId, state);
this.eventBus.emit(Events.NETWORK_STATE_RECEIVED, { playerId, state });
}
_onTurn({ playerId, resultingState, turn }) {
this.eventBus.emit(Events.NETWORK_STATE_RECEIVED, {
playerId,
state: { type: 'turn', resultingState, turn, ts: Date.now() },
});
}
_startTick() {
if (this.tickInterval) return;
const intervalMs = 1000 / Constants.MULTIPLAYER.TICK_RATE_HZ;
this.tickInterval = setInterval(() => {
const sample = this.sampler();
if (!sample) return;
this.client.send({ type: 'state', state: { ...sample, ts: Date.now() } });
}, intervalMs);
}
_stopTick() {
if (this.tickInterval) {
clearInterval(this.tickInterval);
this.tickInterval = null;
}
}
_startPrune() {
if (this.pruneInterval) return;
const intervalMs = 1000 / Constants.MULTIPLAYER.TICK_RATE_HZ;
this.pruneInterval = setInterval(() => {
const pruned = this.registry.prune(Constants.MULTIPLAYER.STALE_PLAYER_MS);
for (const id of pruned) this.eventBus.emit(Events.NETWORK_PLAYER_LEFT, { playerId: id });
}, intervalMs);
}
_stopPrune() {
if (this.pruneInterval) {
clearInterval(this.pruneInterval);
this.pruneInterval = null;
}
}
}EventBus.js — Append Block
Append to src/core/EventBus.js inside the Events constant, just before the closing }:
// === 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',GameState.js — Append Block
Append to the GameState constructor / initial state object:
// === Multiplayer ===
this.multiplayer = {
// Persistent across reset() — preserved so /reset rejoins the same room.
roomId: null,
playerId: null,
// Transient — cleared by reset().
connected: false,
remotePlayers: {}, // { [playerId]: { x, y, z?, score, name?, lastSeenTs } }
};In reset(), append (with a this.multiplayer guard — reset() is often called from the GameState constructor, possibly before the this.multiplayer = {...} block runs):
// === Multiplayer (transient only) ===
if (this.multiplayer) {
this.multiplayer.connected = false;
this.multiplayer.remotePlayers = {};
}
// roomId and playerId persist intentionally.Constants.js — Append Block
Append to the Constants object:
// === Multiplayer ===
MULTIPLAYER: {
SERVER_URL: 'https://<project>.<user>.partykit.dev', // filled by deploy step
DEFAULT_ROOM: 'lobby',
MAX_PLAYERS: 4,
MAX_MESSAGE_BYTES: 4096,
TICK_RATE_HZ: 20,
STATE_INTERPOLATE_MS: 100,
RECONNECT_BACKOFF_MS: 1000,
RECONNECT_MAX_BACKOFF_MS: 16000,
RECONNECT_MAX_ATTEMPTS: 10,
STALE_PLAYER_MS: 5000,
PROTOCOL_VERSION: 1,
},main.js — Wiring Patches
Patch 1 — imports (top of file):
import { NetworkManager } from './systems/NetworkManager.js';Patch 2 — instantiate after EventBus + GameState are ready, before the engine starts.
For realtime games — the sampler reads the local entity from GameState:
const networkManager = new NetworkManager({
eventBus,
gameState,
mode: 'realtime',
sampler: () => {
// Replace `bird` with the game's local entity name from GameState.
const e = gameState.bird;
if (!e) return null;
return {
x: e.x,
y: e.y,
vx: e.vx ?? 0,
vy: e.vy ?? 0,
score: gameState.score ?? 0,
alive: !gameState.gameOver,
};
},
});
networkManager.init().catch(err => console.warn('[main] NetworkManager init failed', err));
window.__NETWORK_MANAGER__ = networkManager;For turn-based games — pass the move event names instead:
const networkManager = new NetworkManager({
eventBus,
gameState,
mode: 'turn-based',
moveEvents: [Events.PLAYER_MOVED, Events.CARD_PLAYED], // adapt to actual events
});
networkManager.init().catch(err => console.warn('[main] NetworkManager init failed', err));
window.__NETWORK_MANAGER__ = networkManager;Patch 3 — extend window.render_game_to_text() additively. Find the existing payload object and add:
payload.multiplayer = {
roomId: gameState.multiplayer.roomId,
playerId: gameState.multiplayer.playerId,
connected: gameState.multiplayer.connected,
};
payload.remotePlayers = Object.entries(gameState.multiplayer.remotePlayers).map(
([id, p]) => ({
id,
x: p.x,
y: p.y,
...(p.z !== undefined ? { z: p.z } : {}),
score: p.score,
name: p.name,
alive: p.alive,
})
);Phaser Scene Integration
Welcome-race gotcha
Under fast local connections (and npx partykit dev in particular), the WebSocket welcome arrives before scene create() finishes registering listeners. The result: code that only listens for NETWORK_CONNECTED / NETWORK_PLAYER_JOINED to set up remote sprites silently misses the initial peers — eventBus.on(...) is called after the events have already fired, so the listener never runs.
Fix pattern: register the listener AND seed from current state right after registration. Both branches are idempotent so it's safe even when the race doesn't happen:
// Subscribe…
this.eventBus.on(Events.NETWORK_PLAYER_JOINED, this._onRemoteJoined, this);
// …and seed from whatever's already in GameState (in case welcome was already processed)
if (this.gameState.multiplayer.connected) {
for (const id of Object.keys(this.gameState.multiplayer.remotePlayers)) {
this._onRemoteJoined({ playerId: id });
}
}The same pattern applies to NETWORK_CONNECTED listeners — also check gameState.multiplayer.connected after subscribing.
Scene patch
In the active GameScene (typically src/scenes/GameScene.js):
import { Events } from '../core/EventBus.js';
import { Constants } from '../core/Constants.js';
create() {
// ...existing single-player creation...
this.remoteSprites = new Map();
this.eventBus.on(Events.NETWORK_PLAYER_JOINED, this._onRemoteJoined, this);
this.eventBus.on(Events.NETWORK_STATE_RECEIVED, this._onRemoteState, this);
this.eventBus.on(Events.NETWORK_PLAYER_LEFT, this._onRemoteLeft, this);
// Welcome-race seed — handle peers that joined before listener registration.
if (this.gameState.multiplayer.connected) {
for (const [id, peer] of Object.entries(this.gameState.multiplayer.remotePlayers)) {
this._onRemoteJoined({ playerId: id });
if (peer.x !== undefined) this._onRemoteState({ playerId: id, state: peer });
}
}
}
_onRemoteJoined({ playerId }) {
const sprite = this.add.sprite(0, 0, 'bird'); // adapt asset key
sprite.setAlpha(0.7);
sprite.setData('targetX', sprite.x);
sprite.setData('targetY', sprite.y);
this.remoteSprites.set(playerId, sprite);
}
_onRemoteState({ playerId, state }) {
const sprite = this.remoteSprites.get(playerId);
if (!sprite) return;
sprite.setData('targetX', state.x);
sprite.setData('targetY', state.y);
if (state.alive === false) sprite.setVisible(false);
else sprite.setVisible(true);
}
_onRemoteLeft({ playerId }) {
const sprite = this.remoteSprites.get(playerId);
if (sprite) sprite.destroy();
this.remoteSprites.delete(playerId);
}
update(time, delta) {
// ...existing local update...
// Smooth remote players toward their target positions.
const lerp = Math.min(1, delta / Constants.MULTIPLAYER.STATE_INTERPOLATE_MS);
for (const sprite of this.remoteSprites.values()) {
const tx = sprite.getData('targetX');
const ty = sprite.getData('targetY');
sprite.x += (tx - sprite.x) * lerp;
sprite.y += (ty - sprite.y) * lerp;
}
}
shutdown() {
this.eventBus.off(Events.NETWORK_PLAYER_JOINED, this._onRemoteJoined, this);
this.eventBus.off(Events.NETWORK_STATE_RECEIVED, this._onRemoteState, this);
this.eventBus.off(Events.NETWORK_PLAYER_LEFT, this._onRemoteLeft, this);
for (const sprite of this.remoteSprites.values()) sprite.destroy();
this.remoteSprites.clear();
}Three.js Integration
In the orchestrator (typically src/core/Game.js):
import * as THREE from 'three';
import { Events } from './EventBus.js';
import { Constants } from './Constants.js';
constructor(/* ... */) {
// ...existing init...
this.remoteMeshes = new Map();
this.eventBus.on(Events.NETWORK_PLAYER_JOINED, ({ playerId }) => {
const mesh = this._createRemotePlayerMesh();
mesh.userData = { playerId, targetX: 0, targetY: 0, targetZ: 0 };
this.scene.add(mesh);
this.remoteMeshes.set(playerId, mesh);
});
this.eventBus.on(Events.NETWORK_STATE_RECEIVED, ({ playerId, state }) => {
const mesh = this.remoteMeshes.get(playerId);
if (!mesh || state?.x === undefined) return;
mesh.userData.targetX = state.x;
mesh.userData.targetY = state.y;
mesh.userData.targetZ = state.z ?? 0;
});
this.eventBus.on(Events.NETWORK_PLAYER_LEFT, ({ playerId }) => {
const mesh = this.remoteMeshes.get(playerId);
if (!mesh) return;
this.scene.remove(mesh);
mesh.geometry?.dispose();
if (Array.isArray(mesh.material)) mesh.material.forEach(m => m.dispose());
else mesh.material?.dispose();
this.remoteMeshes.delete(playerId);
});
}
_createRemotePlayerMesh() {
// Replace with real model. This stub renders a translucent capsule.
const geometry = new THREE.CapsuleGeometry(0.3, 1, 4, 8);
const material = new THREE.MeshStandardMaterial({ color: 0x4488ff, transparent: true, opacity: 0.7 });
return new THREE.Mesh(geometry, material);
}
update(deltaSec) {
// ...existing local update...
const lerp = Math.min(1, deltaSec * 1000 / Constants.MULTIPLAYER.STATE_INTERPOLATE_MS);
for (const mesh of this.remoteMeshes.values()) {
const u = mesh.userData;
mesh.position.x += (u.targetX - mesh.position.x) * lerp;
mesh.position.y += (u.targetY - mesh.position.y) * lerp;
mesh.position.z += (u.targetZ - mesh.position.z) * lerp;
}
}Three.js cleanup is more involved than Phaser — always dispose geometries and materials in the network:player-left handler to avoid GPU memory leaks.
package.json Diff
{
"dependencies": {
"partysocket": "^1.0.0"
}
}Run npm install partysocket to add it. The version range follows the latest stable at scaffold time.
.env and .env.example
Create .env (gitignored):
VITE_MULTIPLAYER_SERVER_URL=https://<project>.<user>.partykit.devCreate .env.example (committed):
VITE_MULTIPLAYER_SERVER_URL=https://your-project.your-username.partykit.devThe VITE_ prefix is required for Vite to expose the variable to client code.
Deploy: PartyKit Server + Client
End-to-end walkthrough: local dev → first deploy → client redeploy. Covers Cloudflare login, URL capture, .env handling, and the most common deploy failures.
Prerequisites
- Node.js 18+
- Cloudflare account (free tier — no credit card required for prototyping)
- The game's existing deploy mechanism configured (here.now / GitHub Pages / Vercel — same as the deploy detection used by
monetize-game)
Step 1: Local Development
Get the server running locally before deploying. PartyKit's dev mode is a local Cloudflare Workers emulator.
cd <game-path>/multiplayer-server
npm install
npx partykit devOutput:
[partykit] Starting development server...
[partykit] Listening on http://127.0.0.1:1999
[partykit] Press Ctrl+C to stopLeave this terminal running.
In a second terminal, point the client at the local server. Add to <game-path>/.env:
VITE_MULTIPLAYER_SERVER_URL=http://127.0.0.1:1999(partysocket accepts http:// and rewrites to ws:// for the actual connection.)
Then start the client:
cd <game-path>
npm run devOpen http://localhost:3000 in two browser tabs. Both should fire network:connected (visible in the console) and see each other in window.render_game_to_text().remotePlayers.
If two tabs do not see each other locally, debug before deploying — the production deploy will not surface different bugs.
Step 2: PartyKit Login
PartyKit was acquired by Cloudflare in 2024. As of 2026, the default login provider (`clerk`) is broken — it redirects through https://dashboard.partykit.io/patience ("under construction") and never completes. Use the GitHub device-code flow instead, which is reliable and independent of the deprecated dashboard:
cd <game-path>/multiplayer-server
npx partykit login --provider githubOutput looks like:
We will now open your browser to https://github.com/login/device
Please paste the code XXXX-XXXX (copied to your clipboard) and authorize the app.Open https://github.com/login/device (the CLI tries to auto-open; if that fails, navigate manually). Paste the code, authorize the app, and the CLI completes with:
Congratulations, you're all set!
Your device is now connected.Credentials are saved to ~/.partykit/config.json. Subsequent npx partykit deploy runs reuse them without prompting.
Do not run `npx partykit login` without `--provider github` — the default clerk flow will hang forever on the broken dashboard redirect.
Step 3: First Deploy
npx partykit deploySuccessful output:
[partykit] Building...
[partykit] Uploading...
[partykit] Deployed to:
https://<game-name>-multiplayer.<cf-username>.partykit.devCapture this URL — it's the value for Constants.MULTIPLAYER.SERVER_URL and VITE_MULTIPLAYER_SERVER_URL.
The <game-name> portion is whatever was set in multiplayer-server/partykit.json's name field. The <cf-username> is the Cloudflare account's subdomain (visible in the dashboard sidebar).
Step 4: Wire the Deployed URL Into the Client
Update three places:
1. `src/core/Constants.js`:
MULTIPLAYER: {
- SERVER_URL: 'https://<project>.<user>.partykit.dev',
+ SERVER_URL: 'https://<game-name>-multiplayer.<cf-username>.partykit.dev',
...
},2. `<game-path>/.env` (replace the localhost URL from Step 1):
VITE_MULTIPLAYER_SERVER_URL=https://<game-name>-multiplayer.<cf-username>.partykit.dev3. `<game-path>/.env.example` (committed, with placeholder):
VITE_MULTIPLAYER_SERVER_URL=https://your-project.your-username.partykit.devConfirm .env is in .gitignore (the env-files rule requires this). If not, add it:
echo ".env" >> <game-path>/.gitignoreThe Constants.MULTIPLAYER.SERVER_URL is the deployed default; VITE_MULTIPLAYER_SERVER_URL lets developers override locally. NetworkManager prefers the env var when present.
Step 5: Rebuild and Redeploy the Client
cd <game-path>
npm run buildIf the build fails, the most likely cause is a syntax error in NetworkManager.js or MultiplayerClient.js. Fix and rebuild before continuing.
Detect the existing host using the same logic as monetize-game:
here.now (default):
~/.agents/skills/here-now/scripts/publish.sh dist/GitHub Pages:
npx gh-pages -d distVercel:
vercel --prodIf the existing deploy mechanism is unclear, ask the user before proceeding.
Step 6: Verify End-to-End
1. Open the deployed game URL in two browser tabs (or two devices). 2. Confirm network:connected fires on both. 3. Move the local entity in tab A and confirm tab B's remote-player rendering updates within 1000 / TICK_RATE_HZ * 2 ms (≈100ms at default 20Hz). 4. Close tab A and confirm tab B receives network:player-left. 5. Reopen tab A and confirm tab B receives network:player-joined.
Step 7: Cost Awareness
Cloudflare Workers free tier (as of 2026):
- 100,000 requests / day across all Workers in the account
- 1 GB Durable Object SQLite storage
- 30 seconds of CPU / day
Each multiplayer message is one Worker request. At a 20Hz tick, one always-on player generates ~1.7M requests/day — above the free tier. For prototyping with bursty play sessions (a few minutes at a time), free tier is comfortable. For production, upgrade to Workers Paid ($5/month flat) which raises the cap to 10M requests/day.
To stay under the free tier:
- Lower
TICK_RATE_HZinConstants.js(10Hz halves traffic; 5Hz quarters it). - Add session timeouts so idle rooms hibernate (PartyKit does this automatically when
options.hibernate: true).
The Cloudflare project is owned by the user, not OpusGameLabs. Costs accrue to their Cloudflare account.
Re-deploys
Subsequent deploys are one command:
cd <game-path>/multiplayer-server && npx partykit deployThe URL stays the same. No client rebuild required for server-only changes.
Switching Modes (realtime ↔ turn-based)
The two server templates are interchangeable. To switch, replace multiplayer-server/src/server.ts with the other template (see partykit-server.md), update NetworkManager instantiation in main.js to the matching mode, then redeploy both server and client.
Troubleshooting
partykit: command not found
Cause: npm install was skipped or failed in multiplayer-server/. Fix: cd multiplayer-server && npm install.
npx partykit login redirects to dashboard.partykit.io/patience and hangs
Cause: The default clerk provider was retired after Cloudflare absorbed PartyKit. The dashboard the OAuth callback expects no longer exists. Fix: Run npx partykit login --provider github instead — it uses GitHub's device-code OAuth flow and writes credentials to ~/.partykit/config.json cleanly.
npm install in multiplayer-server/ reports security vulnerabilities
Cause: partykit pulls in older transitive deps (wrangler, esbuild). The vulnerabilities are flagged by npm audit but are not exploitable in the server template's usage. Fix: Ignore the warning. Do not run `npm audit fix --force` — it'll attempt major-version bumps that break the partykit toolchain.
partykit dev silently picks up the parent .env
Cause: PartyKit's dev server walks upward looking for .env files. Logs Loading environment variables from ../.env when it does. Effect: Your client's VITE_* vars leak into the dev server's process env. Harmless for the standard template (server doesn't read them), but worth knowing if you ever store secrets in the client .env. Fix: If isolation matters, put a separate multiplayer-server/.env and add multiplayer-server/.env.local to override.
Deploy hangs at "Uploading..."
Cause: Slow network, or Cloudflare API is rate-limiting. Fix: Cancel (Ctrl+C), wait 30s, retry. If persistent, check https://www.cloudflarestatus.com/.
Authentication failed on deploy
Cause: Stale Wrangler credentials, or the session was logged out. Fix: npx wrangler logout && npx wrangler login, then retry npx partykit deploy.
Browser console: "WebSocket connection failed: SSL_PROTOCOL_ERROR"
Cause: Mixed content — the deployed game is HTTP but the server URL is HTTPS, or vice versa. Fix: Confirm both are HTTPS. Use the full https:// URL in Constants.MULTIPLAYER.SERVER_URL. partysocket derives wss:// automatically.
Browser console: "Failed to construct 'WebSocket': The URL ... is invalid"
Cause: SERVER_URL contains a placeholder like <project>.<user> that wasn't replaced after deploy. Fix: Update Constants.MULTIPLAYER.SERVER_URL with the actual deployed URL.
Two tabs connect but never see each other in production
Cause: They joined different rooms. URL-based room detection isn't wired by default; both tabs default to 'lobby'. Fix: Confirm both window.render_game_to_text().multiplayer.roomId === 'lobby'. If you wired room-from-URL parsing, ensure both tabs use the same query param.
Error: Could not deploy: project name conflicts
Cause: multiplayer-server/partykit.json has a name that's already taken in the user's CF account. Fix: Rename to <game-name>-multiplayer-<random-suffix> and retry.
Deploy succeeds but the deployed Worker logs nothing in wrangler tail
Cause: The DO is hibernated and you haven't sent traffic yet. Fix: Connect a client first; wrangler tail only shows live invocations.
"Free tier exhausted" after a long testing session
Cause: Sustained 20Hz ticks from a 24h session. Fix: Either upgrade to Workers Paid, lower TICK_RATE_HZ, or add a session timeout that disconnects idle clients.
Local dev: EADDRINUSE on port 1999
Cause: A previous partykit dev is still running (or another tool is on 1999). Fix: lsof -i :1999 to find the PID, kill <pid>. Or pass --port 2000 to partykit dev and update VITE_MULTIPLAYER_SERVER_URL to match.
Server connects but never sends welcome
Cause: A server-side exception in onConnect is throwing silently. Fix: wrangler tail (or PartyKit's dev console) shows the stack. Most common cause is a typo in the type imports — confirm src/types.ts matches what server.ts imports.
Client reconnects in a tight loop
Cause: Server is rejecting all connections (e.g., room-full due to a stuck phantom peer that didn't get cleaned up). Fix: Restart the DO: in the Cloudflare dashboard, navigate to the Worker, then "Durable Objects" → delete the room instance. Or call partykit deploy to redeploy (which evicts existing DO instances).
PartyKit Server Templates
Server-side scaffolding for the multiplayer skill. One Durable Object instance per room id (PartyKit's default). Pick the template that matches the chosen sync mode.
All templates use TypeScript — PartyKit transpiles automatically. JavaScript works too if the user prefers; rename to .js and drop the type annotations.
File Layout
multiplayer-server/
partykit.json
package.json
tsconfig.json
.gitignore
src/
server.ts # one of the templates below
types.ts # shared message typespartykit.json
{
"name": "<game-name>-multiplayer",
"main": "src/server.ts",
"compatibilityDate": "2026-01-15"
}Replace <game-name> with the game's directory name (kebab-case). PartyKit uses this as the project slug — the deployed URL will be https://<game-name>-multiplayer.<cf-username>.partykit.dev.
`compatibilityDate`: pin to a recent date (within the last ~6 months). Older dates lock you out of newer Workers runtime APIs; ones too far in the future may require flags that aren't yet available. Refresh this when scaffolding a new project — YYYY-MM-DD from when you ran the skill is a safe choice.
package.json
{
"name": "<game-name>-multiplayer-server",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "partykit dev",
"deploy": "partykit deploy",
"build": "partykit build"
},
"devDependencies": {
"partykit": "^0.0.114",
"typescript": "^5.4.0"
}
}Pin partykit to whatever the latest stable is at scaffold time. partykit build is optional — deploy builds implicitly.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022"],
"types": ["partykit/server"]
},
"include": ["src/**/*.ts"]
}.gitignore
node_modules/
.partykit/
dist/
.wrangler/
*.logsrc/types.ts (REQUIRED — shared wire types)
Both server templates import type { ... } from './types'. This file is not optional — without it the server won't compile. Keep it next to server.ts.
export const PROTOCOL_VERSION = 1;
export type ClientMessage =
| { type: 'state'; state: PlayerState } // realtime tick
| { type: 'move'; payload: unknown } // turn-based
| { type: 'name'; name: string }; // optional display name
export type ServerMessage =
| { type: 'welcome'; playerId: string; peers: Peer[]; protocolVersion: number }
| { type: 'player-joined'; playerId: string; name?: string }
| { type: 'player-left'; playerId: string }
| { type: 'state'; playerId: string; state: PlayerState }
| { type: 'turn'; playerId: string; resultingState: unknown; turn: number }
| { type: 'reject'; reason: string };
export type PlayerState = {
x: number;
y: number;
z?: number;
vx?: number;
vy?: number;
vz?: number;
score: number;
alive: boolean;
ts: number;
};
export type Peer = {
playerId: string;
name?: string;
state?: PlayerState;
};Keep this file in sync with the client's wire types (mirror in src/multiplayer/types.js if you want, though PartyKit's shape is loose enough that JSDoc is sufficient).
Realtime Template — src/server.ts
import type * as Party from 'partykit/server';
import type { ClientMessage, ServerMessage, Peer, PlayerState } from './types';
import { PROTOCOL_VERSION } from './types';
const MAX_PLAYERS = 4;
const MAX_MESSAGE_BYTES = 4096;
const RATE_LIMIT_PER_SEC = 30; // hard cap on messages/sec/connection
const STALE_PEER_MS = 5000;
type Connection = Party.Connection<{ name?: string; lastState?: PlayerState }>;
export default class Room implements Party.Server {
options: Party.ServerOptions = { hibernate: true };
// playerId -> last-known state (for welcome payload to new joiners)
peers = new Map<string, Peer>();
// playerId -> rate-limit window
rates = new Map<string, { windowStart: number; count: number }>();
constructor(readonly room: Party.Room) {}
onConnect(conn: Connection, ctx: Party.ConnectionContext): void | Promise<void> {
if (this.peers.size >= MAX_PLAYERS) {
conn.send(JSON.stringify({ type: 'reject', reason: 'room-full' } satisfies ServerMessage));
conn.close();
return;
}
const playerId = conn.id;
this.peers.set(playerId, { playerId });
const welcome: ServerMessage = {
type: 'welcome',
playerId,
peers: [...this.peers.values()].filter(p => p.playerId !== playerId),
protocolVersion: PROTOCOL_VERSION,
};
conn.send(JSON.stringify(welcome));
const joined: ServerMessage = { type: 'player-joined', playerId };
this.room.broadcast(JSON.stringify(joined), [playerId]);
}
onMessage(rawMessage: string, sender: Connection): void | Promise<void> {
// UTF-8 byte length, not JS string `.length` — multibyte characters
// (emoji, accented letters) inflate byte count vs char count.
if (new TextEncoder().encode(rawMessage).byteLength > MAX_MESSAGE_BYTES) {
sender.send(JSON.stringify({ type: 'reject', reason: 'message-too-large' } satisfies ServerMessage));
return;
}
if (!this.rateLimitOk(sender.id)) {
// Drop silently — don't reply (further amplifies abuse).
return;
}
let msg: ClientMessage;
try {
msg = JSON.parse(rawMessage);
} catch {
sender.send(JSON.stringify({ type: 'reject', reason: 'bad-json' } satisfies ServerMessage));
return;
}
switch (msg.type) {
case 'state': {
if (!isValidState(msg.state)) {
sender.send(JSON.stringify({ type: 'reject', reason: 'bad-state' } satisfies ServerMessage));
return;
}
const peer = this.peers.get(sender.id);
if (peer) peer.state = msg.state;
const out: ServerMessage = {
type: 'state',
playerId: sender.id,
state: { ...msg.state, ts: Date.now() },
};
this.room.broadcast(JSON.stringify(out), [sender.id]);
return;
}
case 'name': {
const name = String(msg.name ?? '').slice(0, 32);
const peer = this.peers.get(sender.id);
if (peer) peer.name = name;
const out: ServerMessage = { type: 'player-joined', playerId: sender.id, name };
this.room.broadcast(JSON.stringify(out), [sender.id]);
return;
}
default:
sender.send(JSON.stringify({ type: 'reject', reason: 'unknown-type' } satisfies ServerMessage));
}
}
onClose(conn: Connection): void | Promise<void> {
if (!this.peers.has(conn.id)) return;
this.peers.delete(conn.id);
this.rates.delete(conn.id);
const left: ServerMessage = { type: 'player-left', playerId: conn.id };
this.room.broadcast(JSON.stringify(left));
}
onError(conn: Connection, err: Error): void | Promise<void> {
console.error('connection error', conn.id, err);
}
private rateLimitOk(playerId: string): boolean {
const now = Date.now();
const window = this.rates.get(playerId);
if (!window || now - window.windowStart > 1000) {
this.rates.set(playerId, { windowStart: now, count: 1 });
return true;
}
window.count += 1;
return window.count <= RATE_LIMIT_PER_SEC;
}
}
function isValidState(s: unknown): s is PlayerState {
if (!s || typeof s !== 'object') return false;
const o = s as Record<string, unknown>;
return (
typeof o.x === 'number' && Number.isFinite(o.x) &&
typeof o.y === 'number' && Number.isFinite(o.y) &&
typeof o.score === 'number' && Number.isFinite(o.score) &&
typeof o.alive === 'boolean'
);
}
Room satisfies Party.Worker;Why these choices:
- `hibernate: true`: PartyKit hibernates idle DOs to keep CF billing low. Wakes on the next message in <100ms.
- `peers` as `Map`: needed so
welcomecan include current peers and their last-known state. - `room.broadcast(msg, [excludeIds])`: PartyKit's built-in fan-out. Excluding the sender means each client renders only remote players, never itself.
- Rate limiting per second per connection:
RATE_LIMIT_PER_SEC = 30is generous for a 20Hz tick (allows bursts). Drops silently to avoid amplification. - `isValidState`: rejects NaN, Infinity, and missing fields — common sources of bugs in browser physics.
Turn-Based Template — src/server.ts
Use this for card games, board games, puzzles. Replace the realtime template with this one.
import type * as Party from 'partykit/server';
import type { ClientMessage, ServerMessage } from './types';
import { PROTOCOL_VERSION } from './types';
const MAX_PLAYERS = 4;
const MAX_MESSAGE_BYTES = 4096;
type Connection = Party.Connection<{ name?: string }>;
type RoomState = {
turn: number;
currentPlayerIndex: number;
playerOrder: string[]; // playerIds in turn order
history: Array<{ playerId: string; payload: unknown; turn: number }>;
// Game-specific state lives here. Replace `unknown` with a real type per game.
gameState: unknown;
};
export default class Room implements Party.Server {
options: Party.ServerOptions = { hibernate: true };
state: RoomState = {
turn: 0,
currentPlayerIndex: 0,
playerOrder: [],
history: [],
gameState: null,
};
constructor(readonly room: Party.Room) {}
onConnect(conn: Connection): void | Promise<void> {
if (this.state.playerOrder.length >= MAX_PLAYERS) {
conn.send(JSON.stringify({ type: 'reject', reason: 'room-full' } satisfies ServerMessage));
conn.close();
return;
}
this.state.playerOrder.push(conn.id);
const welcome: ServerMessage = {
type: 'welcome',
playerId: conn.id,
peers: this.state.playerOrder
.filter(id => id !== conn.id)
.map(playerId => ({ playerId })),
protocolVersion: PROTOCOL_VERSION,
};
conn.send(JSON.stringify(welcome));
const joined: ServerMessage = { type: 'player-joined', playerId: conn.id };
this.room.broadcast(JSON.stringify(joined), [conn.id]);
// Optional: replay history to bring the new joiner up to date.
for (const entry of this.state.history) {
const replay: ServerMessage = {
type: 'turn',
playerId: entry.playerId,
resultingState: entry.payload,
turn: entry.turn,
};
conn.send(JSON.stringify(replay));
}
}
onMessage(rawMessage: string, sender: Connection): void | Promise<void> {
// UTF-8 byte length, not JS string `.length` — multibyte characters
// (emoji, accented letters) inflate byte count vs char count.
if (new TextEncoder().encode(rawMessage).byteLength > MAX_MESSAGE_BYTES) {
sender.send(JSON.stringify({ type: 'reject', reason: 'message-too-large' } satisfies ServerMessage));
return;
}
let msg: ClientMessage;
try {
msg = JSON.parse(rawMessage);
} catch {
sender.send(JSON.stringify({ type: 'reject', reason: 'bad-json' } satisfies ServerMessage));
return;
}
if (msg.type !== 'move') {
sender.send(JSON.stringify({ type: 'reject', reason: 'unknown-type' } satisfies ServerMessage));
return;
}
const expectedPlayer = this.state.playerOrder[this.state.currentPlayerIndex];
if (sender.id !== expectedPlayer) {
sender.send(JSON.stringify({ type: 'reject', reason: 'not-your-turn' } satisfies ServerMessage));
return;
}
// Game-specific validation. Replace this stub with real rules.
const validation = validateMove(msg.payload, this.state.gameState);
if (!validation.ok) {
sender.send(JSON.stringify({ type: 'reject', reason: validation.reason } satisfies ServerMessage));
return;
}
// Apply the move authoritatively.
this.state.gameState = validation.next;
this.state.turn += 1;
this.state.history.push({ playerId: sender.id, payload: msg.payload, turn: this.state.turn });
this.state.currentPlayerIndex =
(this.state.currentPlayerIndex + 1) % Math.max(this.state.playerOrder.length, 1);
const out: ServerMessage = {
type: 'turn',
playerId: sender.id,
resultingState: this.state.gameState,
turn: this.state.turn,
};
this.room.broadcast(JSON.stringify(out)); // include sender — they need confirmation
}
onClose(conn: Connection): void | Promise<void> {
const idx = this.state.playerOrder.indexOf(conn.id);
if (idx === -1) return;
this.state.playerOrder.splice(idx, 1);
if (this.state.currentPlayerIndex >= this.state.playerOrder.length) {
this.state.currentPlayerIndex = 0;
}
const left: ServerMessage = { type: 'player-left', playerId: conn.id };
this.room.broadcast(JSON.stringify(left));
}
}
// Replace this stub with game-specific rules. Returns either {ok: true, next: newGameState}
// or {ok: false, reason: string}.
function validateMove(
_payload: unknown,
prevState: unknown,
): { ok: true; next: unknown } | { ok: false; reason: string } {
return { ok: true, next: prevState };
}
Room satisfies Party.Worker;Key differences from realtime:
- No tick rate: messages are user-driven (one per move), so no rate-limiting in the loop sense. A simple "max moves per minute" cap can be added if needed.
- `playerOrder` enforces turns: rejected if not the current player's turn.
- `history` enables late-join replay: new joiners receive every prior
turnevent so their UI can rebuild the game state without trusting peers. - Broadcast includes sender: senders need server confirmation that their move was accepted.
- `validateMove` is the game-specific seam: every game replaces this stub.
Persistence (Optional)
PartyKit DOs have room.storage (key-value, persists across hibernation). To survive a CF deploy / DO eviction:
async onStart(): Promise<void> {
const saved = await this.room.storage.get<RoomState>('state');
if (saved) this.state = saved;
}
async persist(): Promise<void> {
await this.room.storage.put('state', this.state);
}
// Call persist() after each authoritative state change.For prototype/v1 we recommend skipping persistence — ephemeral rooms are simpler and fit the "default room" model. Add this if the user asks for crash-resilience.
Local Development
cd multiplayer-server
npm install
npx partykit devThis starts a local Worker emulator at http://127.0.0.1:1999. The PartyKit dev server hot-reloads on save. To connect, use the same URL as host in partysocket:
new PartySocket({
host: 'http://127.0.0.1:1999', // or the deployed https URL
room: 'lobby',
});PartySocket rewrites the scheme to ws/wss automatically.
Deploy
cd multiplayer-server
npx partykit deployFirst run prompts a Cloudflare login (browser-based OAuth). The output prints the deployed URL — capture it for Constants.MULTIPLAYER.SERVER_URL. See deploy.md for the full walkthrough.