# Multiplayer

> 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).

- Skill: `ar9av/multiplayer` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add ar9av/multiplayer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ar9av/multiplayer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Ar9av (https://skillmd.com/u/ar9av)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ar9av/multiplayer

---


# 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:

```ts
// src/schemas/GameState.ts (server-side)
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)

```bash
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

```bash
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

```bash
# Terminal 1 — Colyseus server
cd <project-dir>/server && npm install && npm run dev

# Terminal 2 — Phaser client
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)

```js
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')`:

```js
// --- multiplayer net ---
this._net = new ColyseusClient(this);
this._remotePlayers = new Map();
this._net.onStateChange((state) => this._syncFromServer(state));
// --- end multiplayer net ---
```

### Injection 3 — end of update()

Inserted at the end of the `update()` body:

```js
if (this._net) this._net.sendInput(this._collectInput());
```

### Injection 4 — new methods appended to class body

```js
_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; // local player moved by physics
    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);
    }
  });
  // destroy departed players
  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:

1. Player sets their name.
2. App fetches room list from `http://localhost:2567/matchmake/lobby` (Colyseus HTTP API).
3. Player creates or joins a room.
4. On join success, the lobby redirects to the Phaser game URL with `?room=<roomId>&session=<sessionId>` query params.
5. `ColyseusClient.js` reads those params to rejoin the already-assigned room.

Redux store shape:
```ts
{ 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

