Managing Room User State
Each player in a room has a state of their own there — position, HP, per-match score. It contains exactly what you have written to it.
Room user state is not seeded with an account field.
$room.getMyState() and $room.getUserState(account) return the stored
hash verbatim, so a user who has never been written to returns {} — use
$sender.account for the caller’s identity rather than reading it out of the state.
The batch readers $room.getAllUserStates() and
$room.getUserStates(accounts) are the exception: they add account to
each entry so you can tell the rows apart.
Server API
$room.getMyState(): Promise<Object>— Retrieves the caller’s room-specific state.$room.updateMyState(state: Object, opts?: { returnState?: boolean }): Promise<Object | null>— Updates your room-specific state. Resolves with the patch you passed, not the merged document; pass{ returnState: false }on a hot path to skip shipping it back — see Fire-and-forget writes.$room.getUserState(account: string): Promise<Object>— Retrieves a particular user’s room state. Unlike$global.getUserState, it takes nopathsargument and always reads the full hash.$room.getUserStates(accounts: string[], paths?: string[]): Promise<Array<Object & { account: string }>>— Reads a chosen subset of users in one pipelined call. Order-preserving 1:1 withaccounts; a user with no state comes back as{ account }only.pathsfollows the Partial Fetch rules, so a$roomTickcan read just['position']instead of every user’s full state;paths === []skips the body read entirely, and omittingpathsreturns full states. Prefer this overgetAllUserStates()when you need specific users or only a few fields.$room.updateUserState(account: string, state: Object, opts?: { returnState?: boolean }): Promise<Object | null>— Updates a particular user’s room state. Same return contract asupdateMyState.$room.clearUserState(account: string, fields?: string[]): Promise<Object>/$room.clearMyState(fields?: string[]): Promise<Object>— Delete a user’s room state. No argument wipes the whole hash and resolves with{};clear([ids])deletes only those top-level fields and resolves with what is left;clear([])is a no-op.$room.countUsers(): Promise<number>- Retrieves number of joined users in the room.$room.getAllUserStates(): Promise<Array<Object & { account: string }>>- Retrieves all user states in the room (full state for every user — nopathsfilter).
Leaving does not clear a player’s room user state — only the room’s end does. A player who
leaves and later rejoins a room that stayed alive gets their old values back, so initialize
per-match fields in onRoomJoin, as below. When the last player leaves, every user’s room state
is deleted right after onRoomDestroy
runs, so the hook can still read it. It is never persisted — it is also gone if the room’s server
restarts.
export class Server {
// A player rejoining a room that stayed alive still has their old room
// state, so reset it on the way in.
async onRoomJoin(roomId, account) {
await $room.clearUserState(account);
await $room.updateUserState(account, { hp: 100, score: 0 });
}
}Client API
server.subscribeRoomMyState(roomId: string, (state: any) => {}): UnsubscribeFunction- roomId is required.server.subscribeRoomUserState(roomId: string, account: string, (state: any) => {}): UnsubscribeFunction- roomId is required.server.subscribeRoomAllUserStates(roomId: string, (states: Array<Record<string, any> & { account: string; __updated?: boolean }>) => {}): UnsubscribeFunction- roomId is required. The entry that changed carries__updated: true— note the two leading underscores, and that the key is absent (notfalse) on the entries that did not change, so test it withif (s.__updated).const myState = useRoomMyState()— the caller’s own room-user state. NotuseRoomState(), which is the room’s shared state.const userState = useRoomUserState(account: string)const states = useRoomAllUserStates()— the array itself, not an object wrapping one:Array<Record<string, any> & { account: string }>.
The hooks always target the current room and subscribe for you. The raw subscribe* calls need the room id and the Room Server connection (rsConnected).
Example: Syncing User Positions
Each player’s mouse position is written to their own room-user state, and every player’s position is rendered as a circle.
export class Server {
async updateMyPosition(position) {
// 'position' should be an object: { x: number, y: number }
return await $room.updateMyState({ position });
}
}import { useGameServer, useRoomAllUserStates } from "@agent8/gameserver";
import { useEffect } from "react";
function PositionTracker({ roomId }: { roomId: string }) {
// `account` is your identity. Do NOT look for it inside your own room state —
// useRoomMyState() returns only what you wrote, with no account field.
const { account, connected, server, joinRoom, rsConnected } = useGameServer();
const states = useRoomAllUserStates(); // Array<Record<string, any> & { account: string }>
// Join as soon as the game server is reachable...
useEffect(() => {
if (!connected) return;
// Join the room via the SDK (handles Gateway routing + RS connection)
joinRoom(roomId).catch(console.error);
}, [connected, joinRoom, roomId]);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!rsConnected) return;
const position = { x: e.clientX, y: e.clientY };
server
.remoteFunction("updateMyPosition", [position], { throttle: 100 })
.catch(console.error);
};
// $room state is only available once the Room Server connection is up
if (!rsConnected) return <p>Joining room...</p>;
return (
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100vh",
}}
onMouseMove={handleMouseMove}
>
{states.map((state) => (
<div
key={state.account}
style={{
position: "absolute",
top: state.position?.y || 0,
left: state.position?.x || 0,
width: 20,
height: 20,
borderRadius: "50%",
background: state.account === account ? "blue" : "red",
}}
/>
))}
</div>
);
}
export default function App() {
return <PositionTracker roomId="room1" />;
}