Skip to Content
Game ServerGlobal Collection

Managing Global Collection Data

A collection is a list of items — chat logs, match history, rankings — that you query, sort and page. Global collections are stored in Firestore and shared by the whole verse; for lists scoped to one room, see Room Collection.

Server API

  • $global.getCollectionItems(collectionId: string, options?: CollectionOptions): Promise<Item[]>
    Retrieves multiple items from a collection based on filtering, sorting, and pagination options.

  • $global.getCollectionItem(collectionId: string, itemId: string): Promise<Item>
    Retrieves a single item from a collection using its unique ID. It throws when the item does not exist (Item <id> not found in collection <cid>) — it does not return {} or null. Wrap it in try/catch, or use getCollectionItems with a filter when a miss is an expected outcome.

  • $global.countCollectionItems(collectionId: string, options?: CollectionOptions): Promise<number>
    Returns the number of items matching filters. This is a server-side count — it is not capped at 100, and orderBy / limit / cursors do not apply. Unlike the reads, it does not include writes still in the ~2s write buffer (below), so a count taken right after an add can miss it.

  • $global.addCollectionItem(collectionId: string, item: any, options?: { id?: string }): Promise<Item>
    Adds a new item to the specified collection and returns the added item with its unique identifier. Pass options.id to control the item id (upsert / full overwrite — re-adding the same id replaces the item). The id must be a non-empty string with no /, not ./.., not matching __...__, and ≤1500 bytes; an invalid id throws. Without options.id, an id is generated automatically.

  • $global.updateCollectionItem(collectionId: string, item: any): Promise<{ __id: string }>
    Merges item into an existing item in the collection. The item you pass must carry __id — it is how the target is found, and the call throws Item must have __id to update… without it. The return value is only { __id }, not the merged item; read it back with getCollectionItem if you need the result. Unlike $room.updateCollectionItem, it does not throw when no item with that id exists — it creates a document holding only the fields you passed.

  • $global.deleteCollectionItem(collectionId: string, itemId: string): Promise<{ __id: string }>
    Deletes an item from the collection and returns a confirmation containing the deleted item’s ID.

  • $global.deleteCollection(collectionId: string): Promise<{ collectionId: string }>
    Deletes the collection and returns a confirmation containing the deleted collection ID.

⚠️

Writes are buffered for ~2s before they are committed to Firestore. Reads see a buffered write at once, and the buffer survives a game-server crash — but not a failure of the verse’s in-memory store, which loses whatever it held, including an add with a supplied id. Do not use a global collection as the sole record for currency / NFT / reward writes — use $asset for those.

🚫

Do not re-export these methods as remote functions under their own names. Every Server method is a public endpoint, so a pass-through addCollectionItem(collectionId, item) lets anyone write { score: 999999 } into rankings. Let server logic decide the collection and the values — the example below does, including the ownership check in deleteChat.

Query Options

getCollectionItems and countCollectionItems take an optional CollectionOptions:

FieldTypeNotes
filtersQueryFilter[]Conditions, combined with AND.
orderBy{ field, direction: 'asc' | 'desc' }[]Sort order.
limitnumberMax items per call (capped — see below).
startAfter / endBeforeanyCursor relative to the first orderBy field.

A QueryFilter is { field, operator, value }. Supported operators:

OperatorMeaning
==, !=equals / not equals
<, <=, >, >=range comparisons
in, not-invalue is (not) one of a list of up to 30
array-containsarray field contains value
array-contains-anyarray field contains any of a list of up to 30
server/src/server.ts
// Filter only, then sort what you got back — no composite index needed. const admins = await $global.getCollectionItems("users", { filters: [{ field: "role", operator: "==", value: "admin" }], }); admins.sort((a, b) => b.createdAt - a.createdAt);
⚠️

