Compute Modules
Game Models and Autonomous Processes put your rules and NPCs on the server with a deliberately loop-free expression language — great for declarative rules, but you cannot write a pathfinder, a market simulation, or a full world tick in it. Compute Modules are the next level: you write Rust, deploy the source through the Game API, and the platform compiles it to WebAssembly and runs it server-side, sandboxed, with:
- Real computation — loops, pathfinding, flocking, simulation. Anything Rust can express, bounded only by your compute budget.
- Full access to your app's data — a typed host API over your game-model containers, user/avatar/grid state, chunks, voxels, and actors. Always scoped to your own app; a module can never see another tenant.
- Replication egress — modules push spatial and channel notifications to
connected players through the same realtime path as
model-driven notifications. Clients
need no new subscription: module traffic arrives as ordinary
udpNotificationsevents.
If you are deciding whether a rule belongs in a model function/automation or in WASM, start with Model API vs Compute.
Runaway code is impossible by construction: every call runs under a deterministic fuel budget (an instruction-count meter compiled into your module) plus a wall-clock watchdog, layered circuit breakers, and per-app policy ceilings. Compute time and module-driven replication traffic are metered and billed to your app (see Billing).
All operations live on the Game API GraphQL endpoint. Authoring requires
the org manage_compute permission; monitoring requires
view_compute_diagnostics (see Permissions).
This page describes studio modules: trusted app-scoped code authored by org members. If players author or install code, use Player code and owned grids: its source is stored separately, server execution is restricted to one owned grid and runs as that grid owner, and write/run permissions plus app admission are checked independently.
When to use which
| Game Models + Automations | Compute Modules | |
|---|---|---|
| Language | Loop-free expression DSL | Rust → WebAssembly |
| Best for | Declarative rules, invoke policies, simple NPC turns | Pathfinding, world simulation, server-authoritative AI, heavy logic |
| Data access | Model containers/properties | Broader typed host API (model data + world + app state) |
| Replication egress | Declared notifications effects | Imperative emit_spatial / emit_channel calls |
| Billing metric | automation_compute_units | wasm_compute_units (+ egress metrics) |
They coexist: models remain the right tool for declarative rules and
player-invoked functions, and modules can react to model events
(function_invoked, property_changed, container_created), so a hybrid
design — durable rules in the model, live simulation in a module — is normal.
Writing a module
Modules are ordinary Rust cdylib crates built on the crowdy-compute-sdk
crate, which hides the WebAssembly ABI behind safe Rust. A complete minimal
module — this is also the starter template the management UI offers:
# Cargo.toml
[package]
name = "my-module"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
crowdy-compute-sdk = "0.1.0"
// src/lib.rs
use std::sync::atomic::{AtomicU64, Ordering};
static TICKS: AtomicU64 = AtomicU64::new(0);
fn on_init() {
crowdy_compute_sdk::log(1, "module init");
}
fn on_tick(_dt_ms: u32) {
let n = TICKS.fetch_add(1, Ordering::Relaxed) + 1;
crowdy_compute_sdk::state_set(&n.to_le_bytes());
}
fn on_invoke(input: &[u8]) -> Vec<u8> {
input.to_vec()
}
crowdy_compute_sdk::register_module!(init: on_init, tick: on_tick, invoke: on_invoke);
register_module! wires your functions to the entry points the platform calls:
| Entry point | Called when |
|---|---|
init() | The module is loaded on a server (fresh instance). |
tick(dt_ms) | On every tick of a tick trigger; dt_ms is the time since the previous tick. |
handle_invoke(input) -> Vec<u8> | A client or tool calls computeInvoke. Input/output are raw bytes (JSON by convention). |
on_event(input) | (Optional — 4-arg register_module! form with event:.) A subscribed model or compute event fires. |
Inside any entry point you can call the host API — data reads/writes, replication emits, durable state, logging — documented in Compute host API.
State: what survives and what doesn't
- In-memory Rust state (statics, heap) persists between ticks only while
the module instance stays loaded on the server currently running it. A
redeploy, an idle unload, a failure, or the ticks moving to another server
starts a fresh instance (
initruns again). - Durable state must go through the platform: the module's own state blob
(
state_get/state_set, up to 256 KiB) or the host data API (containers, properties, app state).
Treat in-memory state as a cache. The starter module above is correct because it persists its counter to the state blob every tick.
computeInvoke calls and event deliveries may run on a server that does
not hold your module's tick lease, on a separate short-lived instance
whose memory starts from the last persisted snapshot. State-blob writes are
revision-guarded: the tick-lease holder always wins, and a non-lease
instance's state_set is dropped (with a warning in
computeModuleLogs) if the lease holder persisted first. Consequence: never
keep referee-critical records (grants, receipts, scores) only in the state
blob — commit them to Model containers (model_invoke), which are
transactional and fleet-wide. The state blob is for rebuildable simulation
caches.
Source rules (validated at deploy time)
Deploying a ready-made engine? Skip the source upload entirely:
computeDeployTemplate(appId, templateName)deploys any engine from the template registry by name (list them withcomputeTemplates).
computeDeployVersion validates the upload before anything compiles:
- Files — a JSON map of relative paths to contents.
Cargo.tomlandsrc/lib.rsare required; only.rsfiles undersrc/(plusCargo.toml) are allowed. At most 32 files, 256 KiB per file, 1 MiB total. - Dependencies — only the platform allowlist:
crowdy-compute-sdk, the five game-kit crates (crowdy-game-kit-core,-ai,-sim,-play,-econ),serde,serde_jsonandrand. Compiles run offline against a vendored registry, so these are resolved for you and are not fetched from crates.io — arbitrary crates.io dependencies are rejected at deploy time, not at compile time. - No build-time code —
build.rs, a[package] build = ...key,[build-dependencies], and path/git dependencies are all rejected. Only[package],[lib], and[dependencies]sections are allowed. Do not upload aCargo.lock. - Versions —
sdkVersionandabiVersionmust be platform-supported (currently SDK0.1.0–0.1.5, ABI0;0.1.1addsvoxels_list,0.1.2adds batched/radius reads,0.1.4adds atomic world+model commits viamodel_invoke_with_world,0.1.5adds filtered/paged container lists viacontainers_list_where, and0.1.3adds transactionalmodel_invoke+ explicitly owned container creation). The SDK pins the ABI for you. - Module names are crate-shaped: lowercase letter first, then lowercase
letters, digits,
-,_(max 128 chars).
There is no network, filesystem, or environment access from a module. Clocks
(now_ms) and randomness (random_bytes) come from the host.
Deploying and enabling
The full authoring flow (also available point-and-click in the management UI's Compute tab on your app dashboard):
1. Create the module (starts disabled):
mutation {
computeUpsertModule(input: {
appId: "1",
name: "world-sim",
description: "Crop growth + weather"
}) { moduleId name enabled }
}
2. Upload a source version:
mutation {
computeDeployVersion(input: {
appId: "1",
moduleName: "world-sim",
sourceFilesJson: "{\"Cargo.toml\":\"...\",\"src/lib.rs\":\"...\"}",
sdkVersion: "0.1.0",
abiVersion: 0
}) { versionId versionNo compileStatus }
}
The version parks as compileStatus: "pending", then a game server compiles it
(compiling → succeeded or failed). Poll:
query {
computeModuleVersions(appId: "1", moduleName: "world-sim", limit: 1) {
versionNo compileStatus compileLog compiledSizeBytes
}
}
On failure, compileLog carries the Rust compiler output — fix and deploy a new
version (versions are immutable; each deploy is a new versionNo).
3. Bind a trigger (see Triggers):
mutation {
computeUpsertTrigger(input: {
appId: "1", moduleName: "world-sim",
triggerType: "tick", tickHz: 2
}) { triggerId triggerType tickHz }
}
4. Enable it:
mutation {
computeSetModuleEnabled(appId: "1", name: "world-sim", enabled: true) {
name enabled circuitState
}
}
Enabling requires a successfully compiled deployed version and resets the failure circuit.
Triggers
A module runs only when a trigger fires. Bind any mix with
computeUpsertTrigger (triggerType: tick | event | invoke):
tick — a fixed-rate loop
tickHz sets the rate (must be within your policy's maxTickHz, default 10).
Exactly one server runs a module's ticks at a time, even with multiple API
replicas — your tick never overlaps or double-fires.
event — react to model or compute activity
onEvent is one of:
function_invoked— a model function ran (filter withfunctionName),property_changed— a direct property write (filter withcontainerTypeName/propertyKey); the delivered payload carries theoldValue/newValuedelta so your module doesn't spend a data op re-reading the container it was just told about,container_created— (filter withcontainerTypeName),compute_event— another module (or this one) calledemit_event(filter witheventName),player_left— an actor stopped being present in the app (no filters). The delivered JSON carriesevent: "player_left"and aneventParamsobject withactor_uuid,user_id,chunk_x/chunk_y/chunk_z,last_seen_at,left_reason(presence_removed|lease_expired) andremaining_player_count. It fires once per leaver including the last one (the app is otherwise idle at zero players), so a module can persist or wind down; see Autonomous processes → Players leaving.
debounceMs coalesces bursts. Event chains are bounded: a cascade of modules
triggering each other via emit_event is cut off at a platform depth limit.
Automations can also drive modules directly: a
compute_invoke automation
binds a schedule/event/manual automation to one of your module's invoke
exports on the trusted server path — the natural home for cron-shaped
compute work.
invoke — a callable export
An invoke trigger publishes one of your module's functions to computeInvoke:
mutation {
computeUpsertTrigger(input: {
appId: "1", moduleName: "world-sim",
triggerType: "invoke", exportName: "get_forecast",
invokePolicyJson: "{\"type\":\"anyone\"}"
}) { triggerId exportName }
}
invokePolicyJson is the same JSON authority tree used by
game-model invoke policies.
An invoke trigger may also declare a typed contract (contractJson):
{
"description": "Spend gold and place the block atomically",
"params": {
"amount": { "type": "int", "required": true },
"targetId": { "type": "string" }
},
"result": { "placed": { "type": "bool" } }
}
Types are int | float | string | bool | object | array. Declared params are
validated on computeInvoke before the sandbox runs — a shape mistake is
a structured BAD_REQUEST naming the violation, not a runtime guest error.
Undeclared params pass through untouched; result is documentation/codegen
only. Contracts surface as contractJson on computeModuleTriggers, which is
the declaration to generate TypeScript param/result interfaces and typed
invoke wrappers from.
When it is null, only manage_compute holders may invoke (the safe
default). Leaves that need a model container context (e.g. owner_of_self)
fail closed here — prefer caller-based leaves (anyone, user_in_list,
parameter checks).
Callers then get a synchronous RPC:
mutation {
computeInvoke(appId: "1", moduleName: "world-sim",
exportName: "get_forecast", paramsJson: "{\"day\":3}") {
resultJson resultBase64 fuelUsed durationUs
}
}
The module's handle_invoke receives
{"export": "...", "params": {...}, "callerUserId": "..."} as JSON bytes and
returns bytes; resultJson is populated when they parse as JSON. Unlike
fire-and-forget spatial sends, an invoke failure (trap, fuel exhaustion,
policy denial) is returned to the caller as a GraphQL error.
Limits and circuit breakers
Modules are bounded by layered budgets so a bug degrades gracefully:
- Fuel — a deterministic compute meter compiled into your module. Every
entry call gets a budget (
fuelPerTick/fuelPerInvoke); exhaustion stops the call immediately. An infinite loop cannot run away — it traps. - Watchdog — a wall-clock deadline per call (
maxRunMs). Breach terminates the module instance and fails the run. - Per-call caps — guest memory (
maxMemoryMb), host data operations per call (maxDbOpsPerTick), replication payloads ≤ 1024 bytes. - Egress budget —
maxEgressMsgsPerMin/maxEgressBytesPerMinper module. Over-budget emits fail with a structuredegress_budgeterror the module can handle; they are never silently dropped. - Failure circuit —
failureThresholdconsecutive failures open the module's circuit forcooldownMs(then a half-open probe; one successful run closes it). Re-enable manually withcomputeSetModuleEnabled. - Budget gate — if your app is over its spend cap or denied, all modules pause automatically, exactly like automations.
Per-app ceilings live in the compute policy (computeModulePolicy /
computeSetPolicy) and are clamped to platform maxima. The platform maxima
are operator-managed and can change without notice (a computeSetPolicy
value above one fails with ... exceeds the platform ceiling (N) naming the
current ceiling):
| Policy field | Default | Meaning |
|---|---|---|
enabled | true | App-wide kill switch |
maxModules | 10 | Modules the app may define |
maxTickHz | 10 | Highest tick rate any module may request |
fuelPerTick | 200,000,000 | Fuel budget per tick call |
fuelPerInvoke | 1,000,000,000 | Fuel budget per invoke/event call |
maxMemoryMb | 64 | Guest memory cap |
maxRunMs | 200 | Watchdog per entry call |
maxDbOpsPerTick | 50 | Host data ops per entry call |
maxEgressMsgsPerMin | 600 | Replication messages per module per minute |
maxEgressBytesPerMin | 1,000,000 | Replication bytes per module per minute |
failureThreshold | 5 | Consecutive failures that open the circuit |
cooldownMs | 60,000 | Open-circuit cooldown |
Phase 10 validated these defaults. On the reference builder, a host db-op cost ~1.4 ms, a radius scan ~0.4 ms, and a spatial emit ~0.5 ms; 50 db-heavy modules at 5 Hz (2,500 db-ops/s) held full cadence but used ~80% of one game-api process. Treat ceilings as emergency guardrails — design normal engines far below them.
Activation: when your module actually runs
Modules load lazily: they run while your app has a player present and unload after an idle window (about 5 minutes) once the last one leaves. Presence is measured across the whole fleet, so it does not matter which server your players connected to.
Nothing runs for an app with nobody in it. That applies to compute modules,
scheduled automations and timers alike. alwaysOn used to opt a module out of
this and is retired as of 2026-09-01: computeUpsertModule refuses
alwaysOn: true, and the field always reads false.
If your world needs to advance while it is empty — crops growing, territory decaying, queues draining — compute the elapsed time on the first tick after a player returns rather than ticking through the night. You get the same result for a fraction of the cost, and it stays correct if your game is quiet for a week rather than an hour.
Scheduled automations follow the same rule, and a schedule that comes due while your app is empty is skipped, not queued: its next run is set from the time it was skipped, so nobody logging in triggers a burst of catch-up runs. Timers are the exception — a one-shot timer waits in place and fires on the first tick after somebody returns, because unlike a recurring schedule it has no next occurrence to fall back on.
Billing
Module execution never rides a player request, so it is metered explicitly, per minute, and billed against your app on shared environments as the usage arrives (see Shared environment):
| Metric | Measures |
|---|---|
wasm_compute_units | Compute consumed, derived from CPU time and fuel — 1 unit ≈ 1 ms of reference CPU. Draws on the app's pooled monthly CPU quota (20 CPU-hours across resolvers, automations and modules) |
wasm_egress_bytes | Replication bytes emitted by modules, billed through the app's monthly egress aggregate |
wasm_db_rows_written, wasm_write_bytes | Rows and bytes your module writes; no allowance, priced from the first unit |
durable_storage_byte_hours | Data your app keeps, as GB held over time; 1 GB-month free |
Messages are counted for your usage view but not priced: a message is paid
for by its bytes (the wasm_egress_msgs line was retired on 2026-09-06). The
Phase 10 sweep calibrated the fuel equivalent to 22M fuel/unit against the full
kit-engine fleet and live BWF usage. If a spend cap or balance is hit, the
budget gate pauses your modules until resolved.
Permissions
Compute uses two dedicated org permission keys (grantable per role, like
manage_apps):
| Key | Grants |
|---|---|
manage_compute | Author, deploy, enable/disable, delete, triggers, policy — all 7 compute mutations |
view_compute_diagnostics | All 9 monitoring queries (modules, versions, triggers, policy, runs, stats, logs, diagnostics) |
computeInvoke is gated per-export by its trigger's invokePolicyJson
(default: manage_compute holders only). Org owners hold both keys by
default.
Monitoring
# Live config + circuit state
query { computeModules(appId: "1") { name enabled circuitState consecutiveFailures lastError } }
# Run history (newest first; filter by module / outcome)
query { computeModuleRuns(appId: "1", moduleName: "world-sim", success: false) {
startedAt triggerSource entry durationUs fuelUsed dbReads dbWrites
egressMsgs egressBytes errorMessage circuitAction flowId
} }
# Run rows record every demand-driven run (init, invoke, event — success or
# failure, each carrying its flowId), every failure, and circuit probes.
# Healthy high-frequency ticks are aggregated into per-minute usage (the
# billing metrics) instead of one row per tick, so a runs list with only
# invokes plus growing usage is a healthy ticking module.
#
# flowId stitches cross-engine flows: the same id appears on the
# gameModelEvents rows and gameModelAutomationRuns caused by one entry call
# (a computeInvoke, an automation run, or a player invoke), across
# model_invoke, event triggers and emit_event cascades. One query returns
# the whole correlated timeline: gameModelFlow(appId, flowId) — see
# "Tracing a flow" under Game Models (game-models#tracing-a-flow).
# Aggregate activity over a window
query { computeModuleStats(appId: "1", windowMinutes: 60) {
totalRuns failedRuns failureRatePct totalFuelUsed totalEgressMsgs avgDurationUs
byModule { moduleName runs failures fuelUsed circuitState }
} }
# Module log lines (from the guest log() calls)
query { computeModuleLogs(appId: "1", moduleName: "world-sim", limit: 50) {
ts level message triggerSource
} }
# App-wide compute footprint
query { computeAppDiagnostics(appId: "1") {
moduleCount enabledModuleCount versionCount triggerCount
runs24h failedRuns24h fuelUsed24h
topModules { moduleName runs failures }
} }
Worked example: zero to a ticking module
Everything below uses plain GraphQL against the Game API endpoint with a
bearer token whose user holds manage_compute on the app's org.
- Create —
computeUpsertModule(input: {appId: "1", name: "counter"}). Note that a module only loads while your app has a player present (see Activation), and a fresh test app has none — so connect a client before expecting ticks. Any client counts, including a headless one; the point is that somebody is in the world. - Deploy —
computeDeployVersionwith the starterCargo.toml+src/lib.rsshown above,sdkVersion: "0.1.0",abiVersion: 0. - Wait for the compile — poll
computeModuleVersionsuntilcompileStatusissucceeded(a cold compile takes a few seconds; repeat deploys of the same source hit the artifact cache and return almost immediately). - Trigger —
computeUpsertTriggerwithtriggerType: "tick",tickHz: 2. - Enable —
computeSetModuleEnabled(appId: "1", name: "counter", enabled: true). - Observe — within about half a minute the module loads:
computeModuleRuns(appId: "1")shows thetriggerSource: "init"run,computeModuleLogsshows themodule initline, andcomputeModuleStats/computeAppDiagnosticscount it. The ticks themselves are aggregated into your per-minute usage (thewasm_compute_unitsbilling metric — at 2 Hz expect ~120 ticks per minute), and the module persists its tick count durably viastate_set, so it survives reloads. Any tick failure would surface as asuccess: falserun row with the error message.
From here, add host API calls — read a container, move an NPC, emit a spatial event — using the Compute host API reference.
Related
- Compute tutorial — zero to a live module in under 30 minutes with CrowdyJS, starting from a ready-made engine.
- Compute engines — ready-made, data-driven
engine templates (NPCs/pets, mobs with refereed combat, weather/farming)
built on the
crowdy-game-kitcrates. Start here before writing a simulation from scratch. - Compute host API — every function a module can call, with limits.
- Game models · Autonomous processes · Model-driven notifications
- GraphQL reference — full signatures
for every
compute*operation.