Skip to main content

Compute tutorial: zero to a live module

This is the fastest path from nothing to server-side code running on your game's servers — budget 30 minutes end to end, most of it reading. It uses CrowdyJS, the same package your game client already installs, and nothing else.

Everything here is the Compute Modules GraphQL surface underneath. The SDK is a typed shortcut to it, not a separate product: each step names the field it calls, so you can drop to raw GraphQL — or to any other language — at any point.

This tutorial assumes compute is the right tier. For the decision criteria and the recommended hybrid pattern, see Model API vs Compute.

0. Prerequisites (~4 min)

  • Node 20+ and the SDK:
npm install @crowdedkingdoms/crowdyjs@15.4.0

Version 15.0.0 or newer is requiredauth.login arrived in 15.0.0, which was a breaking release. The compute methods below are in every release since. Pin exactly rather than with a caret: a caret never matches a prerelease, so ^15.0.0 silently skips the dev and test builds.

  • An app you administer. Your user needs the org manage_compute permission, on an environment with compute enabled.

  • Your environment's GraphQL origin. Do not copy one out of a tutorial — this page named a dead host for months. Take it from your environment's dashboard, and see below for the per-app URL the platform hands you.

  • Optional, only for compiling locally before you deploy: a Rust toolchain with rustup target add wasm32-wasip1. You do not need it. The platform compiles your source when you deploy.

1. Sign in and get a client (~2 min)

import { createCrowdyClient } from "@crowdedkingdoms/crowdyjs";

const client = createCrowdyClient({
httpUrl: "https://<your environment's origin>/graphql",
wsUrl: "https://<your environment's origin>/graphql",
});

await client.auth.login({ email: "you@example.com", password: process.env.PASSWORD });

const APP_ID = "<your app id>";
const app = await client.portal.mintAppToken(APP_ID);
client.setToken(app.token);

mintAppToken also returns gameApiUrl and gameApiWsUrl — the Game API actually serving your app, which lives in one datacenter. If they are non-null, build your working client with those instead of the origin you started from; you will be talking to the datacenter holding your data rather than to whichever one DNS chose.

2. A live module, with no Rust at all (~3 min)

The platform ships a registry of ready-made engines and serves them from your environment. You do not fetch, build, or vendor them — you name one and it deploys. Ask what is available:

const templates = await client.compute.templates({ appId: APP_ID });
templates.forEach((t) => console.log(t.name, "—", t.description, "| exports:", t.exports));

That is the computeTemplates query. Deploy one:

const mod = await client.compute.deployTemplate({
appId: APP_ID,
templateName: "mob-engine",
moduleName: "my-mobs", // optional; defaults to the template name
});

computeDeployTemplate is one call in place of four: it upserts the module, publishes the template source (deduped by hash), binds the template's triggers, and enables it. Compilation runs asynchronously, so wait for it:

const version = await client.compute.waitForCompile(APP_ID, "my-mobs", { timeoutMs: 180_000 });
console.log(version.compileStatus, version.compiledSizeBytes, "bytes");

You now have server-side code running against your world, and you have written none. The engines are data-driven — behaviour comes from model containers such as MobDef and EncounterDef — so the intended path is to parameterize one rather than fork it. See Compute engines.

3. Watch it and call it (~3 min)

const stats = await client.compute.moduleStats({ appId: APP_ID, windowMinutes: 60 });
const logs = await client.compute.moduleLogs({ appId: APP_ID, moduleName: "my-mobs", limit: 20 });
const runs = await client.compute.moduleRuns({ appId: APP_ID, moduleName: "my-mobs", limit: 20 });

const result = await client.compute.invoke({
appId: APP_ID,
moduleName: "my-mobs",
exportName: "status",
paramsJson: JSON.stringify({}),
});

Those are computeModuleStats, computeModuleLogs, computeModuleRuns and computeInvoke. Polling the first three on a timer is the whole of a "watch" loop; invoke is the synchronous RPC path into a module's exports.

Note that moduleStats is app-wide, not per-module — it takes a look-back window (default 60 minutes, max 1440) and summarises every module's activity together. moduleLogs and moduleRuns are the two that narrow to one module, and moduleName is optional on both.

For a health view across every module at once, including breaker state and fuel, use client.compute.appDiagnostics({ appId: APP_ID }).

4. Your own module (~8 min)

A module is a small Rust crate compiled to WASM. You upload source, as plain strings — there is no local build step, no bundler, and no artefact to produce. Two files are required:

const sourceFiles = {
"Cargo.toml": `
[package]
name = "my-module"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
crowdy-compute-sdk = "0.1.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
`.trim(),
"src/lib.rs": "/* your module */",
};

Then the four-step lifecycle the template call did for you:

await client.compute.upsertModule({ appId: APP_ID, name: "my-module", description: "..." });

await client.compute.deployVersion({
appId: APP_ID,
moduleName: "my-module",
sourceFiles, // the SDK JSON-encodes this into sourceFilesJson
sdkVersion: "0.1.5", // must match your Cargo.toml — see below
abiVersion: 0,
});

const v = await client.compute.waitForCompile(APP_ID, "my-module", { timeoutMs: 180_000 });
if (v.compileStatus !== "succeeded") throw new Error(v.compileLog);

await client.compute.upsertTrigger({
appId: APP_ID, moduleName: "my-module", triggerType: "tick", tickHz: 2,
});
await client.compute.setModuleEnabled({ appId: APP_ID, name: "my-module", enabled: true });

Pass sdkVersion explicitly and keep it equal to your Cargo.toml. The SDK will default it from a constant baked into whichever CrowdyJS version you installed, and that constant can lag the platform — 15.4.0 defaults to 0.1.3 while 0.1.5 is current. Supported values are 0.0.1 and 0.1.00.1.5, with ABI 0; there is no query that lists them, so treat the Compute Modules page as the reference.

Redeploying identical source is wasted work, so check the hash first:

const [latest] = await client.compute.moduleVersions({ appId: APP_ID, moduleName: "my-module", limit: 1 });
// latest.sourceHash — compare before calling deployVersion again

The constraints your source must satisfy — the file layout, the size caps, the dependency allowlist, and the ban on build scripts — are all listed under writing a module. They are enforced at deploy time, so a violation comes back as a BadRequestException naming the field rather than as a compile error.

5. Tour the registry (~10 min of reading)

computeTemplates is the catalogue, and it is worth reading through once because most games find their shape in it rather than starting from an empty crate. Each entry lists its exports — the names you can invoke.

The registry covers NPC and mob simulation, world simulation, match and matchmaking flow, decks and boards, instances and directors, markets, minigames, abilities, territory, racing, possession, movement validation, and live-ops scheduling.

Because they are parameterized by model containers, two modules can run the same engine with different data side by side — that is what the moduleName override in step 2 is for.

Where to go next

  • Compute engines — the registry in depth, and which containers parameterize each engine.
  • Compute Modules — concepts, the full lifecycle, limits, and billing.
  • Compute host API — everything a module can call: world reads and writes, model invocation, actors, events, presence, and randomness.