JavaScript runtime reference
HTTP host API
Section titled “HTTP host API”citadel.http.start, poll, and cancel
Section titled “citadel.http.start, poll, and cancel”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.
citadel.http.fetch compatibility
Section titled “citadel.http.fetch compatibility”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"); }});Migration limits
Section titled “Migration limits”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.
Storage host API
Section titled “Storage host API”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.
Match-scoped messages
Section titled “Match-scoped messages”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.
Enable JavaScript
Section titled “Enable JavaScript”[runtime]enabled = truelanguage = "js"adapter = "embedded"tier = "trusted"scripts_dir = "./game"deadline_ms = 100hot_reload = trueRun a JavaScript-enabled build:
cargo run --features runtime-jsUse language = "javascript" if you prefer the long name. The entrypoint is
always main.js.
Quick Example
Section titled “Quick Example”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"));import citadel
@citadel.on_message(1)def echo(ctx, body): citadel.log(f"echo from {ctx.sender}") citadel.broadcast(1, body)
@citadel.on_rpc("ping")def ping(ctx, body): return citadel.Reply.ok(b"pong")citadel.on_message(1, function(ctx, body) citadel.log("echo from " .. ctx.sender) citadel.broadcast(1, body)end)
citadel.on_rpc("ping", function(ctx, body) return "pong"end)JavaScript registration is imperative:
function handle(ctx, body) { citadel.broadcast(2, body);}
citadel.on_message(2, handle);Local ESM modules
Section titled “Local ESM modules”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.jsimport { damage } from "./systems/combat.js";
citadel.on_message(1, (ctx) => { citadel.broadcast(2, String(damage(ctx.sender)), false);});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.
citadel.on_message
Section titled “citadel.on_message”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}`);});citadel.on_rpc
Section titled “citadel.on_rpc”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("{}");});citadel.Reply
Section titled “citadel.Reply”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.
citadel.on_join
Section titled “citadel.on_join”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");});citadel.on_leave
Section titled “citadel.on_leave”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`);});citadel.on_tick
Section titled “citadel.on_tick”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; }});citadel.on_room_create
Section titled “citadel.on_room_create”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,}));citadel.on_room_join
Section titled “citadel.on_room_join”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);citadel.broadcast
Section titled “citadel.broadcast”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.
citadel.send
Section titled “citadel.send”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 host API
Section titled “Physics host API”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
Section titled “citadel.set_physics”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
Section titled “citadel.apply_impulse”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
Section titled “citadel.set_move_intent”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
Section titled “citadel.physics_state”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.
Balloon-Fight bot
Section titled “Balloon-Fight bot”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
Section titled “citadel.map_info”citadel.map_info(name) // -> object | nullReturns 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 and citadel.find_path
Section titled “citadel.map_names and citadel.find_path”citadel.map_names() // -> string[]citadel.find_path(name, start, goal) // -> [number, number, number][] | nullmap_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
Section titled “citadel.raycast”citadel.raycast(origin, direction) // -> { point, normal, distance, triangle_index } | nullCasts 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
Section titled “citadel.sphere_overlap”citadel.sphere_overlap(centre, radius) // -> booleanReturns 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
Section titled “citadel.ground_height”citadel.ground_height(origin, maxDistance) // -> { point, normal, distance, triangle_index } | nullFinds 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)}`);citadel.spawn_actor
Section titled “citadel.spawn_actor”Queue a server-owned actor spawn and return the allocated object id.
citadel.spawn_actor({ archetype, x, y, z }) -> numberAccepted 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 });citadel.move_actor
Section titled “citadel.move_actor”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.
citadel.despawn_actor
Section titled “citadel.despawn_actor”Queue a server-owned actor despawn.
citadel.despawn_actor(objectId)citadel.log
Section titled “citadel.log”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.
citadel.text_policy
Section titled “citadel.text_policy”Load an operator-owned text-policy JSON file during main.js initialization,
then scan or sanitize text with its opaque reference.
Mental model
Section titled “Mental model”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); // -> stringconst result = citadel.text_policy.scan(policyRef, text); // -> objectconst result = citadel.text_policy.sanitize(policyRef, text); // -> objectload_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));citadel.static_data.load_json
Section titled “citadel.static_data.load_json”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;citadel.static_data.load_csv
Section titled “citadel.static_data.load_csv”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.
citadel.friends.add
Section titled “citadel.friends.add”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);});citadel.friends.remove
Section titled “citadel.friends.remove”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.
citadel.friends.block
Section titled “citadel.friends.block”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.
citadel.friends.list
Section titled “citadel.friends.list”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 idstate(string): relation state tokenupdated_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));});ctx Shapes
Section titled “ctx Shapes”| 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);});Execution Budgets
Section titled “Execution Budgets”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.
Hot Reload
Section titled “Hot Reload”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.
Console Introspection
Section titled “Console Introspection”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.
Player notifications
Section titled “Player notifications”| 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.
Domain feature calls
Section titled “Domain feature calls”Secure citadel.chat_call
Section titled “Secure citadel.chat_call”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.
citadel.groups_call
Section titled “citadel.groups_call”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 });