Skip to content

JavaScript runtime reference

This is a trusted-server-only capability for operator-owned game code, not client code. start(url, opts?) schedules Rust-owned outbound HTTP and returns an opaque runtime-local string handle immediately; it does not return a Promise and never waits for network I/O. Keep it as a string—do not coerce it to a JavaScript number, so the underlying u64 never loses precision. url must be an http or https DNS-hostname URL. opts may contain method (string, default "GET"), headers (a string-to-string object), and body (string). Rust policy rejections throw their stable code without allocating a handle. Local JavaScript argument or option validation can instead throw a JavaScript-visible validation message; it is not an error_code contract.

poll(handle) never blocks and returns one of these objects:

state Other keys Meaning
"pending" Still running; poll from a later tick.
"success" status (number), body (Uint8Array) Completed HTTP response.
"error" error_code (string) Stable, redacted code for a network/runtime request result.
"timeout" The five-second deadline elapsed.
"cancelled" The request was cancelled.

cancel(handle) returns the same object. It aborts a pending request and is idempotent for any already-terminal known handle; unknown, malformed, evicted, or reload-invalidated handles throw an error. Terminal handles remain pollable only until the bounded per-runtime handle table evicts one; reload and shutdown cancel and forget all handles. On success, body is a Uint8Array containing the exact response bytes (including 0x00 and non-UTF-8 bytes), not a JSON array or decoded text. fetch, start, poll, and cancel all throw Error("interceptor_forbidden") inside realtime interceptors.

The error_code contract is stable and deliberately redacted: never parse a human-readable exception message. The codes are request_too_large, response_too_large, invalid_method, invalid_header, headers_too_large, authority_header_forbidden, capability_disabled, invalid_scheme, invalid_url, url_credentials_forbidden, ip_literal_forbidden, host_forbidden, port_forbidden, private_address_forbidden, resolution_failed, concurrent_limit_reached, rate_limit_reached, handle_limit_reached, unknown_handle, and request_failed. Policy and handle failures throw their code directly; a completed request with state === "error" returns its code in error_code.

fetch(url, opts?) remains available in the trusted runtime for backward compatibility. It accepts the same options and policy, but is synchronous and returns { status, body: Uint8Array }; it is not replaced by start. New gameplay paths should use start + poll to avoid blocking a tick or handler.

let pendingInventory = null;
citadel.on_tick((_dt) => {
if (pendingInventory === null) {
pendingInventory = citadel.http.start("https://inventory.example/v1/stock", {
method: "GET",
headers: { authorization: `Bearer ${token}` },
});
return; // start only schedules work
}
const result = citadel.http.poll(pendingInventory);
if (result.state === "pending") return;
pendingInventory = null;
if (result.state === "success" && result.status === 200) {
consumeInventory(result.body);
} else if (result.state === "error") {
citadel.log("warn", `inventory failed: ${result.error_code}`);
} else if (result.state === "timeout" || result.state === "cancelled") {
citadel.log("warn", "inventory request did not complete");
}
});

There is no native await, Promise, callback, or automatic fetch rewrite: retain the runtime-local handle yourself and poll it from a later tick or handler. A handle cannot survive a successful reload or runtime shutdown; those lifecycle transitions cancel and forget outstanding work, so callers must discard stored handles and start a fresh request when the replacement runtime is ready.

The same operator policy applies to fetch, start, poll, and cancel: hostname/port allowlists, public-address and DNS-rebinding checks, proxy and redirect denial, a 64 KiB request body cap, 1 MiB response cap, 64 headers/ 16 KiB aggregate header cap, five-second timeout, and configured concurrency and rate quotas. See outbound HTTP configuration.

JavaScript exposes trusted-tier user-owned storage: citadel.storage_read(user, collection, key), citadel.storage_write(user, collection, key, valueJson, expectedVersion, readPermission, writePermission), and citadel.storage_delete(user, collection, key, expectedVersion).

