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.
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 - 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.
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. App admins always bypass it;
an absent policy means any entitled player may invoke. 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.
{ "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" }
] }
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 |
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.
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 authority fails you get an authorization error. 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.
Reading state and the graph
gameModelContainer(appId, containerId)— container metadata.gameModelContainerState(appId, containerId)— visible properties as a JSON object.gameModelContainers(appId, typeName, sessionId, where, limit, offset)— list instances.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, and$session_idare injected into function-body and effect 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. - 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.