Skip to main content

Game Kit

client.kit(appId) is the SDK's high-level layer over client.gameModel. It packages the concept mappings from Modeling game concepts — inventory, lockable objects, NPCs, plots, and (8.3+) the genre layers: economy, progression, loot, quests, combat, matches, decks, world simulation, social, leaderboards, and monetization — as ready-made blueprints plus typed runtime helpers, so you don't hand-write container types, expression functions, and invoke policies for the common cases.

The kit follows the platform's two-phase model:

  1. Studio (admin) loads the ruleskit.deploy(blueprints) seeds the container types, property schemas, policy-gated functions, and automations into your app. It needs the app-admin manage_apps permission, so run it from a trusted admin context (a setup script, a studio backend, or your own admin tool) — never the game client you ship.
  2. The game client plays — the runtime helpers (kit.inventory, kit.economy, kit.matches, …) wrap the runtime calls, assuming the conventions the blueprints deployed. Authorization stays entirely server-side.

Everything composes existing client.gameModel operations; the kit adds no new server surface, and the lower-level client.gameModel remains available.

Deploying blueprints (studio)

import {
inventoryBlueprint,
lockBlueprint,
npcBlueprint,
} from '@crowdedkingdoms/crowdyjs';

const kit = admin.kit(appId); // admin holds an app-scoped token + manage_apps

const result = await kit.deploy([
// Per-player inventories + item stacks with owner-gated mutations.
inventoryBlueprint(),

// Key-gated doors: "if a player has key 1 they can open door 1".
lockBlueprint({ objectTypeName: 'Door', authority: { kind: 'key' } }),

// Owner-only chests: "only the owner of this chest can open it".
lockBlueprint({ objectTypeName: 'Chest', authority: { kind: 'owner' } }),

// A wandering NPC driven by a server automation.
npcBlueprint({
behaviors: [
{
name: 'npc-wander',
role: 'wanderer',
trigger: { intervalMs: 60_000 },
mutations: [
{ target: 'self', property: 'x', expression: 'self.x + rand_int(-2, 2)' },
{ target: 'self', property: 'z', expression: 'self.z + rand_int(-2, 2)' },
],
},
],
}),
]);

console.log(result.seed.functionsCreated, result.warnings);

deploy merges the blueprints into one transactional gameModelSeed call, then upserts each automation and event trigger. It is idempotent — rerun it after editing a blueprint and the definitions upsert in place (automations key on their name). Duplicate type/function/automation names across blueprints throw before anything is sent; give a second inventory system a typePrefix (e.g. inventoryBlueprint({ typePrefix: 'Bank' })BankInventory, bank_grant_stack).

Blueprints are plain data (KitBlueprint), so you can also write your own — or extend a generated one — and deploy it the same way. The kitPolicyJson helper builds invokePolicyJson trees with types; composeBlueprints bundles several builders' output under one name (see guildBlueprint).

Layer catalog

Every builder takes a typePrefix-style namespacing option so several instances coexist, and every runtime helper returns KitInvokeResult — authority denials and guard failures resolve success: false, never throw.

LayerBlueprint builderRuntime helperFamiliar terms
InventoryinventoryBlueprintkit.inventorybags, item stacks, transfers
Lockable objectslockBlueprintkit.objects / kit.objectsFordoors, chests, keys, area gates
NPCsnpcBlueprintkit.npcswanderers, guards, traders
PlotsplotBlueprintkit.plotsland sales, rentals, eviction
EconomyeconomyBlueprintkit.economywallets, shops, trades, market
ProgressionprogressionBlueprintkit.progressionxp, levels, skills, achievements, rating
LootlootBlueprintkit.lootloot tables, drops, gacha
QuestsquestsBlueprintkit.questsobjectives, turn-in, dailies
CombatcombatBlueprintkit.combathp/damage, status effects, respawn
MatchesmatchesBlueprintkit.matcheslobbies, rounds, turns, scoring
DecksdecksBlueprintkit.deckshands, draws, hidden information
World simulationworldsimBlueprintkit.worldsimday/night, resource nodes, crops, waves
SocialguildBlueprint (composite)kit.socialparties, guilds, chat, territory
LeaderboardsleaderboardsBlueprintkit.leaderboardsrankings, seasons
Monetization— (featureGate policies)kit.featurespremium features, tier gates

