Managing Room State
Room state is a single document shared by everyone in a room — match phase, round timer, room settings. It is ephemeral: never persisted, and deleted when the last player leaves.
On the server, every room state read includes two fields the runtime maintains:
{ roomId: string, $users: string[] } — for example
{ roomId: "roomA", $users: ["account1", "account2"] }. $users is the room’s current membership,
updated as users join and leave, and it reaches clients too. roomId does not — it is added on
the server read and is not part of the stored state, so useRoomState() and subscribeRoomState
never see it. On the client, take the room id from useGameServer().currentRoomId.
Server API
$room.getRoomState(paths?: string[]): Promise<Object>: The room’s state, or only the dot-notationpathsyou ask for — see Partial Fetch.$room.updateRoomState(state: Object, opts?: { returnState?: boolean }): Promise<Object | null>: Shallow-mergesstateinto the room’s state; a top-levelundefineddeletes that field. Resolves with the patch you passed — on a hot path such as a$roomTick, pass{ returnState: false }; see Fire-and-forget writes.$room.clearRoomState(fields?: string[]): Promise<Object>: Deletes top-level fields. Wiping preservesroomIdand$users.
Partial Fetch
paths follows the global state rules, so a hot-path handler such as a $roomTick can read just the fields it needs:
const state = await $room.getRoomState(['session.status']);
// state === { roomId: 'roomA', session: { status: 'running' }, $users: [...] }Two room-specific differences: roomId and $users are always included regardless of paths, and paths === [] returns just { roomId, $users } (global state returns {}).
Client API
server.subscribeRoomState(roomId: string, (state: any) => {}): UnsubscribeFunction— roomId is required.const roomState = useRoomState()— always the current room; subscribes for you.
Both need the Room Server connection — gate on rsConnected (Joining a Room). Callbacks are throttled full snapshots, not one callback per write: the server coalesces changes on a 30 ms trailing edge and sends the whole state each time, so several rapid writes can arrive as a single callback.
Example: Room Counter
A counter shared by everyone in the room. The increment is a read-modify-write on state the whole room shares, so it runs inside $lock under a room-scoped key.
export class Server {
async countup() {
// One lock per room: two clicks that arrive together would otherwise both
// read the same count and one of the increments would be lost.
return $lock(`room-counter:${$sender.roomId}`, async () => {
const currentState = await $room.getRoomState(["count"]);
const newCount = (currentState.count || 0) + 1;
await $room.updateRoomState({ count: newCount });
return { count: newCount };
});
}
}import { useGameServer, useRoomState } from "@agent8/gameserver";
import { useEffect } from "react";
export default function CountUp() {
const { connected, server, joinRoom, rsConnected } = useGameServer();
const roomState = useRoomState();
// Join as soon as the game server is reachable.
useEffect(() => {
if (!connected) return;
joinRoom("room1").catch(console.error);
}, [connected, joinRoom]);
if (!rsConnected) return <p>Joining room...</p>;
return (
<div>
<p>Count: {roomState.count || 0}</p>
<button onClick={() => server.remoteFunction("countup")}>
Increase Count
</button>
</div>
);
}