Reads return null when absent or an object with value_json, version, read_permission, and write_permission. valueJson must encode a JSON object. Omit expectedVersion for an upsert, use "" for create-only, or pass a returned version for compare-and-set. Permission codes are read 0|1|2 and write 0|1. Errors are storage validation: ..., storage conflict, or storage operation failed.

citadel.storage_index_query(indexName, filtersJson, limit = 50)

Section titled “citadel.storage_index_query(indexName, filtersJson, limit = 50)”

Runs a bounded equality query against an operator-declared storage index.

Parameter Type Meaning
indexName string A configured [[storage.indexes]] name.
filtersJson string A JSON object. Keys must be declared index fields and values must be strings, numbers, or booleans.
limit number Result cap from 1 through 100; defaults to 50.

Returns: an identity-ordered array. Each item has user_id (null for system objects), collection, key, value_json, version, read_permission, and write_permission.

Errors: unknown indexes, invalid JSON/object shape, undeclared fields, non-scalar values, and invalid limits throw an error beginning storage validation:. Backend failures throw storage operation failed.

citadel.on_rpc("find_players_at_score", (ctx, body) => {
const players = citadel.storage_index_query(
"profiles_by_score", '{"score":1200}', 25);
return players.length ? players[0].user_id : "none";
});

Indexes are static operator configuration; scripts cannot create indexes or send arbitrary database queries. See storage indexes in configuration.

citadel.register_storage_index_filter(indexName, callback)

Section titled “citadel.register_storage_index_filter(indexName, callback)”

Registers one write-time filter for an operator-configured index. Call it once during script initialization.

Parameter Type Meaning
indexName string A configured [[storage.indexes]] name.
callback function Receives a candidate object; returns exactly true to include or false to exclude.

The object has index_name, user_id, collection, key, value_json, expected_version, read_permission, and write_permission. It runs only before a matching citadel.storage_write. false removes previous membership without deleting the object; a throw, deadline, or non-boolean return rejects the write and preserves the prior object/membership state. Citadel does not retry callbacks automatically; the script explicitly retries a write only when repeating its callback side effects is safe.

Returns: the callback.

citadel.register_storage_index_filter("profiles_by_score", (candidate) =>
candidate.key !== "draft" && candidate.value_json.includes('"published":true'));

Citadel can embed QuickJS in the node process and route realtime traffic to game/main.js through the same Runtime trait used by Lua and Python. JavaScript support is compiled only when the server is built with --features runtime-js; default builds do not compile or link QuickJS.

For [runtime] config keys, see Configuration reference. This page documents the JavaScript-visible citadel global.

When a participant belongs to a room, its on_message handler receives the numeric ctx.room_id. Any citadel.broadcast emitted by that invocation is delivered only to that room’s current members; direct citadel.send remains explicitly targeted. The regular JavaScript onTick(dt) remains the server-wide tick in this release. Use ctx.room_id to key per-match message state.

[runtime]
enabled = true
language = "js"
adapter = "embedded"
tier = "trusted"
scripts_dir = "./game"
deadline_ms = 100
hot_reload = true

Run a JavaScript-enabled build:

Terminal window
cargo run --features runtime-js

Use language = "javascript" if you prefer the long name. The entrypoint is always main.js.

citadel.on_message(1, (ctx, body) => {
citadel.log(`echo from ${ctx.sender_id}`);
citadel.broadcast(1, body);
});
citadel.on_rpc("ping", => citadel.Reply.ok("pong"));

JavaScript registration is imperative:

function handle(ctx, body) {
citadel.broadcast(2, body);
}
citadel.on_message(2, handle);

main.js and local game files support native static ESM import and export. Citadel resolves module specifiers from [runtime] scripts_dir (normally game/), not from the server’s working directory or a Node package registry.

game/
├── main.js
└── systems/
└── combat.js
game/main.js
import { damage } from "./systems/combat.js";
citadel.on_message(1, (ctx) => {
citadel.broadcast(2, String(damage(ctx.sender)), false);
});
game/systems/combat.js
export function damage(sender) {
return Number(sender % 100n) + 10;
}