Genre coverage comes from combining layers — an RPG is progression + quests + economy + combat + loot + social; a board game is matches + decks; a tycoon is plots + worldsim + economy. See the genre map.

Inventory

Runtime helpers assuming inventoryBlueprint's conventions:

const kit = game.kit(appId); // game holds the player's app-scoped token
const boot = await game.serverStatus.gameClientBootstrap(appId);
const myUserId = boot.me.userId;

// Find or create my inventory (server assigns ownership to the caller).
const bag = await kit.inventory.ensure(myUserId);

// Stacks I own, with parsed { itemId, quantity, slot }.
const stacks = await kit.inventory.stacks(myUserId);

// Mutations run through the owner-gated model functions — atomic and
// authority-checked server-side. A denial is a result, not an exception:
const spend = await kit.inventory.consume(stacks[0].containerId, 5);
if (!spend.success) console.warn(spend.errorMessage); // e.g. insufficient quantity

await kit.inventory.grant(stackId, 10); // sandbox default only
await kit.inventory.move(stackId, 3);
// Atomic cross-stack transfer (same item type; both writes or neither):
await kit.inventory.transfer(fromStackId, toStackId, 16);

// Optional: record membership edges and read a whole bag in one traversal.
await kit.inventory.linkStack(bag.containerId, stackId);
const contents = await kit.inventory.contents(bag.containerId);

// Recipes/barters supplied to inventoryBlueprint become one Model function:
await kit.inventory.craft(bag.containerId, 'wood_planks', [woodId], plankId);
await kit.inventory.barter(bag.containerId, 'wheat_for_emerald', wheatId, emeraldId);

For competitive economies deploy inventoryBlueprint({ stackInstantiableBy: 'admin', grantAuthority: 'server' }). Players can still consume/transfer/craft/barter, but cannot seed a non-empty stack or call generic grant_stack; a trusted bootstrap/referee creates empty stacks and grants through model_invoke. Set ownerIdKind: 'string' in both the blueprint and runtime options only for legacy worlds whose owner-mirror property is a string.

Objects with custom permissions

Runtime helpers assuming lockBlueprint's conventions. The blueprint's authority option decides who may operate the object:

authorityMeaning
{ kind: 'owner' }Only the container's owner (owner_of_self).
{ kind: 'key' }The caller must own a matching key item (checked server-side via a condition rule).
{ kind: 'gridPermission', key, gridId? }The caller must hold a runtime grid permission — ties objects to world regions.
{ kind: 'groupPermission', groupId, permission? }The caller must be in a team (optionally holding a permission).
{ kind: 'custom', rule }Any hand-written policy rule tree.

An array OR's them: authority: [{ kind: 'owner' }, { kind: 'key' }] means "the owner, or anyone with the right key".

// Studio: place a door and hand out its key.
const adminKit = admin.kit(appId, { objects: { objectTypeName: 'Door' } });
const door = await adminKit.objects.create({
displayName: 'Vault Door',
requiredKeyId: 'key_1',
});
await adminKit.objects.grantKey({ keyId: 'key_1', toUserId: playerId });

// Game client: try the door.
const kit = game.kit(appId, { objects: { objectTypeName: 'Door' } });
const [myKey] = await kit.objects.keysOf(myUserId);
const result = await kit.objects.open(door.containerId, { keyId: myKey?.containerId });
if (!result.success) {
showLockedMessage(result.errorMessage); // no key / wrong key — decided server-side
}

Several lockable types can coexist; use kit.objectsFor('Chest') for helpers bound to another deployed type name.

A fifth authority, { kind: 'chunkPermission', key, mode? } (game-api v0.13.12+), gates the object by where it stands: the open/close policy compiles to has_chunk_permission over the object's own cx/cy/cz properties (seed them with objects.create({ chunk: {x,y,z} })), so one deployed function serves every door in the world and automatically honors grid grants. mode picks the covering grid when several overlap ('first' default, 'smallest' for plot logic, 'largest').

Plots: sell and rent land

