Skip to Content
Game ServerRoom System

Room System

In Agent8, rooms are the core unit of real-time multiplayer gameplay. The server decides which room a player goes to, the client joins it through the SDK, and the runtime tracks who is inside and calls your lifecycle hooks.

How Rooms Work

  1. Server defines a matching function (e.g., findMatch) that returns a roomId
  2. Client calls the matching function, then joinRoom(roomId) from useGameServer() to connect to the room
  3. Server receives lifecycle hooks automatically (onRoomCreate, onRoomJoin, onRoomLeave, etc.)
  4. Room state ($room) and membership ($users) are shared with everyone in the room in real time

Server: Room Lifecycle Hooks

Five lifecycle hooks control the full room lifecycle. All are optional — define only what you need.

HookWhenFailure behavior
onRoomCreate(roomId)First user joins (room created)Fails the join — but the room and its first member are already registered, and are not rolled back
onRoomJoin(roomId, account)Each user enters the roomFails the join, and the user is rolled back out of $users. The client SDK treats this as transient and retries the join until its deadline — to turn a player away, reject in your matching function instead
onRoomDisconnect(roomId, account)Connection lost (network drop)Best-effort
onRoomLeave(roomId, account)User permanently leavesBest-effort
onRoomDestroy(roomId)Last user left (room empty) — after the shared room state has been deleted; room user states and room collections are deleted right after this hookBest-effort
server/src/server.ts
export class Server { async onRoomCreate(roomId) { // Called once, when the first user joins. // Do NOT seed `players: []` here — a second player's onRoomJoin can land // before this hook runs, and the blind overwrite would erase them. await $room.updateRoomState({ createdAt: Date.now() }); } async onRoomJoin(roomId, account) { // Room hooks are NOT serialized — two players joining at the same moment // run this concurrently, so the read-modify-write needs $lock or one of // the two appends is lost. (For a plain roster, prefer the built-in // $users array, which the runtime maintains for you.) await $lock(`room:${roomId}`, async () => { const state = (await $room.getRoomState()) || {}; const players = state.players || []; players.push(account); await $room.updateRoomState({ players }); }); } async onRoomDisconnect(roomId, account) { // The user is still "in" the room during the grace period (below). await $room.broadcastToRoom('playerDisconnected', { account }); } async onRoomLeave(roomId, account) { // Same lock key as onRoomJoin — a leave racing a join would otherwise // resurrect the leaver or drop the joiner. await $lock(`room:${roomId}`, async () => { const state = (await $room.getRoomState()) || {}; const players = (state.players || []).filter(p => p !== account); await $room.updateRoomState({ players }); }); } async onRoomDestroy(roomId) { // The shared room state has ALREADY been deleted — $room.getRoomState() // returns no custom fields here; persist it from onRoomLeave instead. // Room user states and room collections are still readable, and are // deleted as soon as this hook returns. } }

Leaving and the Grace Period

Which hooks fire, and when, depends on how the player’s socket closed:

How the player goes awayHooksonRoomLeave fires
Closes the tab / navigates away (close code 1001)onRoomLeave only — onRoomDisconnect never firesImmediately
Calls leaveRoom()onRoomDisconnect, then onRoomLeaveAfter a 30s grace period
Loses the network, tab crashesonRoomDisconnect, then onRoomLeaveAfter a 30s grace period

leaveRoom() waits too because the SDK closes the socket without a status code, which the runtime cannot distinguish from a dropped connection.

During the grace period the player is still in the room: $users includes them and $roomTick keeps running. Reconnecting inside the window resumes the session without re-firing onRoomJoin.

onRoomLeave is the one hook that runs on every exit. Put freeing a slot, awarding the win or substituting an AI there, and use onRoomDisconnect only for what you want to show during the grace window, such as a “reconnecting…” badge.

Server: Room Matching

The server is responsible for matching logic. Define a function that finds an available room or makes up a new id, then return the roomId:

server/src/server.ts
export class Server { ROOM_MAX_USERS = 4 async findMatch() { const roomIds = await $global.getAllRoomIds(); for (const roomId of roomIds) { const count = await $global.countRoomUsers(roomId); if (count < this.ROOM_MAX_USERS) { return roomId; // Found a room with space } } // No room available — a new id; the room is created when the first player joins it return 'Room' + Math.random().toString(16).substring(2, 10); } }
⚠️

$global.joinRoom() and $global.leaveRoom() are not available — the server never joins rooms. Return a roomId and let the client join with the SDK. Don’t name the matching function joinRoom or leaveRoom either: those names are reserved and a server function under either is unreachable.

Server: Room Discovery API

$global answers “which rooms exist and who is in them” from anywhere — a lobby function, a matching function, or a handler inside a room:

  • $global.countRooms(): Promise<number> — Total number of active rooms
  • $global.getAllRoomIds(): Promise<string[]> — All active room IDs
  • $global.getRoomUserAccounts(roomId): Promise<string[]> — Users in a specific room
  • $global.countRoomUsers(roomId): Promise<number> — User count in a specific room
  • $global.destroyRoom(roomId): Promise<void> — Clears a room’s membership. It does not fire onRoomDestroy, delete the room’s collections, or disconnect clients; from outside the room it does not delete room state either. See the caveat in Upgrading to 2.0.

$room offers the same four, plus one that $global does not have — and inside a room they can target a room other than your own:

  • $room.countRooms() / $room.getAllRoomIds() — same as the $global forms
  • $room.getRoomUserAccounts(roomId?) / $room.countRoomUsers(roomId?) — omit roomId for the current room
  • $room.getAllRoomStates(): Promise<Object[]> — every active room’s full state
⚠️

$room.getAllRoomStates() is the only way to read other rooms’ custom state, and it only works from inside a room. Called from a function that is not in a room it returns just roomId and $users per room, with custom fields empty rather than erroring — so a lobby built on it silently renders blanks.

For a lobby, have the room mirror what it needs into global storage: $global.updateGlobalState() from onRoomCreate / onRoomJoin, or one global-collection item per room ($global.addCollectionItem).

Room state itself (getRoomState, updateRoomState, …) is only reachable through $room, inside the room — see Room State and Room User State.

Client: Joining a Room

joinRoom(roomId) from useGameServer() routes the client to the room’s Room Server and connects to it. From then on the client holds two connections: connected reports the game server, rsConnected the Room Server. Everything room-scoped — server functions that use $room, useRoomState() and the other room hooks, onRoomMessage — needs the second, so gate room UI on rsConnected, not connected. currentRoomId holds the joined room’s id, or null.

Use the hook’s joinRoom and leaveRoom, not the lower-level server.joinRoom() / server.leaveRoom(): those open or close the Room Server socket without updating rsConnected and currentRoomId, so room hooks and UI gated on them keep rendering the wrong thing — and the hook’s retry on a transient routing failure is skipped.

src/JoinRoomExample.tsx
import { useGameServer } from "@agent8/gameserver"; export default function JoinRoomExample() { const { connected, server, joinRoom, leaveRoom, currentRoomId, rsConnected } = useGameServer(); const handleJoin = async () => { if (!connected) return; // 1. Ask server to find a room const roomId = await server.remoteFunction("findMatch", []); // 2. Join the room (SDK handles RS connection) await joinRoom(roomId); }; const handleLeave = () => { leaveRoom(); }; return ( <div> {rsConnected ? ( <div> <p>In room: {currentRoomId}</p> <button onClick={handleLeave}>Leave Room</button> </div> ) : ( <button onClick={handleJoin} disabled={!connected}> Find & Join Room </button> )} </div> ); }
⚠️

Testing with two players locally: tabs on the same origin share one anonymous account (cached in localStorage under agent8:temporary_account), so a second tab re-joins as the same player instead of adding one — onRoomJoin and onRoomCreate fire again for that account, and closing either tab removes it for both. Give each tab its own identity with a query parameter: ?account=alice in one and ?account=bob in the other. This works only while the client is unauthenticated (local dev / preview); once a real auth token is presented the account comes from the token and ?account= is ignored.

Client: Monitoring Room User Events

Monitor when users join or leave a room in real-time:

src/RoomUserEvents.tsx
import { useGameServer } from "@agent8/gameserver"; import { useEffect, useState } from "react"; export default function RoomUserEvents() { const { connected, server, joinRoom, currentRoomId, rsConnected } = useGameServer(); const [events, setEvents] = useState([]); useEffect(() => { if (!rsConnected || !currentRoomId) return; const unsubJoin = server.onRoomUserJoin(currentRoomId, (account) => { setEvents((prev) => [...prev, { type: "join", account, time: new Date() }]); }); const unsubLeave = server.onRoomUserLeave(currentRoomId, (account) => { setEvents((prev) => [...prev, { type: "leave", account, time: new Date() }]); }); return () => { unsubJoin(); unsubLeave(); }; }, [rsConnected, currentRoomId, server]); if (!rsConnected) return <p>Not in a room</p>; return ( <div> <h3>Room Activity: {currentRoomId}</h3> <ul> {events.map((e, i) => ( <li key={i}> {e.type === "join" ? "➡️" : "⬅️"} {e.account} — {e.time.toLocaleTimeString()} </li> ))} </ul> </div> ); }

Client: Displaying Room Users

The $users array in room state is the room’s membership, maintained by the runtime as users join and leave — useRoomState() renders it directly:

src/RoomUsersList.tsx
import { useGameServer, useRoomState } from "@agent8/gameserver"; export default function RoomUsersList() { const { rsConnected, currentRoomId } = useGameServer(); const roomState = useRoomState(); if (!rsConnected) return <p>Not in a room</p>; return ( <div> <h3>Users in Room: {currentRoomId}</h3> <p>Total: {roomState.$users?.length || 0}</p> <ul> {roomState.$users?.map((user) => ( <li key={user}>{user}</li> ))} </ul> </div> ); }
Last updated on