Skip to main content

Port a compute module

This page walks through a real port: Titan Assault's two compute modules, ta-mobs (an always-on module ticking at 4 Hz: mobs wander and chase, hostiles hit nearby players, chests reset at dawn) and ta-actions (invoke exports that referee hits, loot and chests), moved to one hub. The client and its wire didn't change; only where the calls go did.

What maps to what​

Compute moduleck-exec
An always-on module with tickHz: 4A hub that sets ctx.timer_every("tick", 250) in spawn; timers are kept in its snapshot
Game Model containers, read and written on every tick and invokeThe hub's own state, snapshotted every persist_every_ms
A seed script that creates the containersThe world compiled into the module, or the type's seed_b64 in the manifest, which spawn receives
invoke exports (paramsJson in, resultJson out)Hub endpoints: call.decode() in, encode(&value) out, as MessagePack
caller_user_id, a stringcall.player()?, the user id the platform set
kit::wire::emit_actor(uuid, pose, suffix, distance, 16)ctx.emit_actor(&actor_id(uuid), chunk, distance, &bytes), with the same bytes
kit::events::emit_server_event(chunk, uuid, type, json, distance)ctx.emit_spatial(chunk, distance, &actor_id(uuid), Spatial::Event { event_type, state })
compute.invoke({ appId, moduleName, exportName, paramsJson })exec.connect(appId, { nodeType, key }), then connection.call(nodeType, key, method, params)

The hub​

A tick and an endpoint, from the port (the simulation is a plain Rust struct, so it tests natively with cargo test):

use ckx_sdk::prelude::*;
use serde_json::{Value, json};

impl Hub for Titan {
fn spawn(ctx: &Ctx, _seed: &[u8]) -> Result<Self> {
// One world: a second key would show the same mobs, by the same ids, somewhere else.
if ctx.key != "main" {
return Err(Error::new("there is one map, `main`"));
}
ctx.timer_every("tick", 250)?;
Ok(Titan::new(ctx.now_ms()))
}

fn on_timer(&mut self, ctx: &Ctx, _timer: &str) -> Result<()> {
// Move everything one step, then show it to the players nearby.
self.tick(ctx.now_ms(), &mut Realtime(ctx));
Ok(())
}

fn handle(&mut self, ctx: &Ctx, call: Call<'_>) -> Result<Vec<u8>> {
let params: Value = call.decode().unwrap_or(Value::Null);
let result = match call.method {
"attack_mob" => {
call.player()?;
self.attack_mob(&params) // { success: true, health, killed } or { success: false, reason }
}
// …
other => return Err(Error::unknown_method(other)),
};
encode(&result)
}
// spawn, load and persist: encode and decode the struct.
}

Realtime(ctx) sends what the legacy host sent. A legacy actor emit addressed the actor by the 32 ASCII characters of its uuid, which is what actor_id(uuid) produces, and its payload was the kit pose followed by any suffix; a server event was its type as a little-endian u16 followed by the JSON. With the same bytes, clients decode the hub's actors and events with the parsers they already have.

The legacy { success, reason } result can stay as the endpoint's value. A refusal the game expects, such as a dead mob or an opened chest, is then a normal reply, and a thrown CrowdyExecError always means the platform didn't answer.

The client​

One connection per page, placed on the host running the hub:

let connection: Promise<ExecConnection> | null = null;

export async function titanCall<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
connection ??= client.exec
.connect(appId, { nodeType: "map", key: "main" })
.catch((error) => {
connection = null;
throw error;
});
return (await connection).call<T>("map", "main", method, params);
}

// was: compute.invoke({ appId, moduleName: "ta-actions", exportName: "attack_mob", paramsJson })
const outcome = await titanCall<{ success?: boolean; health?: number; killed?: boolean }>(
"attack_mob",
{ containerId, amount: 25 },
);

Things to watch​

  • Keys are instances. A client may call any key of a client type, and each key it names is a new instance. A hub that must be the only one refuses other keys in spawn.
  • Deploys and running hubs. A running hub keeps the version it started with until it stops, and a hub whose timer is pending doesn't go idle while players are in the app. To move it at once, switch its type off and on (execSetEnabled, see operations): it persists, stops, and starts again on the new version from its snapshot.
  • Changing the world. A hub restored from a snapshot may meet a world definition that changed since. Titan Assault's load adds what is new, drops what is gone, and takes fixed properties (kind, maximum HP) from the definition while keeping positions and damage.
  • Trust. Positions that clients report, such as the presence heartbeat, are still claims. The hub keys them by call.player(): a player holds at most a few actors and never another player's.

What changed under load​

On test on 2026-09-23, one player of Titan Assault was refused about half of their computeInvoke calls. The module answered every call that reached it, in about 40 ms, but the calls waited for a database connection in the game API first. On dev the same pattern through the hub was answered in full:

ReplayCallsRefusedp99
1 player: a heartbeat a second, an attack every 800 ms, chests143 in 60 s076 ms
25 players, the same cadence3,547 at 59 a second082 ms
50 players attacking every 250 ms7,560 at 252 a second082 ms

Each call includes a 75 ms network round trip from the test machine; the hub's own time per call is well under a millisecond.