# Real-Time Multiplayer Reference - Raw Colyseus Client

The long tail of [app-realtime](app-realtime.md): driving a room with the **raw Colyseus client** instead of the `@gipity/realtime` kit.

**Read [app-realtime](app-realtime.md) first, and prefer the kit.** `gipity add realtime` wraps everything on this page - `onStateChange` diffing, tokens, reconnection, presence, lobby + match rooms - behind a tested, engine-agnostic API. Reach for the raw client only when you need something the kit does not expose, or to understand what it does internally.

Rooms must still be provisioned before any client can connect, and the realtime build loop still ends with a concurrent two-client check - both are covered in [app-realtime](app-realtime.md).

## Quick start (raw client)

1. Provision a room (see "Provisioning a room" in [app-realtime](app-realtime.md)) - e.g. the agent tool:
```
realtime_room action=create name=game-lobby room_type=state auth_level=public max_clients=50
```

2. In your app's HTML, load the realtime client from CDN (every raw-client API is documented on this page - do not search the web for external docs). Pin the exact version - the state-room API below is written for `0.16.22`:
```html
<script src="https://unpkg.com/colyseus.js@0.16.22/dist/colyseus.js"></script>
```

3. Connect to the room:

**IMPORTANT:** The token endpoint is on the API server, NOT the app host. You MUST use the absolute URL `https://a.gipity.ai/api/token` - never a relative path like `/api/token`. It is a POST request and the token is nested under `data`.

```js
// Get app token - MUST be absolute URL to API server, POST with app GUID
const resp = await fetch('https://a.gipity.ai/api/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: '<PROJECT_GUID>' })
});
const { data: { token } } = await resp.json();
// ✗ WRONG: fetch('/api/token')           - relative URL hits app host, not API
// ✗ WRONG: fetch('https://a.gipity.ai/api/token')  with GET - must be POST
// ✗ WRONG: const { token } = await ...   - token is inside data: { data: { token } }

const client = new Colyseus.Client("wss://rt.gipity.ai");
const room = await client.joinOrCreate("state", {
  app: "<PROJECT_GUID>",
  room: "game-lobby",
  token
});
```

## Room Discovery (Lobby / Matchmaking)

The client library does **NOT** have a room listing function. To list rooms, use the REST endpoint:

```js
// List available rooms (uses the app token from step 1)
const roomsResp = await fetch(
  'https://rt.gipity.ai/rooms?room=game-lobby&token=' + encodeURIComponent(token)
);
const { rooms } = await roomsResp.json();
// rooms = [{ roomId, clients, maxClients, metadata }, ...]
```

### Lobby Pattern - Join Existing or Create New
```js
const roomsResp = await fetch(
  'https://rt.gipity.ai/rooms?room=game-lobby&token=' + encodeURIComponent(token)
);
const { rooms } = await roomsResp.json();

// Find a room that isn't full
const available = rooms.find(r => r.clients < r.maxClients);
let room;
if (available) {
  // Join existing room by ID
  room = await client.joinById(available.roomId, {
    app: "<PROJECT_GUID>", room: "game-lobby", token
  });
} else {
  // No room available - create a new one
  room = await client.joinOrCreate("state", {
    app: "<PROJECT_GUID>", room: "game-lobby", token
  });
}
```