Only /-separated relative .js specifiers beginning with ./ or ../ are accepted. A ../ import may move to a parent game subdirectory but cannot escape the canonical scripts root. Absolute paths, backslashes, bare package names ("lodash", "std", "node:fs"), import attributes, CommonJS require, native .dll/.so modules, npm resolution, and TypeScript remain unsupported. ESM modules execute in strict mode; declare variables explicitly.

The QuickJS module cache evaluates each module once per VM and implements the normal ESM cycle semantics. For an end-to-end layout covering all runtimes, see organize multi-file game logic.


Register a handler for inbound realtime messages of a wire kind.

citadel.on_message(kind, handler)
Parameter Type Required Meaning
kind number (u16) yes Wire message kind. Re-registering replaces the previous handler.
handler (ctx, body: Uint8Array) => void yes Handler to store.

Returns: the handler function.

Errors: registration validates that the handler is a function. Exceptions or deadlines inside the handler are isolated server-side and return no outbound commands.

citadel.on_message(1, (_ctx, body) => {
citadel.broadcast(1, body, false);
});

citadel.before_realtime / citadel.after_realtime

Section titled “citadel.before_realtime / citadel.after_realtime”

Observe the post-handshake realtime pipeline around every eligible inbound envelope. Authentication frames are never exposed.

citadel.before_realtime((ctx, body) => true);
citadel.after_realtime((ctx, body) => {});

before_realtime runs before Citadel routes game messages, RPCs, rooms, replication, transform, and networked-actor traffic. Return false to veto; return true, null, or undefined to continue. ctx has sender, user_id, room_id, kind, and a copied Uint8Array body (the second argument has the same bytes). Mutating either array never changes the envelope. An exception, invalid return, deadline, or panic fails closed and vetoes. Both interception hooks are restricted to observation and logging: domain, storage, and outbound HTTP APIs are unavailable while either hook runs.

after_realtime runs once after synchronous routing, including a veto. Its ctx adds dropped and delivered, the number of local outbound deliveries queued by that call. It is observer-only: returns and attempted broadcast or send commands are discarded; domain, storage, and outbound HTTP APIs are unavailable; failures are isolated.

citadel.before_realtime((ctx) => ctx.kind !== 77);
citadel.after_realtime((ctx) => {
citadel.log(`kind=${ctx.kind} delivered=${ctx.delivered}`);
});

Register a named request/response RPC handler.

citadel.on_rpc(method, handler)
Parameter Type Required Meaning
method string yes RPC method name.
handler (ctx, body: Uint8Array) => Reply | Uint8Array | string yes Handler to store.

Returns: the handler function.

Errors: an unknown method returns RpcOutcome::Err. Handler exceptions, deadline interrupts, and invalid replies are logged and returned as a short RPC error. Outbound commands attempted by an RPC handler are discarded.

citadel.on_rpc("profile.get", (ctx) => {
if (!ctx.user_id) {
return citadel.Reply.err("authentication required");
}
return citadel.Reply.ok("{}");
});

Create explicit RPC responses.

citadel.Reply.ok(body = new Uint8Array)
citadel.Reply.err(message)
Function Parameters Meaning
Reply.ok Uint8Array | ArrayBuffer | Array<number> | string Successful RPC body. Strings are UTF-8 encoded.
Reply.err string Short error returned to the caller.

RPC handlers may also return bytes or strings directly; direct returns are treated as successful replies.


Register the lifecycle handler run when a participant connects.

citadel.on_join(handler)
Parameter Type Required Meaning
handler (ctx) => void yes Only one join handler exists at a time.

Errors are isolated like on_message; the join itself is not blocked by a script failure.

citadel.on_join((ctx) => {
citadel.send(ctx.sender, 1, "welcome");
});

Register the lifecycle handler run when a participant disconnects.

citadel.on_leave(handler)