plotBlueprint() + kit.plots (game-api v0.13.11+) close the permission loop end to end: buying a plot spends wallet currency AND grants replication-enforced grid permissions in one transaction (via permission effects).

// Studio: deploy, create a grid for the plot, then the plot over it.
await adminKit.deploy([plotBlueprint({ rentable: true })]);
const grid = await admin.gameApps.createGrid({ appId, corner1, corner2 });
await adminKit.plots.create({
displayName: 'Lakeside Plot',
gridId: grid.grid.gridId,
price: 100,
rentPrice: 10,
rentTtlSeconds: 86_400,
});

// Game client: buy (or rent — the grant expires after rent_ttl_seconds).
const plots = await kit.plots.list();
const result = await kit.plots.buy(plots[0].containerId, myWalletId);
if (result.success) {
// Buddy now enforces access/update_voxel_data on the plot's grid,
// chunk-permission doors on it open, and the HUD can show:
const keys = await kit.plots.accessOf(myUserId, plots[0].gridId);
}

// The plot's owner (or an admin) can revoke:
await kit.plots.evict(plotId, intruderUserId);

The wallet is any container following the kit convention: an owner_user_id property mirroring its owner plus a currency property (default gold) — inventoryBlueprint's stacks or your own type both work. The server verifies wallet ownership and price in the invoke policy; an underfunded or foreign wallet resolves success: false.

NPC selectors that read permissions

NpcBehaviorSpec.selector is now typed (KitSelectorSpec) and supports grid-permission predicates — e.g. a guard that only targets intruders:

npcBlueprint({
behaviors: [{
name: 'guard-response',
role: 'guard',
trigger: { intervalMs: 30_000 },
selector: {
pick: 'nearest',
ofType: 'PlayerAvatar',
candidatePermissionWhere: [{
userFrom: { property: 'owner_user_id' },
op: 'lacks',
key: 'access',
grid: { property: 'grid_id' },
}],
bindAs: { ref: 'target_id' },
},
mutations: [{ target: 'self', property: 'behavior_state', expression: '"alert"' }],
}],
});

See Autonomous processes → Permission predicates for the predicate semantics.

NPCs

Runtime helpers assuming npcBlueprint's conventions. Behaviors run in the API server as automations; clients only spawn (admin), read state, and render:

// Studio: put a live NPC in the world.
await adminKit.npcs.spawn({
displayName: 'Wandering Builder',
role: 'wanderer',
position: { x: 12, y: 0, z: -4 },
});

// Game client: read what the server-driven NPCs are doing.
const npcs = await kit.npcs.list({ role: 'wanderer' });
for (const npc of npcs) render(npc.x, npc.z, npc.behaviorState);

// Studio: test, pause, and monitor the automations behind them.
await adminKit.npcs.runNow('npc-wander');
await adminKit.npcs.setEnabled('npc-wander', false);
const stats = await adminKit.npcs.stats(60);

Behaviors trigger on an interval ({ intervalMs }), a cron expression ({ cronExpr }), or a model event ({ onEvent: 'function_invoked', functionName, debounceMs }), and can carry a selector for target acquisition ("nearest wanted player"). Blueprint-generated behavior functions are autonomousInvocable and gated is_automation, so players can never puppet an NPC.

