Game Models
Game Models let you put your game's rules and state on the server. Instead of every client agreeing on what happens, you describe your game once as data and the server owns the state, evaluates the logic, and records every change. It is deliberately engine-agnostic: the server only knows three primitives.
- Container — any typed, named object in your game: a character, an item, a
spell, a quest, an inventory, a battle. A container has a
typeName(the class) and adisplayName(the instance), and holds properties. - Property — a typed key/value on a container instance. Health, mana,
position, gold, equipped weapon — all properties. Types:
int,float,string,bool,array,object,container_ref(a reference to another container). - Function — a named, parameterized operation that reads properties from any container and produces an ordered set of property writes plus an optional return value. Functions are your game logic. They run in a transaction: either every write applies or none do.
This pairs well with turn-based games (RPGs, tactics, card and board games) but works for anything where the server should be the source of truth. For worked mappings of familiar features — inventory, keys/doors/chests, land permissions, NPCs — onto these primitives, see Modeling game concepts. The expression language is deliberately loop-free; when your server logic outgrows it (pathfinding, world simulation, heavy computation), pair your model with a Rust Compute Module — modules read and write the same containers and properties through a server-side host API.
All Game Model operations live on the Game API GraphQL endpoint and require an
app-scoped token for the app (Authorization: Bearer <token>; mint one with
mintAppToken — studio admins can mint for their own app even without player
entitlement). Studio/authoring operations additionally require the manage_apps
permission on the app — that is the only extra gate. It is checked against the
calling user on that app-scoped token (the server verifies it with the Management
API; the client sends nothing special), so any trusted admin context can author
the model: a studio backend
or your own admin-only / authenticated web tool — a browser is fine. The only
rule is to keep that privileged token out of the untrusted client you ship to end
users.
JSON-typed values (property values, parameters, metadata, invoke policies) are
passed and returned as JSON-encoded strings — the fields ending in Json.
Your client JSON.parses / JSON.stringifys around them.
Defining your model (studio)
As an app admin you declare container types, their property schemas, and your
functions. You can do it field-by-field or in one gameModelSeed call.
gameModelSeed upserts container types, property definitions, and
functions. Seed containers (instances) upsert on binding key seed: +
tempId (the same unique index as gameModelEnsureContainer). A second seed
maps each tempId to the existing container id and increments
containersCreated only on insert. Edges skip pairs that already exist.
Do not key instances by (typeName, displayName) — same-name instances
are intentional. The seed: prefix is reserved: a non-admin
gameModelEnsureContainer cannot claim it.
Creating a container against a type you never defined is refused, with
extensions.code of CONTAINER_TYPE_UNDEFINED. The error names the type you asked
for and lists what the app actually declares, so a typo is visible without a second
call:
{
"message": "Container type 'PlayerLoadut' is not defined for app 1. This app defines: PlayerLoadout, BossAttributes, ...",
"extensions": {
"code": "CONTAINER_TYPE_UNDEFINED",
"typeName": "PlayerLoadut",
"definedTypes": ["BossAttributes", "PlayerLoadout"]
}
}
If definedTypes comes back empty, the app has no game model at all, and that is
a different problem with a different fix. It is the shape an app takes when it is
recreated, or moved between organizations: your client goes on making containers
as players connect, but container types, property definitions, function definitions
and automations are not carried over. Everything else looks perfectly healthy while
this is true — tokens mint, access tiers resolve, players connect, the realtime path
works. Only the model is missing. Re-run your gameModelSeed.
This used to be silent, and it is worth knowing what it looked like, because older builds of your client are still capable of producing the state. The create call succeeded, the container was written, nothing could bind it, and the only sign anywhere was a line in the game's own log naming a symptom and nothing about the cause:
[GameModel] InvokeAndApply: no container bound for entity F3B8B18E478BB6E95D9B1980C602CA47
To find every such problem at once rather than one failed call at a time, use
gameModelLint.
mutation {
gameModelSeed(input: {
appId: "1",
containerTypes: [
{ typeName: "Character", displayName: "Character", instantiableBy: "member" },
{ typeName: "Attack", displayName: "Attack", instantiableBy: "admin" }
],
propertyDefinitions: [
{ containerTypeName: "Character", key: "hp", valueType: "int", defaultValueJson: "100", visibility: "public" },
{ containerTypeName: "Character", key: "str", valueType: "int", defaultValueJson: "10", visibility: "public" },
# A hidden stat is never returned to players (see Property visibility).
{ containerTypeName: "Character", key: "secret_weakness", valueType: "string", visibility: "hidden" }
],
functions: [
{
name: "attack",
containerTypeName: "Character",
returnType: "int",
parameters: [
{ name: "target_id", valueType: "container_ref", required: true },
{ name: "base_power", valueType: "int", required: false, defaultValueJson: "5" }
],
mutations: [
{ target: "ref($target_id)", property: "hp",
expression: "max(0, ref($target_id).hp - (self.str + $base_power))" }
],
returnExpression: "ref($target_id).hp",
invokePolicyJson: "{\"type\":\"and\",\"rules\":[{\"type\":\"owner_of_self\"},{\"type\":\"is_current_turn\"}]}"
}
]
}) { containerTypesCreated propertyDefinitionsCreated functionsCreated warnings }
}
warnings reports non-fatal static-analysis notes (for example, a function that
reads self.someKey you never declared, or a $param you never listed). Your
expressions are compiled when you upload them; a syntax error is rejected with a
position so you can fix it.
Individual authoring operations also exist: gameModelUpsertContainerType,
gameModelUpsertPropertyDef, gameModelUpsertFunction,
gameModelDeleteContainerType, gameModelDeletePropertyDef,
gameModelDeleteFunction. Deleting a container type also removes its property
definitions, but refuses if live containers of that type still exist or if
functions are bound to it — delete those first. Deleting a property definition
does not strip instance values already stored on containers.
For tooling and autocomplete, read your model back with
gameModelTypeSchema(appId, typeName), gameModelContainerTypes(appId),
gameModelPropertyDefs(appId, typeName), gameModelFunctions(appId), and
gameModelFunction(appId, name).
The expression language
Expressions are small, pure (read-only) arithmetic-and-logic. Writes happen
only through a function's mutations list, never inside an expression.
- Literals:
42,3.14,"text",true,null - Property reads:
self.hp,ref("<container-uuid>").hp,ref($target_id).hp - Parameters:
$damage - Operators:
+ - * / %,== != < > <= >=,&& || ! - Conditional:
if(self.hp > 0, self.hp - $dmg, 0) - Builtins:
max min abs floor ceil round clamp pow sqrt len concat to_int to_float to_string rand rand_int not is_null coalesce - List builtins (see Lists):
at set_at append remove_at index_of array - Permission/grid reads (see
Reading permissions from expressions):
has_grid_permission has_chunk_permission grid_at grid_contains grid_min grid_max - Call another function's return value (read-only):
fn:level_bonus(self.level)
Within one invocation, a later mutation sees the values written by earlier mutations.
Argument counts are checked when you upload the function, not when a player
calls it, and the error names what the builtin takes. Two long-standing spellings
are refused by this: max and rand_int take exactly two arguments, and rand
takes none, so max(self.hp) and rand(1) are rejected at upload. They never
worked at runtime either — only the moment you find out has changed. Stored
functions are not re-checked, so an existing app keeps running until the next time
it re-uploads a function that was already broken.
Lists
Array properties have six builtins. All of them are pure: they return a new list and never modify the one you passed.
| Builtin | Returns |
|---|---|
at(list, i) | the item at index i; an error if i is out of range |
set_at(list, i, v) | the list with index i replaced by v |
append(list, v) | the list with v added at the end |
remove_at(list, i) | the list with index i removed; an error if out of range |
index_of(list, v) | the index of the first v, or -1 if it is not there |
array(v1, v2, …) | a new list of the arguments; array() is the empty list |
A property that was never written reads as nothing, and every one of these
treats that as the empty list. So append(self.members, $caller_user_id) on a
brand-new container stores a one-item list rather than failing, index_of on it
returns -1, and at on it returns nothing. The one exception is remove_at,
which is an out-of-range error, because removing index 0 of an empty list has no
answer. A list that a function did not change is never written back as [].
index_of compares values exactly the way == does, so
index_of(l, x) >= 0 and a == test over the same values can never disagree.
There is a 1000-item cap per list, and it is enforced inside the builtins
rather than at the point of storage — append onto a 1000-item list is refused
with an error naming the cap even when your expression was going to discard the
result, because a bounded stored value does not bound the work of building an
oversized one. array(...) and set_at enforce it identically. At the sizes a
game actually uses (a camp's members, a squad, inventory slots) you will not come
near it: an append onto a 990-item list costs about 2 ms end to end, most of that
the database read.
Linting your model
gameModelLint answers one question — does this app's game model hang together — and it
recomputes every check when you ask. It needs app-admin (manage_apps), so it is for you
and your tools rather than for a shipped client.
query {
gameModelLint(appId: "1") {
clean
errorCount
warningCount
findings { code severity subjectKind subject message remedy count }
}
}
Recomputed, never remembered, and that is the point rather than an implementation
note. These findings are about relationships between objects, so the answer changes
without the object changing. A function calling fn:apply_bonus is a finding until you
write apply_bonus, and stops being one the moment you do. Delete apply_bonus a month
later and every caller is broken again — with no write on any of them to notice it. A
stored report is wrong in both directions; only a live one is ever right.
Errors and warnings are different claims
ERROR means provably broken. There is no reading of your app in which it is fine,
and we can say so without knowing your intent: a container whose type does not exist, a
timer targeting a function that is not autonomousInvocable, a stored definition that no
longer compiles.
WARNING means suspicious, and frequently correct anyway. Most of these are ordinary
mid-edit states. Seeding a function that calls another one written later in the same batch
produces function_not_defined, and it resolves itself. clean is therefore true when
there are no errors — an app that could not be "clean" while being authored normally would
be a signal you would learn to ignore, which is the failure this whole surface exists to
correct.
An error can stop the object running
This is the part worth reading before you ship. Some error codes are enforced, and an object with an enforced finding against it is quarantined: it refuses to run until you fix the definition. Warnings never do this, and never will — that is what the severity split is for.
A quarantined function or automation refuses with OBJECT_QUARANTINED:
{
"message": "The function 'notify_round_start' is quarantined and will not run: notification_channel_foreign: channel notification targets channel 123 ('__crowdy_session_456'), which belongs to app 456, not this app. Fix the definition and write it again — quarantine never blocks a write. gameModelLint lists everything currently wrong with this app.",
"extensions": {
"code": "OBJECT_QUARANTINED",
"quarantinedKind": "function",
"quarantinedName": "notify_round_start",
"quarantineReason": "notification_channel_foreign: channel notification targets channel 123 ..."
}
}
On gameModelInvoke — the path a player takes — the same refusal arrives through the
player boundary
as USER_CODE_ERROR with blame: AUTHOR and retryable: false, because that boundary
rebuilds every error from a { code, blame, retryable } triple rather than passing it
through. The quarantinedKind / quarantinedName / quarantineReason fields survive on
both, so the same client code reads the reason either way.
Four properties matter, and they are what make this safe to build against:
- It is scoped to the object, not the app. One bad function is refused; every other function, automation, container and player request carries on. A typo in something nobody calls does not take your game down.
- It never blocks a write. Defining, re-upserting and seeding are always allowed. That is deliberate: writing a new definition is your only way out, so a gate that blocked its own repair would turn a diagnostic into a trap.
- It clears itself. Fix the definition and the object runs again — there is nothing to ask us to reset, and no ticket to file.
quarantineReasoncarries the finding that caused it, so you do not have to guess which of your errors is the enforced one.
Enforcement is a platform decision per code, not per app, and it is made conservatively —
a code is only enforced once we can see how many apps it would affect. So treat every
ERROR from gameModelLint as something to fix rather than something to rank: the set
that is enforced can grow, and an error you were ignoring is the one that surprises you.
clean being true means no errors at all, which is the state to ship in.
The CrowdyJS and CrowdyCPP studio helpers already recognise
OBJECT_QUARANTINED as a "your model is wrong" refusal rather than a transient failure,
and log it once per object instead of once per occurrence, so a quarantined function does
not fill your logs while you fix it.
| Code | Severity | What it means |
|---|---|---|
container_type_undefined | error | Containers name a type the app does not define. They cannot be bound. |
app_has_no_container_types | error | The app holds containers and declares no types at all. Re-run gameModelSeed. |
timer_target_not_autonomous | error | A timer will arm and then fail when it fires, far from the code that armed it. |
function_uncompilable | error | A stored definition no longer compiles and is inert. Re-upsert it. |
notification_channel_foreign | error | A channel notification names a channel belonging to a different app, so it reaches nobody. The shape a model takes when it is copied into another app. |
function_not_defined | warning | A fn: call names a function that does not exist yet. |
timer_target_missing | warning | A timer's target function does not exist yet. |
property_not_declared | warning | self.<key> is not in the property definitions for the type. |
param_not_declared | warning | An expression references a $param the function does not declare. |
permission_key_unknown | warning | A permission-key literal is not in the runtime catalog, so it grants nothing. |
grid_literal_invalid | warning | A grid builtin got a mode or axis outside its allowed set. |
automation_trigger_unmatchable | warning | A trigger's filter cannot match, so it will never dispatch. |
notification_channel_unknown | warning | A channel notification names a channel id, or a name, that this app does not have. A warning because the channel may simply not exist yet. |
gameModelFunctions and gameModelFunction also carry a warnings list, recomputed the
same way for the one function you asked about. gameModelLint is the whole-app version and
the only one that sees the container checks.
Authority: deciding who may invoke a function
Every function carries an invoke policy — a small boolean tree of
requirements you combine with and / or / not. An absent policy means any
entitled player may invoke. The policy applies to everyone, app admins
included: your own account, testing your own game, is judged exactly like a
player's. (Before 2026-09-08 a manage_apps holder skipped every policy
implicitly, which made policies look unenforced to the very people who wrote
them. That is gone.) You can mix authority sources freely:
owner_of_self— the caller owns theselfcontainer (e.g. "only act on your own characters").is_current_turn— it is the caller's turn in the session.is_host— the caller is the app's elected host.is_participant— the caller is a participant of the session.is_automation— the call is driven by an autonomous process (an automation / NPC), not a player. Gate a function to automations only, or branch logic on caller kind.tier_feature— the caller's access tier unlocks a named feature (premium abilities, etc. — see Tier-gated features).group_permission— the caller holds a permission in a team/group (e.g. a GM role can invoke admin functions).grid_permission— the caller holds a runtime grid permission (optionally on a specific grid), tying logic to world regions.condition— an arbitrary expression that must evaluate totrue. It can readself, the call's params, and the injected values$caller_user_id,$current_turn_user_id,$self_owner_id,$session_id,$self_container_id,$app_id,$session_channel_name.
{ "type": "and", "rules": [
{ "type": "owner_of_self" },
{ "type": "is_current_turn" },
{ "type": "tier_feature", "feature": "premium_abilities" }
] }
Some leaves carry fields: tier_feature takes a feature key; group_permission
takes a groupId (and optional permission); grid_permission takes a key
(and optional gridId); condition takes an expression. For example, "a member
holding the manage_group permission in team 42, acting inside grid 7":
{ "type": "and", "rules": [
{ "type": "group_permission", "groupId": "42", "permission": "manage_group" },
{ "type": "grid_permission", "key": "update_voxel_data", "gridId": "7" }
] }
Skipping a policy on purpose (bypassPolicy)
Administrative tooling sometimes needs to run a function regardless of its
policy — a GM console resetting a stuck battle, a seed script driving a fixture
into a state the policy would refuse. For that, set bypassPolicy: true on the
gameModelInvoke input:
- it is honoured only when the caller holds
manage_appson the app; anyone else getsNOT_ALLOWEDand nothing runs, - the result carries
policyBypassed: trueso a client can tell the two kinds of success apart, - the server writes an audit line naming the app, function, user and container.
An admin invoke without the flag is an ordinary player invoke. There is no app or tier setting that turns policy enforcement off.
Reading a policy back, and what self.<attribute> means
gameModelFunctions returns invokePolicyJson as you authored it: the compiled
ast the server stores beside each condition is stripped from the read-back,
so its absence there says nothing about whether the policy was compiled (it
always is, at upsert). A stored condition whose ast is genuinely missing
refuses every call rather than allowing it.
In a condition, self.owner_user_id (or source.owner_user_id) resolves to
a declared property of that name on the container type, never to the row's
owner. The row owner is the injected $self_owner_id, and the leaf
owner_of_self is the direct way to require it. If you declared a property
named owner_user_id, rename it or read $self_owner_id — the two are not
linked.
Functions also have an invokeScope: player (default, directly callable),
server (admins only), or internal (only reachable via fn: from another
function). A separate opt-in flag, autonomousInvocable, controls whether a
server-driven automation (an NPC / autonomous process)
may use the function as an entry point — players are unaffected by it.
Property visibility
Each property declares a visibility:
public— readable by anyone who can read the container.owner— readable only by the container's owner (and app admins).hidden— never returned to players; server-only (secret stats, RNG seeds).
gameModelContainerState returns only the properties the caller may see.
Writable properties and direct writes
By default every property is written only through a function's mutations —
its writable is function. You can instead let a property be set directly,
without a function:
function(default) — only function mutations may write it.owner— the container's owner may set it directly.admin— only app admins may set it directly.
Declare it on the property definition (writable: "owner"), then set it with
gameModelSetProperty:
mutation {
gameModelSetProperty(input: {
appId: "1", containerId: "<character-uuid>",
key: "display_name", valueType: "string", valueJson: "\"Aria\""
}) { containerId }
}
Use direct writes for player-controlled, non-authoritative fields (a cosmetic name, a UI preference); anything that must enforce rules belongs in a function. Direct writes are not recorded in the event log, so pair them with a change notification when other players need to know.
Sessions, ownership, and turns
State can be app-global (a persistent world) or scoped to a session — a battle, match, save, or party. Many sessions run concurrently from one model.
mutation {
gameModelCreateSession(input: {
appId: "1", name: "Skirmish", participantUserIds: ["90001", "90002"]
}) { sessionId }
}
Who may create sessions is set per app with gameModelSetPolicy
(sessionCreationPolicy: admin | member | anyone). member requires app
access; anyone lets any logged-in user create one (handy for open lobbies).
Players join with gameModelJoinSession; list sessions with
gameModelSessions(appId, status) and read one with
gameModelSession(appId, sessionId).
Containers can have an owner (ownerUserId) which powers owner_of_self and
owner-only visibility. Create instances with gameModelCreateContainer
(allowed per the type's instantiableBy: admin | member | owner).
Delete an instance with gameModelDeleteContainer (app admin or the container
owner); that cascades its properties and any connected edges.
On create, default ownership follows the type's instantiableBy, not whether
the caller happens to be an app admin. For player-owned types (member /
owner), omit ownerUserId so the server assigns the caller — clients
cannot claim someone else's container, and owner_of_self / owner-only
visibility work as intended. Admin-instantiable types stay shared (null)
unless an admin or automation sets an owner explicitly.
instantiableBy | Omit ownerUserId | Explicit ownerUserId |
|---|---|---|
| member / owner | Defaults to caller | Admins may set; non-admins cannot set another user |
| admin | Stays null (shared/world) | Admin/automation may set explicitly |
Ensured containers (atomic get-or-create)
When N clients all need the same shared container — one boss, one chest,
one world object every connected player sees identically — use
gameModelEnsureContainer instead of electing a leader to
create-then-search. It atomically gets-or-creates a container keyed by an
opaque, client-derived bindingKey (≤ 128 chars), unique per
(appId, typeName, sessionId); a null session scopes the key app-globally:
mutation {
gameModelEnsureContainer(input: {
appId: "1",
typeName: "BossAttributes",
bindingKey: "boss:titan-1",
# used ONLY when this call creates the row:
displayName: "Boss",
metadataJson: "{\"tier\":3}"
}) {
container { containerId typeName bindingKey ownerUserId }
created # true for exactly ONE of any set of concurrent ensures
}
}
- Atomic under concurrency. Any number of simultaneous ensures of the same
key converge on one row (a partial unique index is the arbiter); all callers
get the same
containerIdand exactly one response carriescreated: true. Duplicate rows are impossible by schema, and the object can be ensured with zero players connected (e.g. by a deploy tool). - Exists ⇒ read. When the row already exists the ensure behaves like a
read: creation-only fields (
displayName,description,metadataJson,properties,ownerUserId) are ignored and the type'sinstantiableByis not enforced. - Not-exists ⇒ create. Creation is authorized like
gameModelCreateContainer(instantiableBy+ the ownership rules above) and by the type'sbindPolicy(below), so a caller who may not instantiate the type, or may not claim keys for it, errors only in the not-exists case. - Read back by key.
bindingKeyis returned on the container object, andgameModelContainers(appId, typeName, bindingKey, ...)is the get-by-key read.
Who may CLAIM a key: bindPolicy
Read this before using an ensured container for anything shared.
bindingKey is supplied by the client, and whoever creates the keyed row
becomes its owner. On a member- or owner-instantiable type that means
any entitled player can claim a key before its rightful user does — and because
owner_of_self reads ownerUserId off that same row, the squatter then passes
every owner_of_self invoke policy on the type and the legitimate player is
refused. The invoke policy does not protect you here; it is the thing that gets
inverted.
Two ways to close it, and a shared object needs one of them:
- An admin-instantiable type. Existing rows stay readable by everyone, while only app admins and automations can create the keyed row.
- A
bindPolicyon the container type. Same JSON shape as a function'sinvokePolicyJson— the sameAuthorityRuletree, evaluated the same way:
mutation {
gameModelUpsertContainerType(input: {
appId: "1",
typeName: "BossAttributes",
displayName: "Boss attributes",
instantiableBy: "member",
bindPolicyJson: "{\"type\":\"is_host\"}"
}) { typeName bindPolicyJson }
}
- Omit it and nothing changes. No
bindPolicymeans binding is governed byinstantiableByalone, which is how ensured containers behaved before the field existed. - It governs creation only. Resolving a key that already exists is a read and is never refused by a bind policy — otherwise no client could see a shared object it did not create.
- App admins bypass it, exactly as they bypass invoke policies.
- Three requirements are refused, because a bind is what creates the
container and there is no acting container to resolve them against:
owner_of_self,is_current_turnandcondition. You get aBAD_REQUESTnaming the leaf when you author one. Everything else composes as usual:is_host,is_participant,is_automation,group_permission,grid_permission,tier_feature,allow, andand/or/not. - Per-player containers. If your key identifies a player's object rather
than a world object,
{"type":"is_participant"}limits claims to the session, but it does not stop one participant claiming another's key. For that, derive the key server-side or make the type admin-instantiable and ensure it from an automation.
Rolling one out onto a live game. Adding a bindPolicy to a type whose
players are already binding keys can refuse the very players it is meant to
protect. Set GM_BIND_POLICY_MODE=shadow on the tier first: a bind the policy
would have refused is admitted and counted on the type, and
scripts/report-bind-policy-shadow.mjs prints the count. A policy at zero
refusals is safe to enforce; one that is accumulating them is refusing real
players. The default is enforce, and an unrecognised value falls back to
shadow.
Turns are explicit and developer-driven: gameModelSetSessionTurn records whose
turn it is (the current turn holder, the elected host, or an app admin may set
it), and the is_current_turn requirement reads it. You implement your own turn
order; the server just enforces it.
Active player count (app-scoped sessions)
gameModelActivePlayerCount reports an app-wide gauge of active app-scoped
gameplay sessions. It is not a count of distinct users, actor rows,
game-model sessions, host candidates, or load on one game server. A user with
two overlapping gameplay sessions can therefore contribute two to the count.
Both the snapshot and subscription require
Authorization: Bearer <app-token>, and that token's app scope must match
appId.
query ActivePlayerCount {
gameModelActivePlayerCount(appId: "1") {
appId
activePlayerCount
status
observedAt
revision
}
}
activePlayerCount is always the best-known count; interpret it together with
status:
FRESH— the observation is complete and authoritative.PARTIAL— some current telemetry is missing, so the count is useful as a best-known value but is not authoritative.UNAVAILABLE— a current authoritative observation is unavailable. The returned count is still best-known, not proof that nobody is active.
observedAt is nullable when no observation time is available. Never turn a
PARTIAL or UNAVAILABLE result, missing telemetry, or a null observedAt
into an authoritative zero.
Once counted, a gameplay session remains active until explicit disconnect or deauthorization, token expiry, or inactivity expiry. An abandoned session can remain visible for roughly 120 seconds plus observation latency. A quick reconnect can briefly overlap the old session and count twice, so this gauge is appropriate for presence, scaling hints, and automations—not billing or distinct-user analytics.
Subscribe for post-observation changes:
subscription ActivePlayerCountChanged {
gameModelActivePlayerCountChanged(appId: "1") {
appId
previousCount
currentCount
delta
revision
observedAt
}
}
Delivery is best-effort and the stream is not an initial snapshot. On startup,
open the subscription and immediately query gameModelActivePlayerCount;
deduplicate snapshot/change data by revision. Re-query after reconnect and
whenever revisions indicate a gap. The snapshot's status remains the
authority on freshness; receiving a change does not imply that later snapshot
telemetry is FRESH.
An app admin can feed the same transitions into an
onEvent: "player_count_changed" automation.
Invoking a function
mutation {
gameModelInvoke(input: {
appId: "1",
functionName: "attack",
selfContainerId: "<your-character-uuid>",
sessionId: "<session-uuid>",
paramsJson: "{\"target_id\":\"<enemy-character-uuid>\"}"
}) {
success
returnValueJson
errorMessage
mutationsApplied { key oldValueJson newValueJson }
}
}
If the invoke policy refuses the caller, success is false, fault.code is
NOT_ALLOWED, no mutation is applied, and the refusal is recorded as a failed
event. This is the verdict for app admins too unless the input carries
bypassPolicy: true (see Skipping a policy on purpose),
in which case the result also reports policyBypassed: true. If the logic
errors (e.g. arithmetic on a missing value) the transaction is rolled back,
success is false, and the attempt is still recorded.
Concurrency: two players writing the same property
A mutation reads the property's old value, evaluates your expression, and writes the result. When two invocations of that overlap, the naive outcome is that both compute from the same starting value and the second write silently replaces the first — both callers are told they succeeded and one of them is not there.
The engine prevents that, for every mutation, and you do not have to do anything to get it. No version numbers, no retry loop, no locking of your own. There are two mechanisms and the difference between them is worth knowing, because it is the difference between exactly-once and correct but serialised.
The four shapes that are atomic
These compile to a single database statement that recomputes from the row's own live value, so a caller that loses the race re-evaluates against what the winner actually committed. No lock is taken and nothing is retried.
| What you write | Guarantee |
|---|---|
p = if(index_of(p, X) < 0, append(p, X), p) | exactly-once — add to a set |
p = if(index_of(p, X) >= 0, remove_at(p, index_of(p, X)), p) | idempotent — remove one entry |
p = append(p, X) | lossless, but see below |
p = p + N / p = p - N, integers | lossless |
Both orderings of each conditional work (if(absent, add, p) and
if(present, p, add)), as do the equivalent spellings of the bound (< 0,
== -1, <= -1; >= 0, != -1, > -1). X must be an int, string, bool
or null — which covers $caller_user_id, the case this exists for.
// enter: exactly-once, however many players arrive in the same instant
self.members = if(index_of(self.members, $caller_user_id) < 0,
append(self.members, $caller_user_id),
self.members)
// leave: removes one entry, and does nothing if they were not in it
self.members = if(index_of(self.members, $caller_user_id) >= 0,
remove_at(self.members, index_of(self.members, $caller_user_id)),
self.members)
Written that way, a player who sends enter three times because their connection flapped is in the camp once, and a disconnect that fires leave twice removes one entry.
Everything else is protected by a lock
Any other mutation that reads the property it writes takes a short lock on the
container row before the first read, so the guarantee is unconditional rather than
a property of the shape you happened to write. That includes a guard that is not
the containment test, a set_at or remove_at at a literal index, anything that
reads a different property to decide this one's value, and anything routed
through fn:. A write that does not read what it writes takes nothing —
there is no update to lose.
You do not have to know which branch you are on to be correct. The reason to prefer the guarded form anyway is contention: the lock is held across an evaluation, so callers to one hot container queue, and it is the locked path where a busy refusal becomes likely.
What you still must not assume
- A bare
appendis not exactly-once. It will not lose anyone's entry, but it will happily add the same player twice if they send enter twice. That isappenddoing what it says. Use the guarded form whenever "already there?" matters. - Two properties written by one function are atomic together; two separate
invokes are not. If a
memberslist and aheadcountmust agree, write them in the same function — they commit or roll back together. Across two invokes there is no such guarantee and no way to give you one. - A very hot single property still has a ceiling. Writes to one row are
serialised by the database and a call has a time budget, so beyond a certain
rate some callers are refused rather than served late. The refusal is
ours:
blame: PLATFORM,retryable: true, it does not count against your function and it never arrives as "your code timed out". Retrying is the correct response. Unlike an authority denial or an evaluation failure — which come back in band assuccess: falsewith afault— this one is a thrown error, so read it fromerrors[].extensions(see Error codes). If a single list is genuinely taking hundreds of writes a second, split it: per squad, per region, per shard of the roster. - Order within a list is not stable under concurrency. Two players entering
together land in an order nobody controls. Never use a position as identity —
use
index_of.
Reading state and the graph
gameModelContainer(appId, containerId)— container metadata.gameModelContainerState(appId, containerId)— visible properties as a JSON object.gameModelContainers(appId, typeName, sessionId, bindingKey, where, limit, offset)— list instances.bindingKeynarrows to a container ensured under that key.where(requirestypeName) filters by up to 8 AND-combined property predicates{ key, op, valueJson }— ops==,!=,<,>,<=,>=; missing properties fall back to the type default (the same predicate shape automation selectors use).limit/offsetpage after filtering over the stable created-at ordering:
query {
gameModelContainers(
appId: "1", typeName: "Unit",
where: [{ key: "team", op: "==", valueJson: "\"red\"" },
{ key: "hp", op: ">", valueJson: "0" }]
limit: 20
) { containerId displayName }
}
gameModelTraverse(appId, rootId, relationshipType, depth)— walk the container graph (edges created withgameModelAddEdge/ removed withgameModelDeleteEdge), e.g. an inventory's items or a tech tree.depthis clamped to a maximum of 5.
Reacting to changes
Every successful invocation is recorded — whether a player or a server-driven
automation made it (events carry callerKind and
automationId). Clients pull the authoritative state from the model API;
the gameModelContainerChanged subscription (below) tells you when to pull.
Poll the event log with gameModelEvents
(filter by session, container, function, success):
query { gameModelEvents(appId: "1", sessionId: "<session-uuid>") {
functionName success callerUserId returnValueJson executedAt
} }
Re-read gameModelContainerState / gameModelContainers after a change to get
the new values.
Tracing a flow
Every event carries a nullable flowId: a correlation id (UUID) minted at
the entry edge of a request — a player gameModelInvoke, an
automation run, or a
computeInvoke — and propagated across model_invoke,
the event bus, and emit_event cascades. The same id lands on the
gameModelEvents rows, the gameModelAutomationRuns rows, and the
computeModuleRuns rows a single cause
produced, so a cross-engine chain like mob kill → compute event → reward
grant is one correlated trace instead of three uncorrelated records.
Stitch a flow into one timeline with gameModelFlow (a diagnostics surface;
requires app-admin manage_apps, like gameModelAutomationRuns). Take the
flowId from any event or run row:
query { gameModelFlow(appId: "1", flowId: "<flow-uuid>") {
flowId
events { functionName callerKind success executedAt }
automationRuns { automationName triggerSource success startedAt }
moduleRuns { moduleName triggerSource success errorMessage startedAt }
} }
Each array is ordered by time ascending; an unknown flowId returns three
empty arrays. Ticks mint their own flow id per batch, so tick-driven cascades
correlate too. The SDKs expose this as client.gameModel.flow({ appId, flowId }) (CrowdyJS 8.13+) / gameModel().flow(appId, flowId) (CrowdyCPP
0.9+), and their default event/run selections include flowId.
Push: the container-change subscription
Instead of interval polling, subscribe to gameModelContainerChanged over
graphql-transport-ws (the same WebSocket path as udpNotifications):
subscription {
gameModelContainerChanged(appId: "1", typeName: "Npc") {
containerId typeName sessionId source functionName changedKeys occurredAt
}
}
Events fire post-commit whenever a container changes — an invoke mutated it
(source: "function"), a direct gameModelSetProperty wrote it
("direct"), or it was "created" / "deleted". Delivery is metadata
only (which container, which keys — never property values), so visibility
rules need no per-subscriber filtering: pull the filtered state with
gameModelContainerState on receipt. Semantics are best-effort like
model-driven notifications — a dropped event
costs one missed pull, never correctness; events fan out across all API
replicas. CrowdyJS exposes this as client.gameModel.containerChanged(...)
(pass webSocketImpl on Node ≤ 21).
Notify clients to pull
To avoid blind polling, have the acting client send a lightweight "model changed" ping over the realtime path; peers then re-pull the model. The ping carries no authoritative state — the model API stays the source of truth.
- Recommended — channels. Publish a small message to a channel
(for example, one per session) with
sendChannelMessage. Every channel member gets aChannelMessageNotificationwherever they are in the world, then callsgameModelContainerState/gameModelEventsto fetch the change. Best for turn-based and session-scoped games whose participants aren't near each other. - Alternative — spatial client events. If the change is tied to a world
location and only nearby players care, send a client event
(
sendClientEvent, chunk + distance) so nearby players get aClientEventNotificationand re-pull.
Both paths are wrapped by CrowdyJS; see CrowdyJS → Game Models for the SDK pattern.
Reading permissions from expressions
Expressions can also read the runtime grid ACL and the grid layout, so a
single function can branch on where a player may act — not just be
allowed/denied as a whole by the invoke policy. Six DB-backed builtins are
available everywhere expressions run (mutations, returnExpression,
notification args, permission-effect expressions, and policy condition
rules), always scoped to your own app:
| Builtin | Returns | Meaning |
|---|---|---|
has_grid_permission(user_id, key) | bool | The user holds an unexpired runtime permission key on any grid. |
has_grid_permission(user_id, key, grid_id) | bool | …on that specific grid. |
grid_at(cx, cy, cz [, mode]) | int | null | The grid covering a chunk (null when none does). |
has_chunk_permission(user_id, key, cx, cy, cz [, mode]) | bool | Sugar for has_grid_permission(user, key, grid_at(...)); false when no grid covers the chunk. |
grid_contains(grid_id, cx, cy, cz) | bool | Whether the grid's box covers the chunk. |
grid_min(grid_id, axis) / grid_max(grid_id, axis) | int | The grid's inclusive chunk bounds on "x" | "y" | "z". |
Several grids may cover one chunk (a plot nested inside the world grid), so
grid_at/has_chunk_permission take an optional mode choosing the
overlap-resolution algorithm:
"first"(default) — lowestgrid_id, exact parity with how the replication layer picks the enforcing grid."smallest"— the innermost, most specific grid (natural for plot logic)."largest"— the outermost grid.
# One door function that decides per-caller, per-location:
mutation {
gameModelUpsertFunction(input: {
appId: "1",
name: "open_door",
containerTypeName: "Door",
mutations: [
{ target: "self", property: "is_open",
expression: "has_chunk_permission($caller_user_id, \"access\", self.cx, self.cy, self.cz, \"smallest\")" }
],
returnExpression: "self.is_open"
}) { name warnings }
}
Semantics worth knowing:
- Live and transactional. Reads hit the same materialized ACL the replication layer enforces, on the invocation's transaction — and after a permission effect applies inside the same invocation, later expressions observe the new grant (read-your-writes).
- Metered. Each uncached lookup charges an extra 25 gas on top of the normal per-node cost; repeats within one invocation are cached and cheap. A runaway lookup loop fails the invocation like any other budget breach.
- Checked at upload.
gameModelUpsertFunction/gameModelSeedreturn non-fatalwarningsfor wrong argument counts, invalidmode/axis literals, and permission keys missing from theruntime_permissionscatalog.
Permission effects: functions that write grid permissions
Invoke policies let model logic read the permission systems; permission
effects let a function write one of them — the runtime
grid permissions that govern movement, voxel edits,
teleports, and voice on regions of the world. Declare permissionEffects on a
function and every successful invocation grants (or revokes) grid permissions
in the same transaction as its property mutations: "pay 100 gold AND get
access to the plot" either both happen or neither does.
mutation {
gameModelUpsertFunction(input: {
appId: "1",
name: "buy_plot",
containerTypeName: "Plot",
parameters: [
{ name: "wallet_id", valueType: "container_ref", required: true }
],
mutations: [
# The policy has already verified ownership and price; spend the gold.
{ target: "ref($wallet_id)", property: "gold",
expression: "ref($wallet_id).gold - self.price" },
{ target: "self", property: "owner_user_id", expression: "$caller_user_id" }
],
permissionEffects: [
{
action: "grant",
permissionKeys: ["access", "update_voxel_data"],
userExpression: "$caller_user_id",
gridIdExpression: "self.grid_id"
}
],
invokePolicyJson: "{\"type\":\"condition\",\"expression\":\"ref($wallet_id).owner_user_id == $caller_user_id && ref($wallet_id).gold >= self.price\"}"
}) { name permissionEffects { action permissionKeys } }
}
Each effect declares:
action—grant(upsert direct grants) orrevoke(delete them).permissionKeys— runtime permission keys (access,teleport,update_voxel_data,use_voice_chat), validated against theruntime_permissionscatalog.userExpression/gridIdExpression— model expressions resolving the target user id and grid id, evaluated in the invocation's (post-mutation) context.ttlSecondsExpression— grant only, optional: an expiry in seconds (rentals and leases; e.g."86400"for a day).
Semantics and safety:
- Transactional. Effects apply after the mutations succeed, on the same
transaction; a failing effect (unknown key, wrong-app grid, grantee without
app access, non-integer user/grid) rolls the whole invocation back with
success: false. Contrast with notifications, which are post-commit and best-effort. - Immediately enforced. The effect writes the direct-grant input table and
recomputes the materialized ACL, so Buddy's movement/voxel enforcement and
every
grid_permissionpolicy check see the change at commit. - System params.
$caller_user_id,$current_turn_user_id,$self_owner_id,$session_id,$self_container_id(the acting container's UUID),$app_id, and$session_channel_name(this app's default session channel,__crowdy_session_<appId>) are injected into function-body, effect, and notification-arg expressions (they cannot be spoofed by a same-named caller param), so "grant to whoever invoked" is just$caller_user_id. This applies to yourmutationsexpressions too. The last two are derived from the app the function is running in, so a model copied into another app addresses its new home rather than its old one. - Bounded. At most 4 effects per function, each charged against the
invocation's gas budget; the target user must hold active app access to
receive a grant; per-grid
grid_permission_limitsstill cap what is effective. - Audited. Applied effects are recorded on the event
(
gameModelEvents→permissionEffectsAppliedJson), so every model-driven grant/revoke is attributable — including automation-driven ones (an NPC quartermaster can grant withrunAsUserIdresolving$caller_user_id).
For the worked land-purchase mapping, see Modeling game concepts.
Tier-gated features
Sell or gate abilities by access tier. Define a feature key for your app, then
grant it to the tiers that should unlock it; the tier_feature requirement then
passes only for players on a granting tier.
mutation { gameModelDefineFeature(input: {
appId: "1", featureKey: "premium_abilities"
}) { featureKey } }
mutation { gameModelGrantTierFeature(input: {
appId: "1", tierId: "<tier-id>", featureKey: "premium_abilities"
}) { featureKey } }
Revoke a grant with gameModelRevokeTierFeature (same input shape), and list an
app's features and grants with gameModelFeatures(appId) and
gameModelTierFeatures(appId, tierId).
Feature keys are specific to your app and independent of the built-in runtime permission keys used for grids.