| name | multiplayer |
| description | Adds optional multiplayer to any generated Phaser 3 game. Supports three tiers — local (two gamepads/keyboards, no server), LAN (Colyseus authoritative server, same network), and online (Colyseus + TURN/STUN relay). Optional React/Redux lobby and PeerJS voice/video chat. Trigger only when the user explicitly requests multiplayer ("make it 2-player", "add online co-op", "--multiplayer" flag). |
Multiplayer — Optional Networked Play for Phaser 3 Games
Adds network play to any game already generated by the gameforge pipeline. The skill is additive and optional — it patches existing files minimally and creates new files under server/ and src/net/. It never rewrites src/scenes/Game.js from scratch; it injects hooks.
When to use
Trigger on any of:
- Explicit multiplayer request in the prompt ("2-player", "co-op", "online", "multiplayer")
--multiplayer CLI flag passed to gameforge generate
--local, --lan, or --online tier flags
- "add a lobby", "add voice chat" (implies multiplayer)
Do not activate automatically for single-player games. Always confirm tier with the user if ambiguous (local vs LAN vs online costs differ significantly in deployment complexity).
Three tiers
Tier 1 — Local only (no server)
Two players on the same machine or two gamepads/keyboards. No network code, no Colyseus. Player 2 input uses WASD (if P1 uses arrows) or gamepad index 1. State is shared in the same Phaser scene. Fastest to implement; zero infrastructure.
Trigger: --local or "same screen" or "splitscreen" in the prompt.
What gets added:
- A second player sprite spawned from the same entity pool as P1
- P2 input wiring (WASD or gamepad 1)
- Optional split-screen camera (two Phaser cameras, each following one player)
- Shared HUD showing both HP/score
Tier 2 — LAN (Colyseus, same network)
Authoritative Colyseus server running locally. Both clients connect over WebSocket to ws://localhost:2567. No TURN/STUN needed — NAT traversal assumed handled by router or VPN. Suitable for game jams, LAN parties.
Trigger: --lan or "local network" or "same WiFi" in the prompt.
What gets added:
server/ directory with Colyseus server (TypeScript, compiled to server/dist/)
src/net/ColyseusClient.js — connects, joins room, forwards input, applies server state
src/net/RemotePlayer.js — interpolated sprite for each networked peer
patch_game.mjs injects net hooks into src/scenes/Game.js
Tier 3 — Online (Colyseus + TURN/STUN)
Full internet play. Colyseus server hosted on a public URL. ICE servers (TURN/STUN) configured for WebRTC signaling via PeerJS voice/video. Requires a .env with VITE_COLYSEUS_URL and optionally VITE_PEER_HOST.
Trigger: --online or "internet", "play with friends online" in the prompt.
What gets added:
- Everything in Tier 2
.env.example with VITE_COLYSEUS_URL, VITE_PEER_HOST, VITE_PEER_PORT, VITE_PEER_PATH
- ICE server configuration in
ColyseusClient.js
- Optionally:
src/net/VoiceChat.js (PeerJS, triggered by --voice)
- Optionally:
lobby/ React app (Vite + React + Redux, triggered by --lobby)
Player session schema
Each connected player carries this state on the server (authoritative) and mirrored to all clients:
export class Player extends Schema {
@type('string') sessionId: string = '';
@type('string') name: string = 'Player';
@type('number') x: number = 0;
@type('number') y: number = 0;
@type('number') hp: number = 3;
@type('number') score: number = 0;
@type('boolean') left: boolean = false;
@type('boolean') right: boolean = false;
@type('boolean') up: boolean = false;
@type('boolean') down: boolean = false;
@type('boolean') action: boolean = false;
}
input fields (left, right, up, down, action) are written by the client and consumed by the server simulation loop. Position, HP, and score are written by the server and read by clients.
How the host agent should invoke the scripts
Step 1 — init server (Tier 2 or 3 only)
node skills/multiplayer/scripts/init_server.mjs <project-dir> [--port 2567] [--voice] [--lobby]
This reads game-state.json, creates server/ and src/net/, and optionally lobby/.
Options:
--port N — Colyseus WebSocket port (default: 2567)
--voice — also create src/net/VoiceChat.js (PeerJS)
--lobby — also scaffold lobby/ React app
Step 2 — patch Game.js
node skills/multiplayer/scripts/patch_game.mjs <project-dir>
Reads existing src/scenes/Game.js, injects ColyseusClient and RemotePlayer hooks, writes the patched file back. Genre-aware (platformer vs top-down vs shooter).
Step 3 — start server + client
cd <project-dir>/server && npm install && npm run dev
cd <project-dir> && npm run dev
For local-only tier, skip Step 1 and Step 3 server start. patch_game.mjs --local adds P2 input only.
File structure created
<project>/
├── server/ # Colyseus authoritative server (Tier 2+)
│ ├── package.json # colyseus, @colyseus/monitor, typescript
│ ├── tsconfig.json
│ └── src/
│ ├── index.ts # HTTP + WS bootstrap, /colyseus monitor
│ ├── rooms/
│ │ └── GameRoom.ts # Room<GameState>, 20 Hz sim loop
│ └── schemas/
│ └── GameState.ts # MapSchema<Player> + Player schema
├── src/
│ └── net/ # Phaser client networking
│ ├── ColyseusClient.js # Colyseus.Client, room join, state sync
│ ├── RemotePlayer.js # Phaser sprite + linear interpolation
│ └── VoiceChat.js # PeerJS voice/video (--voice only)
└── lobby/ # React matchmaking UI (--lobby only)
├── package.json # react, react-redux, @reduxjs/toolkit, vite
├── vite.config.ts
└── src/
├── main.tsx
├── store.ts # Redux store
├── slices/
│ └── lobbySlice.ts # rooms list, player name, join status
└── components/
├── App.tsx
├── RoomList.tsx
└── PlayerSetup.tsx
How Game.js is patched
patch_game.mjs performs three minimal injections into the existing Game.js:
Injection 1 — imports (top of file)
import ColyseusClient from '../net/ColyseusClient.js';
import RemotePlayer from '../net/RemotePlayer.js';
Injection 2 — end of create()
Inserted just before this.events.emit('scene-ready'):
this._net = new ColyseusClient(this);
this._remotePlayers = new Map();
this._net.onStateChange((state) => this._syncFromServer(state));
Injection 3 — end of update()
Inserted at the end of the update() body:
if (this._net) this._net.sendInput(this._collectInput());
Injection 4 — new methods appended to class body
_collectInput() {
return {
left: this.cursors.left.isDown || this.keys?.A?.isDown,
right: this.cursors.right.isDown || this.keys?.D?.isDown,
up: this.cursors.up.isDown || this.keys?.W?.isDown,
down: this.cursors.down.isDown || this.keys?.S?.isDown,
action: this.keys?.SPACE?.isDown || false,
};
}
_syncFromServer(serverState) {
const myId = this._net.sessionId;
serverState.players.forEach((player, sessionId) => {
if (sessionId === myId) return;
if (!this._remotePlayers.has(sessionId)) {
const rp = new RemotePlayer(this, player.x, player.y, player.name);
this._remotePlayers.set(sessionId, rp);
} else {
this._remotePlayers.get(sessionId).syncFrom(player);
}
});
this._remotePlayers.forEach((rp, id) => {
if (!serverState.players.has(id)) { rp.destroy(); this._remotePlayers.delete(id); }
});
}
The patch preserves all existing logic; it never removes lines.
PeerJS voice/video layer (--voice)
src/net/VoiceChat.js provides optional peer-to-peer voice and video chat between players. It uses PeerJS as the signaling and connection layer on top of WebRTC.
Activation: pass --voice to init_server.mjs. The Phaser scene calls VoiceChat.init(sessionId, peersArray) after the Colyseus room join is confirmed.
The Colyseus room broadcasts a peer-joined message when a new player arrives. Existing players call voiceChat.call(newPeerId). The new player auto-answers all incoming calls. Video elements are rendered via this.add.dom() in a Phaser scene or mounted to a separate <div> overlay above the canvas.
See references/peerjs-video.md for implementation details.
React lobby (--lobby)
lobby/ is a standalone Vite React app that handles pre-game matchmaking:
- Player sets their name.
- App fetches room list from
http://localhost:2567/matchmake/lobby (Colyseus HTTP API).
- Player creates or joins a room.
- On join success, the lobby redirects to the Phaser game URL with
?room=<roomId>&session=<sessionId> query params.
ColyseusClient.js reads those params to rejoin the already-assigned room.
Redux store shape:
{ lobby: { playerName, rooms, status, error, roomId, sessionId } }
See references/colyseus-schema.md for the Colyseus HTTP matchmaking API details.
Constraints
- Max 4 players per room (set in
GameRoom.ts maxClients = 4).
- Tick rate: 20 Hz —
this.clock.setInterval(this._simulate.bind(this), 50) in GameRoom.ts.
- State sync: every 50 ms — Colyseus default patch interval; do not lower below 50ms for browser games.
- Input authority: clients send input; server computes position. Clients show server position for remote players (no client-side prediction for simplicity — add it in refiner if needed).
- Local player: client still moves the local player sprite via Phaser physics for zero-latency feel, but server position reconciles every sync (smooth toward server pos if delta > 8px).
- No split-screen in Tier 2+: with network players, each client renders only their own player via Phaser physics and all remote players via RemotePlayer sprites.
- ESM throughout:
ColyseusClient.js, RemotePlayer.js, VoiceChat.js are all ES modules.
- TypeScript on server only: the Phaser client remains vanilla JS (matching codesmith convention). Server code is TypeScript compiled with
tsc.
References
references/colyseus-schema.md — Colyseus Schema decorators, MapSchema, state listeners
references/peerjs-video.md — PeerJS API, getUserMedia, video in Phaser