Skip to Content
Game ServerGlobal State

Managing Global State

Global state is a single document shared by every player and persisted across sessions. Use it for game-wide values — game status, season settings, maintenance messages, shared counters. Per-player data belongs in Global User State; growing lists belong in a Global Collection.

Server API

  • $global.getGlobalState(paths?: string[]): Promise<Object>: The whole state, or only the dot-notation paths you ask for — see Partial Fetch.
  • $global.updateGlobalState(state: Object, opts?: { returnState?: boolean }): Promise<Object | null>: Shallow-merges state into the document — see Merge Semantics; a top-level undefined deletes that field. Resolves with the patch you passed, not the merged document — see Fire-and-forget writes.
  • $global.clearGlobalState(fields?: string[]): Promise<Object>: Deletes top-level fields. With no argument it wipes every user-defined field (lifecycle metadata is kept); clear([]) is a no-op. Persists immediately.

Partial Fetch

Pass dot-notation paths to read only part of a large state — useful on hot paths such as a room tick. The result keeps the nested shape:

server/src/server.ts
// full state: { profile: { name, level, inventory: [...] }, stats: { hp, mp } } const result = await $global.getGlobalState(['stats.hp', 'profile.level']); // result === { stats: { hp: 100 }, profile: { level: 12 } }

Path rules, shared by every paths argument in the SDK:

  • Paths are dot-separated, with a maximum depth of 5 (a.b.c.d.e). Paths under the same top-level field merge into one sub-object.
  • Well-formed paths that do not exist are silently omitted — there is no undefined placeholder.
  • Malformed paths throw: an empty string, an empty component ('a..b'), or a depth over 5.

Here, paths === [] returns {} without reading the state, and omitting paths returns the full state.

⚠️

paths (read) vs fields (clear) are different. getGlobalState(paths) accepts dot-notation paths. clearGlobalState(fields) accepts top-level field names only, matched literally — 'profile.name' looks for a top-level field with that exact name and almost always deletes nothing. To remove a nested field, read the parent, delete the key, and write the parent back with updateGlobalState. The same holds for every clear* in the SDK.

Merge Semantics

Updates merge only at the top level. Each top-level key of your patch replaces that whole field, so a nested object is overwritten wholesale rather than merged into:

server/src/server.ts
// stored: { profile: { name: 'kim', level: 3 } } await $global.updateGlobalState({ profile: { level: 4 } }); // stored: { profile: { level: 4 } } ← `name` is GONE, not preserved

To change one nested field, read the branch, spread it, and write it back:

server/src/server.ts
const { profile } = await $global.getGlobalState(['profile']); await $global.updateGlobalState({ profile: { ...profile, level: 4 } });

Persistence Limits

State is served from memory and persisted to Firestore in the background. Firestore caps a single document at 1 MiB and at 40,000 index entries, so the runtime checks a state document before persisting it:

GuardThresholdWhat happens when exceeded
Document size~900 KBThe persist is skipped (no retry)
Index entries~35,000 estimated (≈17,500 leaf fields — every leaf costs ~2 entries)The persist is skipped (no retry)

When a guard trips, the runtime logs one error to the game log (verse:persist_failed), then stays quiet until the document drops back under the threshold. Reads and writes keep succeeding the whole time, so nothing looks wrong until the server restarts and the state reloads from the last successful persist.

The guards apply to global state, per-user state, and $asset balances. Room state is never persisted, so it is unaffected.

⚠️

Persisting merges into the stored document, so fields you deleted are not removed from it — a document that has already grown past a limit can keep failing even after you stop writing to it. Recovering may need a full clearGlobalState() / clearUserState(account).

Keep global state for small, hot data. Large keyed datasets (per-player records, match history, logs) belong in a Global Collection — one small item per entry — not in a single ever-growing state document.

Client API

  • server.subscribeGlobalState((state: any) => {}): UnsubscribeFunction
  • const globalState = useGlobalState() — subscribes and unsubscribes for you; prefer it in React.

Every subscribe* in the SDK returns its unsubscribe function. When you use one directly, call it inside useEffect and return it:

useEffect(() => { if (!connected) return; return server.subscribeGlobalState((state) => { // react to the new state }); }, [connected, server]);

Example 1: Global Counter

A counter shared by every player. The increment is a read-modify-write on shared state, so it runs inside $lock.

server/src/server.ts
export class Server { async countup() { // Without the lock, two clicks that arrive together both read the same // count and one of the increments is lost. return $lock("global-counter", async () => { const currentState = await $global.getGlobalState(["count"]); const newCount = (currentState.count || 0) + 1; await $global.updateGlobalState({ count: newCount }); return { count: newCount }; }); } }
src/App.tsx
import { useGameServer, useGlobalState } from "@agent8/gameserver"; export default function CountUp() { const { connected, server } = useGameServer(); const globalState = useGlobalState(); if (!connected) return <p>Loading...</p>; return ( <div> <p>Count: {globalState.count || 0}</p> <button onClick={() => server.remoteFunction("countup")}> Increase Count </button> </div> ); }

Example 2: Maintenance Mode

A maintenance toggle only an administrator can flip. Every connected client sees the change through useGlobalState().

server/src/server.ts
export class Server { isAdmin() { const ADMIN_ACCOUNT = "0x1234567890123456789012345678901234567890"; return $sender.account === ADMIN_ACCOUNT; } async setMaintenanceMode(enabled, message) { if (!this.isAdmin()) { return { error: "Unauthorized action" }; } await $global.updateGlobalState({ status: enabled ? "MAINTENANCE" : "ACTIVE", maintenanceMessage: enabled ? message : null, lastUpdated: Date.now(), }); return $global.getGlobalState(); } }
src/App.tsx
import { useGameServer, useGlobalState } from "@agent8/gameserver"; export default function MaintenanceToggle() { const { connected, server } = useGameServer(); const globalState = useGlobalState(); if (!connected) return <p>Loading...</p>; const handleToggle = async () => { const enable = globalState.status === "ACTIVE"; const newMessage = enable ? prompt( "Enter maintenance message:", globalState.maintenanceMessage || "" ) : null; await server.remoteFunction("setMaintenanceMode", [enable, newMessage]); }; return ( <div> <p>Status: {globalState.status || "ACTIVE"}</p> {globalState.status === "MAINTENANCE" && ( <p> Message:{" "} {globalState.maintenanceMessage || "Maintenance mode activated"} </p> )} <button onClick={handleToggle}> {globalState.status === "ACTIVE" ? "Enable Maintenance Mode" : "Disable Maintenance Mode"} </button> </div> ); }
Last updated on