The signature and failure behavior match on_join.

citadel.on_leave((ctx) => {
citadel.broadcast(2, `${ctx.sender_id} left`);
});

Register the periodic game-loop handler.

citadel.on_tick(handler)
Parameter Type Required Meaning
handler (dt: number) => void yes Receives elapsed seconds as a number.

The tick loop starts only when [runtime] tick_hz > 0 and a handler is registered.

let elapsed = 0;
citadel.on_tick((dt) => {
elapsed += dt;
if (elapsed >= 1) {
citadel.broadcast(3, "tick");
elapsed = 0;
}
});

Register the room-label decision hook.

citadel.on_room_create(handler)
Parameter Type Required Meaning
handler (ctx, params: Uint8Array) => object | string | null yes Receives the raw create params as bytes.

Return null or undefined for the gateway default, a string for the map, or an object with map, optional mode, optional max_players or maxPlayers, and optional open.

citadel.on_room_create( => ({
map: "arena_01",
mode: "ffa",
max_players: 8,
open: true,
}));

Register the admission hook for room joins.

citadel.on_room_join(handler)
Parameter Type Required Meaning
handler (ctx, roomId: BigInt) => boolean yes Return true to admit, false to reject.

With no handler, joins are admitted. If the handler errors or times out, the join is rejected fail-closed.

citadel.on_room_join((_ctx, roomId) => roomId === 7n);

Queue a message to every connected participant except the sender currently being handled.

citadel.broadcast(kind, body, unreliable = false)
Parameter Type Required Meaning
kind number (u16) yes Wire message kind.
body Uint8Array | ArrayBuffer | Array<number> | string yes Payload, capped at 64 KiB per call.
unreliable boolean no Prefer datagram delivery when the transport supports it.

Returns nothing. Calls made from RPC and room hooks are discarded because those hooks communicate through their return values.


Queue a message to one participant.

citadel.send(session, kind, body, unreliable = false)
Parameter Type Required Meaning
session number | bigint | string (u64) yes Target participant id, usually ctx.sender.
kind number (u16) yes Wire message kind.
body Uint8Array | ArrayBuffer | Array<number> | string yes Payload, capped at 64 KiB per call.
unreliable boolean no Prefer datagram delivery when available.

Physics is opt-in for server-simulated actors while transform sync is enabled. Dimensions are centimetres, velocity is cm/s, and acceleration is cm/s². The three write calls queue gateway commands; physics_state reads live state.

citadel.set_physics(objectId, opts = null)
Parameter Type Meaning
objectId number (u32) Server-simulated actor to configure.
opts object or null Optional gravity, buoyancy, drag, radius, height, max_speed, shape ("capsule" or "aabb"), and enabled.

Returns: undefined. Pass null or { enabled: false } to detach the body.

Errors: a non-object value, invalid field type, or unknown shape throws; commands from that handler are discarded. Non-server actors are ignored by the hub.

citadel.apply_impulse(objectId, ix, iy, iz)
Parameter Type Meaning
objectId number (u32) Bodied server actor.
ix, iy, iz number Instantaneous velocity delta in cm/s; positive Y jumps/flaps.

Returns: undefined.

Errors: invalid numeric values throw. An actor without a body is a no-op.

citadel.set_move_intent(objectId, vx, vy, vz)
Parameter Type Meaning
objectId number (u32) Bodied server actor.
vx, vy, vz number Desired velocity in cm/s; X/Z are blended by physics and Y remains physics-led.

Returns: undefined.

Errors: invalid numeric values throw. No body means no movement change.

citadel.physics_state(objectId) // -> { grounded, position, velocity } | null
Parameter Type Meaning
objectId number (u32) Actor whose authoritative body state to inspect.

Returns: { grounded: boolean, position: [x, y, z], velocity: [x, y, z] } or null when transform sync/no hub is unavailable or the actor has no body.

Errors: invalid object ids throw. The read queues no command.

