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{}ornull. Wrap it intry/catch, or usegetCollectionItemswith a filter when a miss is an expected outcome. -
$global.countCollectionItems(collectionId: string, options?: CollectionOptions): Promise<number>
Returns the number of items matchingfilters. This is a server-side count — it is not capped at 100, andorderBy/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 anaddcan 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. Passoptions.idto 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. Withoutoptions.id, an id is generated automatically. -
$global.updateCollectionItem(collectionId: string, item: any): Promise<{ __id: string }>
Mergesiteminto an existing item in the collection. The item you pass must carry__id— it is how the target is found, and the call throwsItem must have __id to update…without it. The return value is only{ __id }, not the merged item; read it back withgetCollectionItemif 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:
| Field | Type | Notes |
|---|---|---|
filters | QueryFilter[] | Conditions, combined with AND. |
orderBy | { field, direction: 'asc' | 'desc' }[] | Sort order. |
limit | number | Max items per call (capped — see below). |
startAfter / endBefore | any | Cursor relative to the first orderBy field. |
A QueryFilter is { field, operator, value }. Supported operators:
| Operator | Meaning |
|---|---|
==, != | equals / not equals |
<, <=, >, >= | range comparisons |
in, not-in | value is (not) one of a list of up to 30 |
array-contains | array field contains value |
array-contains-any | array field contains any of a list of up to 30 |
// 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:
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[]} }) => {}): UnsubscribeFunctionconst { 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.
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
}
}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>
);
}