Filtering on one field and ordering by a different one needs a composite index, which is declared in the platform repository — you cannot provision it from game code, and without it the query fails with FAILED_PRECONDITION. Adding orderBy: [{ field: "createdAt", direction: "desc" }] to the query above is exactly that shape, and it would stop running unless the index already exists.

Stay inside what works without an index: filter and order on the same field, or filter only and sort the result in memory, as above (fine for the ≤100 items a call returns).

⚠️

getCollectionItems returns at most 100 items per call — a limit above 100 is capped to 100. To read more, page with startAfter (below). countCollectionItems is not capped: it returns the true total of matching items.

Pagination

Pass the last item’s orderBy value as startAfter to fetch the next page:

server/src/server.ts
const PAGE = 100; // also the server cap — a larger value is clamped back to 100 let cursor; const all = []; while (true) { const page = await $global.getCollectionItems("scores", { orderBy: [{ field: "score", direction: "desc" }], limit: PAGE, ...(cursor !== undefined ? { startAfter: cursor } : {}), }); all.push(...page); if (page.length < PAGE) break; // last page cursor = page[page.length - 1].score; }
⚠️

startAfter is a value cursor on the first orderBy field, applied as a strict > / < comparison — every item whose value equals the cursor is skipped. Paging on a field with duplicate values (score above, level, role) silently drops the rows that tie with the last row of a page. When you need every row, page on a field whose value is unique per item.

Client API

  • server.subscribeGlobalCollection(collectionId: string, ({ items, changes }: { items: any[], changes: { op: 'add'|'update'|'delete'|'deleteAll', items: any[]} }) => {}): UnsubscribeFunction
  • const { items } = useGlobalCollection(collectionId) — subscribes and unsubscribes for you.

A subscription starts from a snapshot of at most 100 items, in id order — no filter, no sort — and then applies each change as it happens. That suits a small list. For a large or sorted one — a chat history, a leaderboard — query what you need through a server function instead, as the example below does.


Example: Chat

The server owns every write and exposes a getChats() read for the newest 50; the client calls it on mount and again after every write. To show other players’ messages the moment they are sent, broadcast them as well — see Real-time Messaging.

server/src/server.ts
export class Server { async sendChat(message) { const chatItem = { sender: $sender.account, message, timestamp: Date.now(), }; return await $global.addCollectionItem("chats", chatItem); } async deleteChat(id) { const chat = await $global.getCollectionItem("chats", id); if (chat.sender !== $sender.account) { return { error: "Not authorized to delete this message" }; } return await $global.deleteCollectionItem("chats", id); } async getChats() { const items = await $global.getCollectionItems("chats", { orderBy: [{ field: "timestamp", direction: "desc" }], limit: 50, }); return items.reverse(); // newest 50, oldest-first for rendering } }
App.tsx
import { useGameServer } from "@agent8/gameserver"; import { useState, useEffect, useCallback } from "react"; export default function Chat() { const { connected, server } = useGameServer(); const [chats, setChats] = useState<any[]>([]); const [message, setMessage] = useState(""); // The newest 50, sorted — not what the subscription snapshot gives — so pull them. const refresh = useCallback(async () => { setChats(await server.remoteFunction("getChats")); }, [server]); useEffect(() => { if (!connected) return; refresh(); }, [connected, refresh]); if (!connected) return <p>Loading...</p>; const handleSend = async () => { if (!message.trim()) return; await server.remoteFunction("sendChat", [message]); setMessage(""); await refresh(); }; const handleDelete = async (id: string) => { await server.remoteFunction("deleteChat", [id]); await refresh(); }; return ( <div> <div> {chats.map((chat) => ( <div key={chat.__id}> <span>{chat.sender}: </span> <span>{chat.message}</span> <button onClick={() => handleDelete(chat.__id)}>Delete</button> </div> ))} </div> <input type="text" value={message} onChange={(e) => setMessage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSend()} /> <button onClick={handleSend}>Send</button> </div> ); }
Last updated on