const bot = citadel.spawn_actor({ x: 0, y: 200, z: 0 });
citadel.set_physics(bot, { gravity: 900, buoyancy: 300, drag: 0.5,
radius: 30, height: 90, shape: "capsule" });
citadel.on_tick((dt) => {
const state = citadel.physics_state(bot);
if (state && state.grounded) {
citadel.apply_impulse(bot, 0, 600, 0);
} else if (state && state.velocity[1] < 0) {
citadel.apply_impulse(bot, 0, 120, 0);
}
citadel.set_move_intent(bot, 180, 0, 0);
});

citadel.map_info(name) // -> object | null

Returns the loaded CMAP’s bounds_min, bounds_max, vertex_count, and triangle_count, or null when no cooked map has that name. Coordinates are Unreal world units (cm).

const level = citadel.map_info("Lvl_ThirdPerson");
if (level) citadel.log(`loaded ${level.triangle_count} collision triangles`);

citadel.map_names() // -> string[]
citadel.find_path(name, start, goal) // -> [number, number, number][] | null

map_names returns the loaded map keys in deterministic order. find_path asks the Rust core to query the map’s authoritative Detour navigation data; it returns a navigation corridor ending at goal, or null when the map is unknown or either point cannot be routed. Game JavaScript never receives map geometry or executes pathfinding itself.

const path = citadel.find_path("Lvl_ThirdPerson", [0, 0, 0], [900, 0, 300]);
if (path) path.forEach(([x, y, z]) => citadel.move_actor(bot, x, y, z));

citadel.raycast(origin, direction) // -> { point, normal, distance, triangle_index } | null

Casts the finite segment origin + direction against the active room map, in cm. Each argument is a three-number array. The result is null if there is no active map or no hit; otherwise point and unit normal are arrays and distance is in cm. Non-finite or non-three-element vectors return null.

const hit = citadel.raycast([0, 200, 0], [0, -500, 0]);
if (hit) citadel.log(`floor at ${hit.point[1].toFixed(1)} cm`);
citadel.sphere_overlap(centre, radius) // -> boolean

Returns whether a sphere intersects any active-map collision triangle. centre is a three-number array and radius is a finite non-negative value. It returns false without an active map or for invalid parameters.

if (citadel.sphere_overlap([100, 50, 100], 30)) {
citadel.log("spawn position is blocked", "warn");
}
citadel.ground_height(origin, maxDistance) // -> { point, normal, distance, triangle_index } | null

Finds the closest upward-facing collision surface below origin within maxDistance cm. The returned hit uses the same fields as citadel.raycast. It returns null with no active map, no walkable surface, or invalid input.

const ground = citadel.ground_height([0, 500, 0], 1000);
if (ground) citadel.log(`ground y = ${ground.point[1].toFixed(1)}`);

Queue a server-owned actor spawn and return the allocated object id.

citadel.spawn_actor({ archetype, x, y, z }) -> number

Accepted fields are archetype, x, y, and z. Missing numeric fields default to 0.

const actorId = citadel.spawn_actor({ archetype: 3, x: 1, y: 2, z: 3 });

Queue an authoritative actor transform update.

citadel.move_actor(objectId, x, y, z, vx = 0, vy = 0, vz = 0)

The JavaScript adapter emits the same OutboundCommand::MoveActor shape as Lua: position plus velocity, with identity rotation.


Queue a server-owned actor despawn.

citadel.despawn_actor(objectId)

Emit a script log through Citadel tracing.

citadel.log(message, level = "info")

Unknown levels fall back to info. Logging never raises a Citadel runtime error. console.log, console.info, console.warn, console.error, and console.debug are mapped to citadel.log.


Load an operator-owned text-policy JSON file during main.js initialization, then scan or sanitize text with its opaque reference.

Think of a policy as a referee’s sealed rulebook: Citadel reads and checks it before the match starts, then the running game can consult it quickly without walking back to the filing cabinet. scan is the referee pointing at a rule; sanitize is the same referee handing back a safe-to-display version of the message. The GameScript still decides whether to warn, mute, or reject a player.