> **Note:** The `room` query param is optional - omit it to list all rooms for your app. Add `&scope=<value>` to list only one scope's instances.
> **Never use** `client.getAvailableRooms()` - it does not exist in the client library.
> Raw join options also accept `scope` (opaque instance partition key - same semantics as the kit's `scope` config, documented in [app-realtime](app-realtime.md)).

## Relay Room Patterns

```js
// Send a typed message
room.send("chat", { user: "Alice", text: "Hello!" });
room.send("move", { x: 10, y: 20 });

// Receive messages by type
room.onMessage("chat", (msg) => {
  console.log(msg.user + ": " + msg.text);
});
room.onMessage("move", (msg) => {
  movePlayer(msg.x, msg.y);
});
```

## State Room Patterns

**IMPORTANT - how to read state-room state.** The Colyseus client (`colyseus.js@0.16.22`) does **not** expose `.onAdd()` / `.onChange()` / `.onRemove()` callbacks on the `players` and `data` maps - those were removed after 0.14, and calling them throws `TypeError: ... is not a function`. Instead, react to state inside `room.onStateChange` - it fires with the **full state** on every server update - and diff it against what you have already seen. The maps are also `undefined` on a fresh room, so always guard before reading them.

The canonical diff loop for both maps lives in "State room - safe initialization boilerplate" at the bottom of this page - copy it from there rather than re-deriving it. The sections here cover the messages you send and the APIs that do not exist.

### Players (auto-tracked)

The server tracks joined players in `room.state.players` (each record carries `displayName`). Detect joins and leaves by diffing that map inside `room.onStateChange` (see the boilerplate); set custom per-player fields with a message:

```js
// Set custom player data (e.g. score, position)
room.send("set_player_data", { data: JSON.stringify({ score: 100 }) });

// ✗ WRONG - .onAdd is not a function in colyseus.js 0.16:
// state.players.onAdd((player, sid) => { ... })
```

### Shared Data (key-value, auto-synced)

Any client can set a key and every client receives it. Values are JSON strings - compare them raw to spot a change, then parse. Detect changes and deletions by diffing `room.state.data` (see the boilerplate):

```js
// Set shared data (any client can set, all clients receive)
room.send("set_data", { key: "gameState", value: JSON.stringify({ round: 1, phase: "playing" }) });
room.send("delete_data", { key: "oldKey" });

// ✗ WRONG - .onChange is not a function in colyseus.js 0.16:
// state.data.onChange((value, key) => { ... })
```

### Custom Messages (broadcast to all)
```js
// Any unrecognized message type is broadcast to all other clients
room.send("explosion", { x: 50, y: 30, radius: 10 });
room.onMessage("explosion", (data) => renderExplosion(data));
```

## Turn-Based Game Pattern

Use a state room with a `currentTurn` key:
```js
// Host sets initial turn
room.send("set_data", { key: "currentTurn", value: JSON.stringify(room.sessionId) });
room.send("set_data", { key: "board", value: JSON.stringify(Array(9).fill(null)) });

// On each move, update board + advance turn
function makeMove(index) {
  const board = JSON.parse(room.state.data.get("board"));
  board[index] = mySymbol;
  room.send("set_data", { key: "board", value: JSON.stringify(board) });
  room.send("set_data", { key: "currentTurn", value: JSON.stringify(opponentSessionId) });
}

// React to board/turn changes by diffing data on every update
const seen = new Map();
room.onStateChange((state) => {
  if (!state.data) return;
  state.data.forEach((value, key) => {
    if (seen.get(key) === value) return;   // unchanged
    seen.set(key, value);
    if (key === "board") renderBoard(JSON.parse(value));
    if (key === "currentTurn") updateTurnIndicator(JSON.parse(value));
  });
});
```

## State room - safe initialization boilerplate

The canonical diff loop. Copy-paste this as your starting point for any state room app - it covers player join/leave and shared-data change/delete in one `onStateChange` handler:
```js
// 1. Get token
const resp = await fetch('https://a.gipity.ai/api/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: '<PROJECT_GUID>' })
});
const { data: { token } } = await resp.json();

// 2. Connect (or use Room Discovery to join an existing room - see above)
const client = new Colyseus.Client("wss://rt.gipity.ai");  // from CDN loaded above
const room = await client.joinOrCreate("state", {
  app: "<PROJECT_GUID>",
  room: "my-room",
  token
});

// 3. React to state by diffing it. onStateChange fires with the full state
//    on every server update; colyseus.js 0.16 has NO .onAdd/.onChange
//    callbacks on the maps - diff against what you have seen instead.
const knownPlayers = new Set();
const dataSeen = new Map();   // key -> last raw JSON string
room.onStateChange((state) => {
  // Players
  if (state.players) {
    const present = new Set();
    state.players.forEach((player, sessionId) => {
      present.add(sessionId);
      if (!knownPlayers.has(sessionId)) {
        knownPlayers.add(sessionId);
        // Handle player join
      }
    });
    for (const sessionId of [...knownPlayers]) {
      if (!present.has(sessionId)) {
        knownPlayers.delete(sessionId);
        // Handle player leave
      }
    }
  }
  // Shared data - values are JSON strings
  if (state.data) {
    state.data.forEach((value, key) => {
      if (dataSeen.get(key) !== value) {
        dataSeen.set(key, value);
        const parsed = JSON.parse(value);
        // Handle data change
      }
    });
    for (const key of [...dataSeen.keys()]) {
      if (!state.data.has(key)) {
        dataSeen.delete(key);
        // Handle data delete
      }
    }
  }
});

// 4. Send messages
room.send("set_data", { key: "myKey", value: JSON.stringify({ foo: "bar" }) });

// 5. Cleanup on leave
room.onLeave((code) => {
  console.log("Left room, code:", code);
});
```

## Related
- [app-realtime](app-realtime.md) - the main guide: the `@gipity/realtime` kit, party/lobby flows, presence, provisioning, and how to verify multiplayer across concurrent clients
- [realtime-scheduled-app](realtime-scheduled-app.md) - end-to-end recipe combining presence + messages with a database and a scheduled poster

