Modeling game concepts
Game Models and Autonomous Processes are deliberately abstract — three primitives (containers, properties, functions) plus server-driven automations. That abstraction is powerful, but it isn't always obvious how a traditional game feature maps onto it. This guide shows the mappings for the features developers ask about most: inventory, custom permissions on game objects (keys, doors, chests, land), and NPCs.
Every mapping follows the same two-phase shape:
- The studio loads the model — an app admin (a caller with
manage_apps) seeds container types, property definitions, functions with invoke policies, and automations. This is your game's rules, uploaded once and versioned like code (gameModelSeedis the bulk call). The admin also seeds any shared catalog containers (item definitions, NPC templates, world objects). - Players (and automations) drive the runtime — clients create their own
containers where the type allows it, and mutate state exclusively through
gameModelInvoke, where the server enforces each function's invoke policy.
Split your types into admin-instantiable catalog/world types (item
definitions, NPC templates, doors, world state — instantiableBy: "admin")
and player-instantiable runtime types (inventories, item stacks, claims —
instantiableBy: "member" or "owner"). The
Blocks with Friends sample uses exactly
this split: admins seed BlockDef/ItemDef/Recipe/Npc; players create
Inventory/ItemStack/PlayerStats/Claim at runtime.
The CrowdyJS Game Kit packages each of these mappings as a ready-made blueprint (the seed payload) plus typed runtime helpers, so you can adopt them without hand-writing the GraphQL below.
Inventory
An inventory is a container that owns other containers. Model it as:
- An
Inventorycontainer per player (or per chest/NPC — see below), holding slot metadata. - One
ItemStackcontainer per stack, withitem_id,quantity, andslotproperties. - Functions that mutate quantities and slots, gated by
owner_of_selfso only the stack's owner may spend or move it.
mutation {
gameModelSeed(input: {
appId: "1",
containerTypes: [
{ typeName: "Inventory", displayName: "Inventory", instantiableBy: "member" },
{ typeName: "ItemStack", displayName: "Item Stack", instantiableBy: "member" }
],
propertyDefinitions: [
{ containerTypeName: "Inventory", key: "max_slots", valueType: "int", defaultValueJson: "24" },
{ containerTypeName: "ItemStack", key: "item_id", valueType: "string" },
{ containerTypeName: "ItemStack", key: "quantity", valueType: "int", defaultValueJson: "0" },
{ containerTypeName: "ItemStack", key: "slot", valueType: "int", defaultValueJson: "0" }
],
functions: [
{
name: "grant_stack",
containerTypeName: "ItemStack",
parameters: [{ name: "amount", valueType: "int", required: true }],
mutations: [
{ target: "self", property: "quantity",
expression: "self.quantity + max(0, $amount)" }
],
invokePolicyJson: "{\"type\":\"owner_of_self\"}"
},
{
# The condition rule refuses the call (success: false, no writes)
# when the stack is short — the guard lives in the policy, not the
# client.
name: "consume_stack",
containerTypeName: "ItemStack",
parameters: [{ name: "amount", valueType: "int", required: true }],
mutations: [
{ target: "self", property: "quantity",
expression: "self.quantity - $amount" }
],
returnExpression: "self.quantity",
invokePolicyJson: "{\"type\":\"and\",\"rules\":[{\"type\":\"owner_of_self\"},{\"type\":\"condition\",\"expression\":\"$amount > 0 && self.quantity >= $amount\"}]}"
},
{
name: "move_stack",
containerTypeName: "ItemStack",
parameters: [{ name: "to_slot", valueType: "int", required: true }],
mutations: [
{ target: "self", property: "slot", expression: "clamp($to_slot, 0, 63)" }
],
invokePolicyJson: "{\"type\":\"owner_of_self\"}"
},
{
# Cross-container transfer in ONE transaction: both writes apply or
# neither does. `self` is the source stack; $to_id the destination.
# The policy checks ownership, matching item ids, and sufficient
# quantity before any write happens.
name: "transfer_stack",
containerTypeName: "ItemStack",
parameters: [
{ name: "to_id", valueType: "container_ref", required: true },
{ name: "amount", valueType: "int", required: true }
],
mutations: [
{ target: "self", property: "quantity",
expression: "self.quantity - $amount" },
{ target: "ref($to_id)", property: "quantity",
expression: "ref($to_id).quantity + $amount" }
],
invokePolicyJson: "{\"type\":\"and\",\"rules\":[{\"type\":\"owner_of_self\"},{\"type\":\"condition\",\"expression\":\"$amount > 0 && self.quantity >= $amount && ref($to_id).item_id == self.item_id\"}]}"
}
]
}) { containerTypesCreated functionsCreated warnings }
}
Runtime flow: on first login the client creates (or finds) its own
Inventory and ItemStack containers with gameModelCreateContainer
(omitting ownerUserId so the server assigns the caller), lists them back with
gameModelContainers(appId, typeName), and mutates them only through
gameModelInvoke. Because every function above requires owner_of_self,
players cannot spend or move anyone else's items — even though the client
code is fully untrusted.
Two useful extensions:
- Membership edges. Link stacks to their inventory with
gameModelAddEdge(relationshipType: "inventory_contains")and read a whole bag back in one call withgameModelTraverse. This also models nested storage (a backpack inside a chest). - Crafting. Keep recipes as admin-seeded catalog containers
(
Recipe { inputs, output, station }). Acraftfunction can validate the recipe and consume/grant in one transaction when the input stacks are passed ascontainer_refparams; for large recipes it is also fine to validate server-side and let the client orchestrateconsume_stack/grant_stackcalls (each call is still owner-gated and atomic).
Custom permissions on game objects
There is no per-container ACL table — and you don't need one. Authority is
expressed on functions via the composable
invoke policy
tree, drawing on ownership, grid ACLs, groups, tiers, and arbitrary
condition expressions. Below are the three canonical shapes.
"If a player has key 1 they can open door 1"
The key is an item the player owns; the door names the key it requires. The
open_door function takes the key as a container_ref param and a
condition rule verifies — server-side — that the key matches the door and
belongs to the caller.
mutation {
gameModelSeed(input: {
appId: "1",
containerTypes: [
{ typeName: "Door", displayName: "Door", instantiableBy: "admin" },
{ typeName: "KeyItem", displayName: "Key", instantiableBy: "admin" }
],
propertyDefinitions: [
{ containerTypeName: "Door", key: "required_key_id", valueType: "string" },
{ containerTypeName: "Door", key: "is_open", valueType: "bool", defaultValueJson: "false" },
{ containerTypeName: "KeyItem", key: "key_id", valueType: "string" },
# Container ownership isn't readable from expressions, so mirror the
# owner into a property when a condition rule needs it.
{ containerTypeName: "KeyItem", key: "owner_user_id", valueType: "int" }
],
functions: [
{
name: "open_door",
containerTypeName: "Door",
parameters: [{ name: "key_id", valueType: "container_ref", required: true }],
mutations: [{ target: "self", property: "is_open", expression: "true" }],
invokePolicyJson: "{\"type\":\"condition\",\"expression\":\"ref($key_id).key_id == self.required_key_id && ref($key_id).owner_user_id == $caller_user_id\"}"
}
]
}) { functionsCreated }
}
The admin then instantiates Door 1 with required_key_id: "key_1" and
grants a player Key 1 (a KeyItem with key_id: "key_1",
owner_user_id set to that player, and the container's ownerUserId set so
inventory functions also work on it). A player without the key gets
success: false from gameModelInvoke — there is nothing the client can
spoof, because both properties are read by the server inside the policy.
condition expressions can read self, the call's params, and the injected
values $caller_user_id, $current_turn_user_id, $self_owner_id, and
$session_id.
"Player X has movement permission on chunk X → they can enter it and open the door"
Spatial permissions already exist as
grids and runtime permissions: a grid is a box of
chunks, and per-user grants (direct or via groups) materialize into the ACL
that the replication layer enforces on movement and voxel writes. The
grid_permission policy leaf lets your model logic reuse those same
grants — one permission source for both "can the player physically enter the
chunk" (enforced by the runtime) and "can the player operate this object"
(enforced by the model):
mutation {
gameModelUpsertFunction(input: {
appId: "1",
name: "open_area_door",
containerTypeName: "Door",
mutations: [{ target: "self", property: "is_open", expression: "true" }],
# The caller must hold the `access` runtime permission on grid 7 —
# the same grant that lets them enter the chunks the grid covers.
invokePolicyJson: "{\"type\":\"grid_permission\",\"key\":\"access\",\"gridId\":\"7\"}"
}) { name }
}
Grant the permission once (grantGridPermissions, or a group grant with
assignGroupToGrid) and the player can both enter the region and open its
doors. Omit gridId to accept the permission on any grid.
Even better: with the permission-read builtins the door doesn't need a hand-pinned grid id at all — it checks the ACL for the chunk it stands in, so one function serves every door in the world:
mutation {
gameModelUpsertFunction(input: {
appId: "1",
name: "open_door",
containerTypeName: "Door",
# cx/cy/cz are int properties set when the door is placed.
mutations: [{ target: "self", property: "is_open", expression: "true" }],
invokePolicyJson: "{\"type\":\"condition\",\"expression\":\"has_chunk_permission($caller_user_id, \\\"access\\\", self.cx, self.cy, self.cz, \\\"smallest\\\")\"}"
}) { name }
}
Game logic can also produce those grants itself. With permission effects a function grants (or revokes) grid permissions transactionally with its mutations — the canonical "buy this plot of land" flow, with the price and the access grant in one atomic invoke:
mutation {
gameModelUpsertFunction(input: {
appId: "1",
name: "buy_plot",
containerTypeName: "Plot",
parameters: [{ name: "wallet_id", valueType: "container_ref", required: true }],
mutations: [
{ target: "ref($wallet_id)", property: "gold",
expression: "ref($wallet_id).gold - self.price" },
{ target: "self", property: "owner_user_id", expression: "$caller_user_id" }
],
# Pay AND get access atomically: if either fails, neither happens. The
# grant is enforced by Buddy on movement/voxel writes at commit.
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 }
}
The same mechanism handles rentals (ttlSecondsExpression puts an expiry on
the grant) and banishment (action: "revoke"). Every applied effect is
audited on the invocation event (permissionEffectsAppliedJson).
Together with the
permission-read builtins,
this closes the full loop inside the model: buy_plot writes the grant,
open_door/place_block logic reads it back (has_chunk_permission),
and guard NPCs target players
who lack it — all against the same live ACL the replication layer enforces on
movement and voxel writes.
"Only the owner of this chest can open it"
Ownership is built in: containers carry an ownerUserId, and the
owner_of_self rule passes only for that user. A chest is just an
admin-defined type whose functions require ownership:
mutation {
gameModelUpsertFunction(input: {
appId: "1",
name: "open_chest",
containerTypeName: "Chest",
mutations: [{ target: "self", property: "is_open", expression: "true" }],
invokePolicyJson: "{\"type\":\"owner_of_self\"}"
}) { name }
}
Pair it with property visibility: declare
the chest's contents_ref (or its stacks' properties) as visibility: "owner" and non-owners can't even read what's inside, let alone take it.
For a team chest, swap the rule for
{"type":"group_permission","groupId":"42"} (any active member of
team 42) or add "permission":"use_chest" to require a specific
role-held permission. To transfer ownership, an app admin (or an
automation) re-creates or reassigns the container — clients cannot set another
user as owner.
Choosing an authority source
| You want to express | Policy leaf |
|---|---|
| Only the object's owner | owner_of_self |
| Holder of a specific key/item | condition on a container_ref param (key pattern above) |
| Anyone allowed in this world region | grid_permission (key + optional gridId) |
| Members of a team / holders of a team role | group_permission (groupId + optional permission) |
| Players on a paid/premium tier | tier_feature |
| Whoever's turn it is / session participants | is_current_turn, is_participant |
| The elected game host | is_host |
| Server-driven automations only | is_automation |
| Any arbitrary check on model state | condition expression |
All leaves compose with and / or / not — "the owner, or a teammate
with the use_chest permission, or an automation" is one small JSON tree.
App admins always bypass; an absent policy means any entitled player.
NPC concepts
NPCs decompose into three ingredients, each already covered by a primitive:
- What an NPC is — containers. A catalog template
(
NpcDef/ trade tables / quest definitions, admin-instantiable) plus one liveNpccontainer per spawned NPC holding its mutable state (x/y/z,health,behavior_state,role). - What an NPC can do — functions marked
autonomousInvocable, usually gated{"type":"is_automation"}so players can't puppet them. - What makes an NPC act — automations: schedule triggers for ambient behavior, event triggers for reactions, and selectors for target acquisition.
Ambient behavior: the wanderer
mutation {
gameModelUpsertFunction(input: {
appId: "1",
name: "npc_wander",
containerTypeName: "Npc",
mutations: [
{ target: "self", property: "x", expression: "self.x + rand_int(-2, 2)" },
{ target: "self", property: "z", expression: "self.z + rand_int(-2, 2)" }
],
invokePolicyJson: "{\"type\":\"is_automation\"}",
autonomousInvocable: true
}) { name }
}
mutation {
gameModelUpsertAutomation(input: {
appId: "1", name: "npc-wander", functionName: "npc_wander",
targetMode: "type", targetTypeName: "Npc", maxTargets: 8,
triggerType: "schedule", scheduleKind: "interval", intervalMs: 60000,
selectorJson: "{\"selfWhere\":[{\"key\":\"role\",\"op\":\"==\",\"value\":\"wanderer\"}]}"
}) { name nextRunAt }
}
Every minute the server fans out over Npc containers whose role is
wanderer and nudges each one. Clients simply re-read the containers (or
listen for a model-driven notification declared
on npc_wander) and render the new positions.
Reactive behavior: the guard
Event triggers plus a selector turn "a player did something" into "the nearest guard responds" — with no server code:
mutation {
gameModelUpsertAutomation(input: {
appId: "1", name: "guard-response", functionName: "npc_attack",
targetMode: "type", targetTypeName: "Npc", maxTargets: 4,
triggerType: "event",
selectorJson: "{\"selfWhere\":[{\"key\":\"role\",\"op\":\"==\",\"value\":\"guard\"}],\"pick\":\"nearest\",\"ofType\":\"PlayerAvatar\",\"where\":[{\"key\":\"wanted\",\"op\":\"==\",\"value\":true}],\"by\":\"manhattan\",\"bindAs\":{\"ref\":\"target_id\",\"approachX\":\"step_x\",\"approachY\":\"step_y\",\"approachStop\":1}}"
}) { automationId }
}
mutation {
gameModelUpsertAutomationTrigger(input: {
appId: "1", automationName: "guard-response",
onEvent: "function_invoked", functionName: "commit_crime", debounceMs: 2000
}) { triggerId }
}
When any player invokes commit_crime, each guard picks the nearest wanted
player, steps toward them (bounded by its own move stat), and attacks if in
range — the pattern behind the tactical simulator's
self-playing enemy team.
The rest of the NPC toolbox
- Traders. Trade tables are catalog containers (
NpcTrade { gives, wants, stock }); arestockautomation refillsstockon an interval; thetradefunction consumes the player's stack and grants the traded item in one transaction (the inventorytransfer_stackpattern, with aconditionverifying stock and price). - Spawners. A
Spawnercontainer with aspawn_mobautomation on an interval; the function flips free pool slots to alive (pre-seed a bounded pool ofMobcontainers rather than creating unbounded rows). - Dialogue and quests. Pure catalog data —
Quest/DialogueNodecontainers the client reads. Turn-in is an owner-gated function that consumes objective items and grants rewards atomically. - Turn-taking enemies. Event-trigger an automation on your
begin_enemy_phasefunction and let the selector play each enemy unit; useis_current_turnon the player-facing functions. - When not to use automations. The dispatcher floor is
seconds-per-tick, not frames. Fast-twitch mob movement should be simulated
by the elected host client over the realtime path (state
synced with
is_host-gated model functions for durable health/position), with automations handling slower lifecycle work (spawning, restocking, regen). Blocks with Friends uses exactly this hybrid.
Automation activity is billed per run and visible in gameModelAutomationRuns
/ gameModelAutomationStats; events tag callerKind: "automation" and
automationId, so NPC actions are distinguishable from player actions in the
event log.
More concept mappings
The same primitives cover most genre staples. Each row below is a model design; the CrowdyJS Game Kit ships every one as a blueprint + runtime helpers.
Economy and trading
Wallets are containers with an owner_user_id mirror plus one int property
per currency. Every movement of value is one function invocation: a shop
purchase debits the wallet, decrements the listing's stock, and grants the
item stack in a single transaction (all reachable through container_ref
params); a player-to-player trade swaps two stack pairs atomically after
guards verify ownership mirrors, item ids, and quantities; a market purchase
pays the seller's wallet and transfers the stack in one invoke. Trusted mints
(earn) are invokeScope: "server" or automation-driven — never plain
player calls. The injected $self_owner_id pins an offer/listing to its
server-truth creator, so forged offers over other players' stacks can never
be accepted.
Quests and objectives
An admin QuestDef catalog (objective count, reward spec, daily flag) plus
per-player QuestProgress rows. Progress advances via event automations
attached to your gameplay functions (function_invoked on consume_stack,
mob_died, …) or trusted server calls; turn-in is one atomic invoke that
marks the row claimed AND grants item + currency rewards through
container_ref params; dailies reset with a cron automation over
daily == true rows.
Matches, turns, and scoring
Sessions ARE the match primitive — participants and currentTurnUserId
(set through gameModelSetSessionTurn, whose authority the service
enforces). Add a session-scoped MatchMeta container (lobby state, round,
winner, notification channel) and per-player Score rows written by a
host/server/automation-gated function. Lifecycle functions declare a
channel notification so every participant gets a "match_changed" ping
post-commit and re-pulls (notify-to-pull). Turn timeouts use tick counters
bumped by an interval automation — expressions have no now().
Decks and hidden information
For small/low-stakes games, hidden state is property visibility, not client discipline: a
CardInstance carries an owner-visible card_id plus a public
revealed_card_id that stays empty until the play function copies it over in
the same transaction. Shuffling works within the platform's limits (no array
permutation in expressions): a manual type-fan-out automation deals each
deck-zone card a rand_int position, and drawing takes the caller's
lowest-position card with the zone transition enforced server-side.
For true secret deck order, simultaneous reveal or competitive cards, use
the deck-engine: hands/order stay in module state and only caller-scoped
invokes reveal them.
World simulation
Simple interval automations can run the world with no client online: a WorldState
singleton advances time_of_day (% hours_per_day) and re-rolls weather —
emitting a spatial notification at its anchor chunk so nearby clients
update the sky without polling; ResourceNodes regenerate toward
max_amount (selector where amount < self.max_amount); crops advance
stage and are harvested by an owner-gated atomic function. For weather
fronts, waves, resource placement or shared agents that need loops/fidelity,
use world-engine/director/mob-engine rather than an elected-host
spawner.
Leaderboards and seasons
Per-player LeaderboardEntry rows (board_id, score, season) written
only by a trusted submit_score (host-gated, server scope, or an event
automation on end_match). A bounded/casual board may fetch and sort
locally; competitive or large boards should use board-engine for
server-computed rank/percentile/pages. Seasons still roll cleanly with a
cron automation.
Genre map
| Genre | Core needs | Model mappings (kit layers) |
|---|---|---|
| RPG / MMORPG | stats, quests, XP/levels, vendors, loot, parties, guilds | progression, quests, economy, combat, loot, social, NPCs, inventory |
| Survival / sandbox | land, build rights, crafting, farming, regen, mobs | plots, inventory, quests + world/npc/mob/combat engines |
| Turn-based tactics / board / card | matches, turn order, hidden info, decks, ELO | sessions, matches/decks + match/deck/tactics engines |
| FPS / battle royale / racing | refereed actions, zones, rounds, ranking | abilities, movement, liveops/zones, racing, match/board engines |
| MOBA / team arena | teams, matchmaking, abilities, lanes, territory | social + matchmaking/abilities/director/territory engines |
| Tycoon / city / farming sims | plots, production chains, timers, markets | plots, worldsim (production ticks), economy (market) |
| Tower defense / idle | waves, upgrades, offline progress | worldsim (waves), progression, interval accrual |
| Roguelike / dungeon | runs, procedural loot, meta-progression | matches (run = session), loot, progression |
| Social / party / creative | rooms, chat, cosmetics, moderation | social, matches (rooms), monetization features, revoke effects |
| Trading / collection (TCG, pets) | collections, rarity, trading, breeding ticks | economy (trade/market), inventory, worldsim, decks |
Using the SDK instead
Every mapping above ships in CrowdyJS as a blueprint + runtime helpers:
client.kit(appId).deploy(...) seeds the types/functions/automations, and
the runtime facades (kit.inventory, kit.objects, kit.npcs,
kit.plots, kit.economy, kit.progression, kit.loot, kit.quests,
kit.combat, kit.matches, kit.decks, kit.worldsim, kit.social,
kit.leaderboards, kit.features) wrap the runtime calls. See
CrowdyJS → Game Kit.
See also
- Game Models — the primitives, expression language, and authority model
- Autonomous Processes (NPCs) — automations, triggers, selectors, circuit breakers
- Model-driven notifications — announcing model changes over realtime
- Grids and permissions · Teams — the authority sources policies draw on