const policyRef = citadel.text_policy.load_json(path); // -> string
const result = citadel.text_policy.scan(policyRef, text); // -> object
const result = citadel.text_policy.sanitize(policyRef, text); // -> object

load_json accepts one non-empty relative .json path under [runtime] static_data_dir (for example, "policy.json") and returns a reference such as "text-policy:policy.json". The file must be a schema-version-1 policy:

{"schema_version":1,"rules":[{"id":"bad-word","category":"abuse","severity":"high","terms":["bad"],"match":"whole_word","action":"mask"}]}

For that policy, scan(policyRef, "BAD actor") returns exactly {decision:"mask",matches:[{rule_id:"bad-word",category:"abuse",severity:"high",span:{start:0,end:3},action:"mask"}],text:"BAD actor"}. sanitize(policyRef, "BAD actor") returns the same decision and matches with text:"*** actor". Every result has decision, matches, and text. Each match has rule_id, category, severity (or null), span, and action; span.start and span.end are zero-based UTF-8 byte offsets in the input text, with an exclusive end.

Matching folds ASCII letters only (BAD matches bad); it performs no Unicode normalization or Unicode case folding. Rules use whole_word or phrase matching. Actions and aggregate decisions are allow, flag, mask, replace, and reject, in that order of precedence. sanitize masks matched text with one * per character and applies a rule’s replacement for replace; allow, flag, and reject retain the matched input text. Use decision to enforce a flag or rejection—the API never silently permits an invalid policy.

Policies are compiled and cached by path during top-level initialization. A repeat load returns the cached reference. Citadel seals the catalog before handlers run: a cached reference remains usable, but a new path is denied, so message/tick handlers cannot cause policy-file I/O. A successful hot reload builds a new runtime and sealed catalog; a failed replacement leaves the prior runtime active.

Errors: all access, parse, validation, unknown-reference, and late-load failures are fail-closed and throw Error. Static-data access failures are prefixed text policy static data error; invalid JSON/schema/rules/actions are prefixed text policy is invalid; a post-seal cache miss says text policy was not loaded during script initialization; an invalid or foreign reference says unknown text policy reference.

const chatPolicy = citadel.text_policy.load_json("policy.json");
citadel.on_rpc("moderate", (ctx, body) =>
citadel.text_policy.sanitize(chatPolicy, body)
);

Load one operator-owned JSON object or array during main.js initialization.

citadel.static_data.load_json(path) // -> object | array
Parameter Type Required Meaning
path string yes A non-empty relative .json path below [runtime] static_data_dir, such as "gameplay/collision.json". Use / separators only.

Returns: a fresh ordinary JavaScript object or array with parsed JSON values.

Errors: throws Error beginning static data access denied, static data file not found, static data file exceeds configured size limit, invalid JSON static data, or static data schema invalid. Absolute/drive paths, ./.., backslashes, escaped symlinks, missing files, and non-JSON extensions are rejected. It also throws access denied when static_data_dir is unset.

const collision = citadel.static_data.load_json("gameplay/collision.json");
const knightRadius = collision.characters.knight.radius_cm;

Load one operator-owned CSV table during main.js initialization.

citadel.static_data.load_csv(path) // -> Array<object>
Parameter Type Required Meaning
path string yes A non-empty relative .csv path below [runtime] static_data_dir, such as "gameplay/attacks.csv". It has the same containment and size rules as load_json.

Returns: a fresh array of header-keyed objects. Cells spelled true/false become booleans and finite numeric cells become numbers; other cells remain strings.

Errors: malformed/invalid UTF-8 CSV or uneven rows throw invalid CSV static data; empty, missing, or duplicate headers throw static data schema invalid. The same access, missing-file, and size-limit errors as load_json apply.

const attacks = citadel.static_data.load_csv("gameplay/attacks.csv");
const slashDamage = attacks.find((row) => row.id === "slash").damage;