To make NPC changes visible without polling, declare a model-driven notification on the behavior function (add it to the blueprint's function before deploying) — clients then re-read on the ping.

Economy

economyBlueprint() + kit.economy: per-player Wallet containers (one int property per currency), an admin ShopListing catalog, escrow TradeOffer swaps, and a player MarketListing flow. Anti-duplication is structural — every movement of currency or items is one invoke whose condition guards check balances, stock, item identity, and ownership mirrors server-side. Never split a spend and a grant across two invokes.

// Studio: deploy, then price the shop.
await adminKit.deploy([economyBlueprint({ currencies: ['gold'], restock: { intervalMs: 300_000 } })]);
await adminKit.economy.shop.create({ displayName: 'Iron Sword', itemId: 'sword', price: 25, stock: 10 });

// Trusted mint: earn_gold is invokeScope "server" by default — app admins only
// (or deploy with earnAuthority: 'automation' and drive grants from automations).
await adminKit.economy.earn(walletId, 100);

// Game client: wallet, shop, market.
const wallet = await kit.economy.ensureWallet(myUserId);
await kit.economy.shop.buy({ listingId, walletId: wallet.containerId, toStackId: mySwordStack });
await kit.economy.market.list({ stackId: mySwordStack, itemId: 'sword', quantity: 1, price: 40 });
const bought = await kit.economy.market.buy({ listingId: theirListing, walletId, toStackId });

// Escrow trade: propose, then the invited player accepts — the four stack
// writes swap atomically, or not at all.
const offer = await kit.economy.trades.offer({
toUserId: friendId, giveStackId, giveItemId: 'sword', giveQty: 1,
wantItemId: 'shield', wantQty: 1, receiveStackId: myShieldStack,
});
// friend's client:
await kit.economy.trades.accept({ offerId: offer.containerId, wantStackId, toGiveStackId });

Trade/market guards verify stack ownership through the kit-standard owner_user_id mirror on the stack (set InventoryKit.createStack({ ownerUserId })), and pin the offer/listing creator via the injected $self_owner_id — the server-truth container owner, unspoofable by params.

Progression

progressionBlueprint() + kit.progression: Progress (xp / level / skill points / rating), a SkillDef catalog with prerequisite chains, threshold achievements, and an ELO-style rating hook for the match layer.

The XP curve is ONE internal function (xp_for_level) that grant_xp's mutations call via the fn: helper patternif(self.xp >= fn:xp_for_level(self.level + 1), self.level + 1, self.level) — so every reader stays in sync and the curve is not directly invocable. Ordered mutations see earlier writes: xp lands first, then the skill-point award, then the level bump (one level per grant).

const progress = await kit.progression.ensure(myUserId);
await adminKit.progression.grantXp(progress.containerId, 250); // trusted (server scope)
const rank = await kit.progression.buySkill({
skillRankId, progressId: progress.containerId, skillDefId, prereqRankId,
}); // cost, max rank, and the prerequisite chain checked server-side
await kit.progression.unlockAchievement({ ownerUserId: myUserId, progressId, achievementDefId, achievementId: 'xp_1000' });

applyMatchResult(progressId, delta) invokes the host-gated adjust_rating (configurable via ratingAuthority) — wire it from an event automation on the match layer's end_match.

Loot

lootBlueprint({ tables }) + kit.loot: weighted tables are unrolled into pure expressions at blueprint-build time (the expression language is loop-free, so the builder generates the nested-if chain — cap 16 entries per table). A roll stores ONE rand() seed, then resolves item and quantity from that seed in the same transaction; claiming marks the roll claimed AND grants the stack atomically, so nothing can be claimed twice and clients never pick their loot.

await adminKit.deploy([lootBlueprint({
tables: [{ tableId: 'goblin', entries: [
{ itemId: 'coin', weight: 3, minQty: 1, maxQty: 5 },
{ itemId: 'sword', weight: 1 },
]}],
drops: [{ name: 'goblin-drop', tableId: 'goblin', onEvent: 'function_invoked', functionName: 'mob_died' }],
})]);

const roll = await kit.loot.createRoll({ ownerUserId: myUserId, tableId: 'goblin' });
await adminKit.loot.roll(roll.containerId); // trusted (server scope by default)
await kit.loot.claim(roll.containerId, myCoinStack); // owner-gated atomic claim

Event-triggered drops roll a pooled unrolled LootRoll when the event fires — automations mutate, they cannot create containers, so keep a small pool of pre-created rolls per table.

drops, quest advanceOn, and NPC event behaviors all take the same trigger filters, including writeSource. That one matters whenever you trigger on onEvent: 'property_changed': kit state is written by functions, not by client setProperty calls, so watching a property like a mob's hp needs writeSource: 'function' (or 'any'). Without it the trigger matches only direct writes and quietly never fires — see write sources.

Quests

questsBlueprint() + kit.quests: an admin QuestDef catalog and per-player QuestProgress rows. Progress advances through trusted calls or advanceOn event automations bound to your gameplay functions; claim_reward marks the row claimed AND grants the item + currency rewards via container_ref params in one transaction; a cron automation resets daily quests (dailyResetCron, default UTC midnight).

await adminKit.deploy([questsBlueprint({
advanceOn: [{ name: 'advance-on-craft', questId: 'craft_10', onEvent: 'function_invoked', functionName: 'consume_stack' }],
})]);
await adminKit.quests.defineQuest({ questId: 'craft_10', targetCount: 10, rewardGold: 50, daily: true });

const progress = await kit.quests.accept(myUserId, questDefId);
// ...gameplay fires the automation; then:
await kit.quests.claim({ progressId: progress.containerId, questDefId, toStackId, walletId });

Combat

combatBlueprint() + kit.combat covers the server-authoritative tier (turn-based and MMO-durable combat): the damage formula (max(1, attack - defense)), the death flip, and status-effect damage-over-time all run server-side.

  • turnBased: true threads is_current_turn into attack/apply-effect policies for session-turn games.
  • Status effects use the selector join pattern: combatants carry a unique combat_key, effects record a target_key, and the tick automation's selector binds the matching combatant as a $target ref (where combat_key == self.target_key) — automations cannot follow property refs directly.
  • For competitive realtime combat, deploy a compute referee and use kit.combat.attackRouted; live poses/range/cooldowns are validated server-side and Model HP remains durable truth.
  • hostSynced: true remains a legacy/low-stakes co-op fallback: the elected host may persist simulated HP, but it is not an anti-cheat boundary.
const me = await kit.combat.spawnCombatant({ ownerUserId: myUserId, displayName: 'Knight', attack: 12 });
const result = await kit.combat.attack(me.containerId, targetId);
await kit.combat.applyEffect({ targetKey: enemyKey, effectId: 'poison', magnitude: 2, ticks: 5 });
await kit.combat.respawn(me.containerId); // owner + dead-only

Matches

Sessions ARE the match primitive (participants + currentTurnUserId); matchesBlueprint() adds a session-scoped MatchMeta (lobby/active/finished, round, winner, notification channel) and per-player Score rows. kit.matches wires the whole loop, including a channel per match for the notify-to-pull pattern:

const match = await kit.matches.create({ creatorUserId: myUserId, mode: 'ranked', maxPlayers: 4 });
const joinable = await kit.matches.open();
await kit.matches.join(joinable[0]);

await kit.matches.start(match); // creator or host
const off = kit.matches.onMatchChanged(match, (m) => render(m)); // subscribe → ping → re-pull
await kit.matches.endTurn(match, nextUserId); // platform session-turn authority
await kit.matches.score(match, scoreId, 10); // trusted (host by default)
await kit.matches.finish(match, winnerUserId); // event-automation hook point

The lifecycle functions declare a channel notification — Buddy pings every member with "match_changed" post-commit, and clients re-pull. scoreAuthority picks the referee ('host' default | 'server' | 'automation'). For rating/leaderboard updates, attach an event automation to end_match (function_invoked).

Turn deadlines

matchesBlueprint({ turnTimer: { delayMs } }) gives each turn a wall-clock deadline. start_match and begin_turn arm a one-shot timer deduped per match, so opening the next turn replaces the previous deadline rather than stacking another; when it fires, expire_turn records the expiry and pings the match channel, which your clients are already listening to.

const bp = matchesBlueprint({ turnTimer: { delayMs: 30_000 } });
// endTurn opens the incoming turn (arming its deadline) before handing over.
await kit.matches.endTurn(match, nextUserId);
// Whoever referees the match reacts to the ping:
if (turnExpired(await kit.matches.get(match.metaId))) {
await kit.matches.endTurn(match, playerAfter(nextUserId));
}

turnExpired() is the comparison turn_expired_seq >= turn_seq, which is also what makes a late fire harmless: a deadline that was already claimed when the player beat the clock is stamped with the older turn, so it records a lower sequence and never reads as expired. Finishing a match advances the sequence for the same reason.

The older turnTick option still works — it bumps a tick_count counter on every active match on an interval — but a deadline is cheaper and exact, so turnTick is deprecated and will go away in the next major.

Decks and hidden information

decksBlueprint() + kit.decks model cards with server-enforced hidden information: CardInstance.card_id carries visibility: "owner", so only the owner's reads include it, while the public revealed_card_id stays empty until play_card copies it over in the same transaction — opponents see a card exists in your hand, never what it is.

Shuffling is honest about the platform (no array permutation in expressions): decks are ordered by a position int dealt by a manual type-fan-out automation (rand_int per card); drawing takes your lowest-position deck card.

await kit.decks.deal({ ownerUserId: myUserId, cardIds: myDeckList, sessionId: match.sessionId });
await adminKit.decks.shuffle(); // runs the assign_position automation
const hand = await kit.decks.myHand(myUserId, { sessionId: match.sessionId });
await kit.decks.draw(myUserId, { sessionId: match.sessionId }); // top of deck
await kit.decks.play(hand[0].containerId, { sessionId: match.sessionId });

World simulation

worldsimBlueprint() + kit.worldsim: day/night + weather (WorldState singleton), regenerating ResourceNodes, growing Crops, and WaveSpawner counters — all interval automations, which run while the app has a player in it and are skipped while it is empty. Advance these by elapsed time rather than one step per tick, or the world stalls whenever nobody is playing. The world clock declares a spatial notification at the world anchor chunk, so nearby clients update the sky push-style instead of polling.

await adminKit.deploy([worldsimBlueprint({ time: { intervalMs: 60_000 }, waves: { intervalMs: 120_000 } })]);
await adminKit.worldsim.ensureWorld({ anchorChunk: { x: 0, y: 0, z: 0 } });
await adminKit.worldsim.createNode({ displayName: 'Iron Vein', nodeId: 'iron_1', resourceItemId: 'iron' });

const world = await kit.worldsim.worldState(); // { timeOfDay, day, weather }
await kit.worldsim.gather({ nodeId, amount: 3, toStackId: myIronStack }); // atomic
const crop = await kit.worldsim.plant({ ownerUserId: myUserId, outputItemId: 'wheat' });
await kit.worldsim.harvest(crop.containerId, myWheatStack); // stage >= max_stage, atomic

Wave spawners only advance counters — actual entity spawning stays host-side on the replication plane (the Blocks-with-Friends hybrid).

Social: parties, guilds, chat

kit.social wraps teams (membership + roles) and channels (app-wide messaging) in familiar words — no model schema needed:

const party = await kit.social.party.create('dungeon-run'); // team + chat channel pair
await kit.social.party.invite(party, friendUserId);

const guild = await kit.social.guild.create('Iron Legion'); // request-to-join by default
await kit.social.guild.promote(guild, memberId, [officerRoleId]);
await kit.social.guild.claimTerritory(guild, gridId); // grid group-grant — replication-enforced

const room = await kit.social.chat.room('global');
const off = kit.social.chat.onMessage(room.groupId, (m) => show(m.senderUuid, m.text));
await kit.social.chat.send(room.groupId, 'hello world');

The optional guildBlueprint({ guildGroupId }) composite deploys a GuildHall lockable gated on guild membership (group_permission) plus a prefixed guild-bank inventory — a worked example of blueprint composition (composeBlueprints). Create the guild team first, then deploy one prefixed blueprint per guild that needs its own hall. Moderation: evict-style revoke effects and removeMember cover bans.

Leaderboards

leaderboardsBlueprint() + kit.leaderboards: per-player LeaderboardEntry rows keyed by board_id, written only through the trusted submit_score (submitAuthority: 'host' | 'server' | 'automation', keep-best by default), with optional cron season rolls (seasonCron).

There is no server-side ORDER BY on container lists, so ranking is client-side — top() fetches a board's entries and sorts (fine for the few hundred entries a per-app board holds); automation selectors' pick: 'highest' covers server-side top-1 needs.

const entry = await kit.leaderboards.ensureEntry(myUserId, 'weekly_kills');
await hostKit.leaderboards.submit(entry.containerId, 42); // host-refereed
const top10 = await kit.leaderboards.top('weekly_kills', 10);
const nearMe = await kit.leaderboards.around('weekly_kills', myUserId);

Monetization: features and tier gates

kit.features wraps the app feature/tier surface in shop terms: define feature keys, grant them to the access tiers players buy/hold, and gate any kit function with a tier_feature policy leaf:

await adminKit.features.define('land_owner', 'May buy plots');
await adminKit.features.grantToTier(premiumTierId, 'land_owner');

// Compose the gate into builders via their *policyExtra options:
await adminKit.deploy([
plotBlueprint({ rentable: true, buyPolicyExtra: featureGate('land_owner') }),
lockBlueprint({ objectTypeName: 'VipDoor', authority: { kind: 'key' }, policyExtra: featureGate('vip') }),
]);

featureGate(key) (also kit.features.gate(key)) returns the policy leaf; andPolicies(base, ...extra) composes rules for hand-written blueprints.

Patterns

The mental models behind the layers — read these before designing your own blueprints.

The four simulation tiers

  1. Replication/client render (20–60 Hz): actor updates, voxel edits, prediction and interpolation over client.udp / client.world.
  2. Compute engines (normally 1–5 Hz): authoritative agents, projectiles, world loops and competitive referees.
  3. Automations (seconds–minutes): restock, dailies, coarse selector fan-out; never pathfinding or fast motion.
  4. Model invokes (on demand): atomic, policy-gated durable transactions.

The elected host remains useful for targeted delivery and low-stakes co-op fallbacks. It is not the preferred authority for competitive simulation. See Choosing Game APIs.

Notify-to-pull

Model changes are pull-based — there is no model subscription. The wiring recipe: (a) give the aggregate a channel (or use a spatial ping for world-anchored state); (b) declare a notifications entry on each mutating function (emitted via Buddy post-commit) or send a client ping after runtime mutations; (c) subscribers re-read the model on each ping. kit.matches.onMatchChanged and the worldsim clock's spatial ping are the two shipped examples. The client half is packaged as the World Stores ContainerMirror: watch containers, bind the channel, and typed snapshots refresh themselves.

Cooldowns and timers

Expressions have now() (int milliseconds, bound once per invoke). For deadlines and recurring work, prefer scheduled execution:

  • One-shot timers for a deadline on a particular thing: a function declares a timers effect and arms it transactionally with its own mutations. Give it a dedupeKey and re-arming replaces the pending fire instead of queueing another. This is how matchesBlueprint({ turnTimer }) expires a turn.
  • Interval automations for genuinely recurring work that is not about any one entity's deadline (status-effect ticks, shop restocks, crop growth). Reach for these when the answer to "how many timers would this be?" is "one per row".
  • TTL permission-effect grants as timed capabilities (rentals, buffs that expire — plotBlueprint({ rentable: true })).

Since a fire can land after the thing it was armed for has moved on, stamp the timer with what it was armed for and compare on arrival — turnTimer passes the turn sequence and keeps the recorded expiry monotonic, so a late fire lands below the open turn and is ignored.

Catalog vs. instance

Admin-instantiable catalog types hold the rules (ShopListing, SkillDef, QuestDef, CardDef, LootTable entries baked into expressions); member-instantiable instance types hold per-player state (Wallet, Progress, QuestProgress, CardInstance). Players can create instances but only functions can mutate the numbers that matter.

Hidden information

Property visibility: "owner" is server-enforced read filtering — the basis of hidden hands (decksBlueprint), hidden chest contents, and fog-of-war state. Pair a hidden property with a public one and copy on reveal inside a function, so the reveal and the state change commit together.

Anti-cheat checklist

  • Guards live in invoke policies, not clients: balance/stock/ownership conditions deny with success: false and roll back.
  • Reward-granting functions (earn, grant_xp, roll_*, submit_score, score_points) are invokeScope: "server", is_host, or automation-driven — the kit's authority options default accordingly. Never trust client params for rewards.
  • Currency/item movements are single invokes with container_ref params (buy_listing, accept_trade, claim_reward) — two-step flows can be interleaved or abandoned.
  • Mirror owners into owner_user_id properties (kit standard: int) so cross-container guards can verify them; pin creators with the injected $self_owner_id, which callers cannot spoof.
  • Hidden state uses property visibility, not client discipline.

Engine-aware helpers (8.7+)

When your app deploys compute engines (server-side Rust modules built on the crowdy-game-kit crates), the kit gains a second gear. Capability detection keeps one code path for both deployments: every engine-aware helper probes the module once per session (kit.engines, the shared EngineDetector) and degrades to the model/automation behavior when no engine is present.

  • kit/wire (package root exports): the engine actor wire registry — the 48-byte pose codec (decodeEnginePose, container-id suffix), FLAG_MOB / FLAG_NPC flag bits, enginePoseCodec (a StateCodec for World Stores), engineLanes() ready-made players/mobs/npcs lane predicates for createWorldSession, and the server-event parsers parseContactDamage (type 77) / parseWeatherEvent (type 90).
  • kit.mobsattack(containerId, amount) through the engine's server referee (range/presence validated server-side; denials resolve {success: false, reason}), defs() / slots() durable reads, status().
  • kit.petsadopt / list / summon / dismiss / rename over the npc-engine (pets follow their owner server-side).
  • kit.combat.attackRouted({targetId, attackerId?, amount?}) — routes through the referee when the engine is present, else the model attack function; the result names which authority resolved it (via).
  • kit.npcs.engineAvailable() + kit.npcs.overlayLivePoses(npcs, lane) — overlay live engine-driven poses onto polled containers; model-only deployments keep the polled positions.
  • kit.worldsim.forecast() — the world engine's current weather front + day phase; track transitions from the type-90 event stream with kit.worldsim.parseWeather.

Session engines (8.8+)

  • kit.matches engine pathengineReady / engineSubmitMove / engineForfeit / engineStatus (server-driven turn order, timeouts, authoritative scoring) and findByProposal (the matchmaking handoff).
  • kit.decks engine pathengineNewTable / engineHand (caller-scoped hidden hands that never replicate) / engineDraw / enginePlay / engineTakeZone / engineTable.
  • kit.instances — open/join/complete/state over the instance engine (per-run seeds, disjoint chunk volumes).
  • kit.directordefineEncounter (admin), startRun, reportKill, reportBossHp, skipWave, runState.
  • kit.matchmakingqueueJoin (party blocks, optional explicit rating), queueLeave, queueStatus, accept, reportResult.
  • kit.economy.orderBook — the escrowed order-book market: depositCoins/depositItems, bid/ask, cancel, book, account, withdraw.
  • kit.leaderboards engine pathengineTop (server-ranked pages), engineRankOf, engineSubmitSelf, engineSeasons.
  • kit.minigames — a thin invoke wrapper for invoke-loop games; denials resolve as {success: false, reason}.
  • kit.quests tutorial sequencingdefineTutorial (admin), tutorial(owner) (ordered steps as locked/active/complete), acceptNextTutorialStep.
  • kit/wire — reserved event types 91/92/93 with parseTurnEvent, parseScoreEvent, parseProposalEvent.

Realtime + live-ops (8.9+)

  • kit.abilities — server-validated realtime casts: cast(abilityId, targetX, targetZ) (your position is your live pose), loadout, book, defineAbility (admin), type-94 parsing.
  • kit.movement — warden reads (observe/flag): violations, config, defineConfig (admin), type-95 parsing.
  • kit.territorypoints (live capture state), factions, admin map CRUD, type-96 parsing.
  • kit.racingdefineCourse, enter, raceStatus, best, ghostPlay, type-97 parsing; plus the possession ball (joinMatch/claim/pass/shoot/matchState).
  • kit.liveops — event windows (scheduler-aware activeWindows), seasons + battle-pass composition, type-98 zone-change parsing.
  • kit.moderation / kit.telemetry — model-first: reports/queue/ mutes; track(name, props) over sampled counters.
  • kit.loot engine pathenginePull/enginePity/engineAudit for pity-timer tables; small weighted tables stay on the model.
  • kit.deploy({ engines }) — blueprints + platform engine templates in one call (computeDeployTemplate under the hood).

Escape hatches

The kit is a convention layer. When a concept outgrows it, drop down to client.gameModel with your own types, functions, and policies — blueprints and hand-authored models coexist in the same app. The underlying model design for each concept is documented in Modeling game concepts. For heavy server-side logic (pathfinding, simulation), graduate to Compute Modules and the engine templates.