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
- Server defines a matching function (e.g.,
findMatch) that returns aroomId - Client calls the matching function, then
joinRoom(roomId)fromuseGameServer()to connect to the room - Server receives lifecycle hooks automatically (
onRoomCreate,onRoomJoin,onRoomLeave, etc.) - 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.
| Hook | When | Failure 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 room | Fails 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 leaves | Best-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 hook | Best-effort |
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 away | Hooks | onRoomLeave fires |
|---|---|---|
| Closes the tab / navigates away (close code 1001) | onRoomLeave only — onRoomDisconnect never fires | Immediately |
Calls leaveRoom() | onRoomDisconnect, then onRoomLeave | After a 30s grace period |
| Loses the network, tab crashes | onRoomDisconnect, then onRoomLeave | After 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:
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 fireonRoomDestroy, 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$globalforms$room.getRoomUserAccounts(roomId?)/$room.countRoomUsers(roomId?)— omitroomIdfor 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.
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:
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:
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>
);
}