Only top-level initialization can add a cache entry. Later calls may return an already-cached value, but a cache miss is denied, so message and tick handlers cannot cause static-data disk I/O. Each successful hot reload rebuilds the QuickJS VM and parsed catalog atomically; a bad replacement leaves the previous VM and catalog live. See shared static gameplay data for the ordered configuration and deployment workflow.


Add a friend or accept a pending friend request between two users.

citadel.friends_add(user, other) -> string
Parameter Type Required Meaning
user string yes The acting user id (usually ctx.user_id in the trusted tier).
other string yes The other user id to befriend.

Returns: a state token ("invited_sent", "invited_received", "friend", or "blocked").

Errors: returns an error string if the operation fails (e.g., database error, user not found). The error is already sanitized and safe to log.

citadel.on_rpc("befriend", (ctx, body) => {
const state = citadel.friends_add(ctx.user_id, body);
return citadel.Reply.ok(state);
});

Remove any relation (friend or block) between two users, both directions.

citadel.friends_remove(user, other) -> boolean
Parameter Type Required Meaning
user string yes The acting user id (usually ctx.user_id).
other string yes The other user id.

Returns: true if a relation existed and was removed, false if no relation existed.

Errors: returns an error string if the operation fails.


Block another user from the acting user’s side.

citadel.friends_block(user, other) -> void
Parameter Type Required Meaning
user string yes The acting user id (usually ctx.user_id).
other string yes The user id to block.

Errors: returns an error string if the operation fails.


List all relations for a user, sorted by other-user id.

citadel.friends_list(user) -> Array<{user_id, state, updated_unix_ms}>
Parameter Type Required Meaning
user string yes The user id to fetch relations for.

Returns: an array of relation objects, each containing:

  • user_id (string): the other user id
  • state (string): relation state token
  • updated_unix_ms (bigint): last update timestamp

Errors: returns an error string if the operation fails.

citadel.on_rpc("friends.list", (ctx) => {
const rows = citadel.friends_list(ctx.user_id);
return citadel.Reply.ok(JSON.stringify(rows));
});

Hook ctx fields
on_message sender, sender_id, sender_number, kind, user_id
before_realtime Message fields plus copied body; return false to veto before routing. Authentication envelopes are excluded.
after_realtime Before fields plus dropped and delivered; observer-only after synchronous routing.
on_join / on_leave sender, sender_id, sender_number, user_id
on_rpc sender, sender_id, sender_number, method, user_id
on_room_create same as RPC, with method == "room.create"
on_room_join same as RPC plus room_id, room_id_text, room_id_number
on_tick no ctx; receives dt directly

ctx.sender and ctx.room_id are BigInt values so u64 ids remain exact. Use ctx.sender_id / ctx.room_id_text when you need a string and ctx.sender_number / ctx.room_id_number only when you know the id fits safely inside JavaScript’s 53-bit integer range.

function u64be(value) {
const out = new Uint8Array(8);
new DataView(out.buffer).setBigUint64(0, BigInt(value), false);
return out;
}
citadel.on_message(1, (ctx, body) => {
const tagged = new Uint8Array(8 + body.length);
tagged.set(u64be(ctx.sender), 0);
tagged.set(body, 8);
citadel.broadcast(2, tagged, true);
});

JavaScript uses the same configured budgets as the runtime trait:

Limit Value Notes
Handler deadline runtime.deadline_ms, default 100 ms Applies to messages, lifecycle, RPC, and room hooks.
Tick deadline runtime.tick_deadline_ms or auto-derived Applies per tick.
Load/reload deadline 5 seconds Bounds top-level main.js registration work.
Max outbound commands 1024 per invocation Extra commands are dropped.
Max body size 64 KiB per broadcast/send Oversized bodies raise a script error.
Max total outbound bytes 1 MiB per invocation Extra messages are dropped.
QuickJS heap cap 64 MiB per runtime Set through QuickJS memory limits.
QuickJS stack cap 512 KiB per runtime Set through QuickJS stack limits.

