Appearance
Replicated collections
serverside clientsideA collection is a server-authoritative, keyed store of JSON records that the engine persists for you and delta-syncs to the right clients. Server scripts read and write records synchronously; clients get a read-only, live-updating mirror they can read in O(1) every frame.
Collections are the tool for game data that players need to see: inventories, quest logs, bank vaults, an item catalog, ground drops in a level, a guild's roster. Compared with the alternatives:
| Flags | SQLite | Collections | |
|---|---|---|---|
| Shape | Small key/value | Relational tables | Keyed JSON records |
| Client sees it | clientr / serverr | Never directly | Live read-only mirror |
| Persistence | Yes | Yes | sql backing, or RAM-only memory |
| Partitioning | Per player / global | Whatever you model | global / account / level / group |
| Server queries | — | Full SQL | Paged where over record fields |
Defining a collection (staff, in RC)
Scripts cannot create collections. Definitions are made by staff in GRC's Collections tool (the collections staff right) and saved in data/collections.json, loaded at server start. This is deliberate: every collection adds replication and storage overhead, and changing who can see one would be a data leak, so a script edit should never be able to do it.
Each definition picks a value on four axes, plus two optional settings:
| Axis | Values | Meaning |
|---|---|---|
| scope | global, account, level, group | How the record space is partitioned: one shared set, one per account, one per level, or one per script-chosen group id. |
| audience | none, owner, all, level, group | Who receives a replica of a scope. |
| backing | sql, memory | Persisted to SQLite, or RAM only (gone on restart). |
| replicate | eager, lazy, none, cache | When the replica is delivered (see below). |
| readonly | on / off | Script writes throw; records are edited in RC only. |
| schema | field list | Optional typed fields that every write is validated against. |
Not every combination is valid. The audience must match the scope, and the replication mode must match the audience:
| scope | valid audiences | valid replicate |
|---|---|---|
global | none, all | none for audience none; eager, lazy, none or cache for all |
account | none, owner | none for audience none; eager, lazy or none for owner |
level | none, level | none for audience none; eager for level |
group | none, group | none for audience none; eager or lazy for group |
Replication modes
eager— delivered automatically: at login forowner/all, on level entry forlevel, on subscribe forgroup.lazy— nothing ships until a server script callssubscribe(player). Good for data only needed while a UI is open (a bank vault).none— never replicated. Clients reach the data only through explicit pagedquery()calls, audience-checked by the server (auction listings).cache— per-record, on demand. Clientsfetch(key)individual records and from then on receive pushed updates only for the keys they fetched, never the whole set. Currently requiresglobalscope with audienceall. Built for large catalogs (item definitions) where each player only ever needs a few hundred entries.
Server API
serversidecollection(name) returns a stateless Collection handle. It resolves at use time, so it is safe to create at module top level regardless of load order. An undefined name doesn't throw here — it throws at the first call that touches the collection.
ts
const inventories = collection('inventories') // account scope
const items = collection('items') // global scopePicking a scope with for()
A global collection exposes the record methods directly. Every other scope needs .for(target) to pick one record set, which returns a CollectionBag:
| Scope | for(...) target |
|---|---|
account | A Player, or a bare account name — works for offline accounts too |
level | A level name (for a gmap, the member level's name, not the .gmap) |
group | Any group id string you choose ('guild:knights', a party id) |
ts
const inv = collection('inventories')
export function onPlayerJoined(pl: Player) {
const bag = inv.for(pl)
if (bag.keys().length === 0)
bag.set('starter', { id: 'sword', quantity: 1 })
}
// Mail a gift to someone who is offline — same API, by account name.
inv.for('SomeAccount').set('gift', { id: 'potion', quantity: 3 })Account scope keys are case-insensitive. Scope keys (level names, group ids) are 1–64 characters.
Reading and writing records
CollectionBag methods are synchronous — they hit server memory directly:
get(key)— the parsed record, orundefined. It's a fresh copy: mutating it changes nothing until you write it back.keys()— all record keys, sorted.set(key, value)— whole-record replacement.patch(key, fields)— shallow merge of the given fields. It's applied server-side in one step, so there's no read-modify-write race; patching a missing (or non-object) record replaces it wholesale.delete(key)— removes a record; deleting a missing key is a no-op.
ts
const bag = inv.for(pl)
const stack = bag.get('potion')
if (stack) bag.patch('potion', { quantity: stack.quantity + 1 })
else bag.set('potion', { id: 'potion', quantity: 1 })Record keys are 1–128 characters and a record can be at most 64 KB of JSON.
Writes are batched: once per server tick every changed scope bumps its version, sends one delta to its audience, and persists in the background (for sql backing). Several set/patch calls in one handler therefore cost one network message per scope.
Mutating get() does nothing
ts
inv.for(pl).get('potion').quantity++ // lost — get() returned a copyAlways write back with set or patch.
Read-only collections and schemas
A collection flagged read-only in RC rejects set/patch/delete from scripts with a thrown error ("…is read-only; records are edited in RC's Collections tool"). Reads and query() still work. Use this for data designers own, such as an item catalog.
A collection can also carry a schema: an ordered list of fields, each with a type (string, number, boolean, object, array, any) and a required flag. Once set, every write must be an object with all required fields present (and non-empty — null is rejected, and "" is rejected for required strings), types matching, and no fields outside the schema. Violations throw with a precise message. patch is judged by the record it produces, not just the fields you passed. Schema changes apply to future writes only; existing records are revalidated when next written.
Querying: query()
For sql-backed collections, query(opts) runs a paged SELECT and resolves with CollectionQueryRow objects ({ key, record }):
ts
const rows = await inv.for(pl).query({
where: 'id = ? AND quantity > ?',
params: ['potion', 0],
limit: 50,
})whereaddresses record fields by (dotted) name —'ench.level > ?'— and must be a single expression (no;). Parameters bind to?in order, asSqlParams.limitdefaults to 100 (max 1000); page withoffset.- Pending writes flush first, so the query sees what you just wrote.
- A
memory-backed collection can't be queried (the promise rejects) — iteratekeys()instead.
Subscriptions
For lazy owner/all collections and for all group collections, delivery is explicit:
ts
const bank = collection('bank') // account / owner / lazy
const guild = collection('guilds') // group / group / eager
// Open the bank UI: ship the vault to this player.
bank.subscribe(pl)
// Close it: the client mirror empties (its disk cache is kept for a cheap catch-up).
bank.unsubscribe(pl)
// For groups, subscribing IS membership — scope (the group id) is required.
guild.subscribe(pl, 'knights')
guild.for('knights').set('motd', { text: 'Welcome, knight.' })subscribe is idempotent and throws for collections whose delivery the engine manages itself: eager owner/all (delivered at login), level audiences (follow the player's level), audience none, and replicate cache (clients fetch records instead). unsubscribe is a no-op when the player isn't subscribed.
Client API
clientsideOn the client, collection(name) returns a ClientCollection: a read-only mirror of whatever replicas the server has delivered to you. Unknown names, or collections you aren't in the audience of, simply read as empty.
ts
// weapons/inventory.client.ts
const inv = collection('inventories')
export function onUpdate() {
for (const key of inv.keys()) {
const stack = inv.get(key) // O(1), deep-frozen
// ...draw the slot...
}
}get(key),keys()andforEach(fn)read the mirror. Records are deep-frozen — local mutation would never sync, so it's blocked outright. Writes always go through the server (triggerServer).- For a
levelcollection, the mirror reads across every live scope — on a gmap that's every level in your gmap window, not just the one you're standing in.
Change events
Instead of re-reading everything each frame, listen for changes with on. The CollectionEventMap has two events:
| Event | Fires when | Arguments |
|---|---|---|
change | One record changed | (key, record) — record is null on delete |
reset | The whole replica was replaced (login snapshot, revoke) | none |
ts
const inv = collection('inventories')
let dirty = true
inv.on('change', (key, record) => { dirty = true })
.on('reset', () => { dirty = true })
export function onUpdate() {
if (!dirty) return
dirty = false
rebuildInventoryUi()
}on is chainable; off(event, listener?) removes one listener (or all for that event). Listeners belong to the registering script and are removed automatically when it unloads, so a hot reload doesn't leave stale handlers behind.
fetch() for cache collections
A replicate: 'cache' collection starts empty on the client. Pull records with fetch:
ts
const items = collection('items')
const sword = await items.fetch('sword') // record or null
const defs = await items.fetch(['sword', 'potion']) // batch, ≤ 64 keys- A cached record resolves straight from the local mirror; otherwise the request round-trips and the server registers you for pushed updates to exactly those keys — a later
changeevent fires when any of them changes, including a missing key being created. - Missing records resolve
null. - Concurrent fetches of the same key share one request.
- It rejects for collections that don't replicate
cache.
A common pattern is an ensureItems(ids) helper that fetches unknown item ids in chunks of 64 and lets the UI mark itself dirty when new definitions arrive.
Don't build UI after an await
On the client, code that runs after an await resumes outside the normal event context. Creating GUI controls or findimg images there is unreliable — set a dirty flag and build in onUpdate instead, as the example above does.
query() for non-replicated data
query(opts) is a paged fetch straight from the server — the only way a client can read a replicate: 'none' collection, and also a paged view over anything sql-backed you're allowed to see. It resolves with CollectionQueryRows.
ts
const auction = collection('auction')
const page = await auction.query({ where: 'price < ?', params: [100], limit: 20, offset: 0 })
for (const row of page) echo(row.key + ': ' + row.record.item)Access is audience-checked server-side: audience all reads the global set, owner reads your own scope, level / group read scopes you currently hold (pass scope). where works like the server API; limit defaults to 100 with a max of 200 on the client.
What about audience none?
A collection with audience none is server-only — clients can't read it at all, even with query(). Its replicate is none too, but that's a different case from a replicate: 'none' collection whose audience is all/owner/level/group.
Caching and reconnects
Clients keep a per-server, per-account disk cache of their replicas. On reconnect they announce the versions they hold, and the server answers with "up to date", a small catch-up delta, or a full snapshot only when needed. The same applies to lazy/group resubscribes: an unsubscribe empties the mirror but keeps the disk copy, so the next subscribe is cheap. You don't need to do anything to benefit from this.
Patterns
| Use case | scope / audience / backing / replicate | Notes |
|---|---|---|
| Player inventory | account / owner / sql / eager | e.g. inventories, with a schema (id, quantity, props). |
| Item catalog | global / all / sql / cache, read-only | e.g. items: edited in RC, fetched per id by clients. |
| Bank vault | account / owner / sql / lazy | subscribe when the bank opens, unsubscribe when it closes. |
| Ground drops | level / level / memory / eager | Vanish on restart; everyone in the level (or gmap window) sees them. |
| Guild or party | group / group / sql / eager | subscribe(player, groupId) adds a member. |
| Auction house | global / all / sql / none | Clients page through listings with query(). |
| Loot tables, ledgers | global or account / none / sql / none | Server-only data with the collection API. |
A typical split keeps the rules on the server and the view on the client:
ts
// weapons/inventory.ts (server): the only writer
const inv = collection('inventories')
export function onActionServerSide(pl: Player, action: string, key: string) {
if (action === 'drop') inv.for(pl).delete(String(key))
}ts
// weapons/inventory.client.ts (client): read the mirror, ask the server to change it
const inv = collection('inventories')
inv.on('change', () => { dirty = true })
let dirty = true
function dropStack(key: string) {
triggerServer('weapon', 'inventory', 'drop', key)
}Gotchas
Editing records in the SQL Explorer
sql-backed collections are stored in the collectionsSQLite database. Editing those tables directly (Explorer or opendatabase) bypasses replication — clients and the server's in-memory copy won't see it. Edit records through the API or RC's Collections tool, which writes through the normal replicated path.
- Scripts can't change a collection's scope, audience, backing or replication — ask staff to change the definition in RC.
- A write to an undefined collection throws at the call site; wrap first-use in
try/catchif the definition might be missing. memory-backed collections are the storage: nothing survives a server restart, andquery()is unavailable.
Related
- SQLite databases — raw SQL, for data clients never see.
- Flags — simpler per-player/global values.
- Events & triggers — routing client write requests to the server.
- Reference:
collection(server),Collection,CollectionBag,collection(client),ClientCollection,CollectionEventMap.