Citadel uses QuickJS interrupt handling to stop runaway JavaScript. A timed-out message/lifecycle/tick handler returns no outbound commands. A timed-out RPC returns RPC handler timed out; room create falls back to the gateway default, and room join rejects fail-closed.


With [runtime] hot_reload = true, Citadel watches <scripts_dir>/main.js and every successfully loaded local ESM dependency. Reload is failure-safe: the node reads and builds a fresh QuickJS runtime, requires at least one registered handler, and swaps it in atomically under the runtime lock. A missing file, syntax error, top-level exception, timeout, handlerless script, or broken import is rejected and the previous JavaScript runtime keeps serving.

JavaScript globals and the ESM module cache reset on each successful reload. Capped QuickJS mode provides only the scoped static ESM loader; it does not provide CommonJS require, npm package loading, Node built-ins, native modules, or TypeScript transpilation.


The console runtime endpoints report JavaScript the same way they report Lua and Python: registered RPC method names, message kinds, hook names, reloadability, source path, and the active deadline. See Admin console & console API for endpoint authentication and response shape.


Function Signature Returns / errors
citadel.notifications_send (recipient, code, subject, contentJson, sender?, deliveryKey?) committed notification object; validation errors throw Error.
citadel.notifications_list (recipient, limit?, cursor?) { items, next_cursor }, newest first.
citadel.notifications_mark_read (recipient, ids) { read_ids }; repeated calls are idempotent.
const n = citadel.notifications_send("player-42", 7, "Reward", '{"coins":10}', "server", "reward:round-1");
const page = citadel.notifications_list("player-42", 50);
const changed = citadel.notifications_mark_read("player-42", [n.id]);

Persistence precedes a best-effort local KIND_NOTIFICATION live delivery. Clients deduplicate by id and reconcile through notifications.list.

citadel.chat_call(actor, operation, payload) uses the same secure chat schema as player RPCs. actor is an explicit trusted-runtime identity and is still checked against current friendship or group membership; it is never accepted as a JSON sender. For send, pass {target:{kind:"direct",other_user_id:"player-b"},content:"hi"}. For history, pass the same target with optional limit and before_id. Group targets use group_id; room targets are unavailable to this bridge because only the realtime gateway owns current room presence. Raw channel and channel_type fields fail with CHAT_PROTOCOL_UPGRADE_REQUIRED. operation may be edit (id plus content) or delete (id): both retain the explicit actor, canonical target fence, author time window, revision/event semantics, and durable shared rate limits used by player RPCs. moderate accepts a group target and id only; it applies the same group role hierarchy as player RPCs, fences membership, and writes a redacted durable audit record. Direct and room moderation targets are rejected.

citadel.groups_call(actor, operation, payload), citadel.leaderboards_call(...), citadel.chat_call(...), and citadel.wallet_call(...) take a JavaScript payload object and return decoded JSON, throwing Error on invalid input/service failure. The operation schemas match game-client RPCs. Only trusted code may call wallet adjust.

Signature: citadel.groups_call(actor, operation, payload) -> object. payload is a JavaScript object. Invalid fields, an unknown operation, insufficient role, a conflicting admission, or a full group throws Error.

Operation Payload Return
join { group_id: number } { state: "joined", group } for open groups or { state: "requested", admission } for closed groups.
invite { group_id: number, user_id: string } { state: "invited", admission }; requires admin/superadmin.
approve_request { group_id: number, user_id: string } Updated group; requires admin/superadmin and a pending request.
accept_invitation { group_id: number } Updated group for the invited actor.
cancel_admission { group_id: number } {}; repeat cancellation is safe.
transfer_ownership { group_id: number, user_id: string } Updated group; only current superadmin may transfer to an existing member.
const pending = citadel.groups_call("owner", "invite", { group_id: 7, user_id: "player-42" });
const group = citadel.groups_call("player-42", "accept_invitation", { group_id: 7 });