Configuration (TOML)
Citadel is configured by a TOML file, layered over built-in defaults, then
CITADEL_ environment variables, then CLI flags. Unknown keys are rejected
(deny_unknown_fields). Validate any file with citadel check.
The config file is selected as follows: an explicit --config <path> is always
used as given. With no --config, Citadel discovers a citadel.toml in the
current working directory and loads it if present — the zero-flag “unzip and run”
default — otherwise it falls back to the built-in defaults. The repository (and
the standalone release) ships an editable citadel.toml at its root, so
cargo run / the packaged citadel or citadel.exe starts against it with no
flags. The release archive uses scripts/ for its starter game logic; the
repository configuration may use a different scripts_dir.
Full example with defaults
Section titled “Full example with defaults”Every value below is the built-in default. An empty config file (or no file) is equivalent to this.
[server]# Stable identifier for this node within a (future) cluster.node_id = "dev-1"# Address other nodes/clients use to reach this node.public_addr = "127.0.0.1:7350"
[http]# Address the HTTP / health listener binds to.bind = "127.0.0.1:7350"
[logging]# Log level directive, e.g. "info", "debug", "citadel=trace".level = "info"# "pretty" (human-readable) or "json" (structured).format = "pretty"
[errors]# The journal file is always citadel-errors.jsonl beside the executable.max_bytes = 8388608max_entries = 2000
[telemetry.authoritative_decisions]# Process-local recorder for already-validated authoritative decisions.# It retains no payloads, replies, commands, corrected values, or identities.enabled = truecapacity = 1024
[transport.quic]# QUIC is the primary realtime transport (datagrams + reliable streams, TLS 1.3).enabled = falsebind = "127.0.0.1:7351"outbound_queue_capacity = 1024
[transport.tls]# Optional CA-issued PEM TLS for public QUIC and WebTransport. Set both paths# together in production; omit both for the local self-signed development mode.# certificate_file = "/etc/letsencrypt/live/game.example.com/fullchain.pem"# private_key_file = "/etc/letsencrypt/live/game.example.com/privkey.pem"
[transport.websocket]# Reliable-only fallback for browsers without WebTransport and UDP-blocked nets.enabled = falsebind = "127.0.0.1:7352"outbound_queue_capacity = 1024# Native WebSocket Ping/Pong liveness after authentication. Set the interval to# 0 only when an upstream proxy owns equivalent liveness handling.heartbeat_interval_ms = 15000heartbeat_timeout_ms = 45000
[transport.webtransport]# Browser path: QUIC-grade datagrams + streams over HTTP/3 (own UDP endpoint).enabled = falsebind = "127.0.0.1:7353"outbound_queue_capacity = 1024
[transport.network_peer]# Optional authoritative property replication. This attaches the gateway authority# only; trusted server lifecycle code still registers classes and spawns objects.enabled = falseshared_quantized_state = falseinterest_cell_size = 100interest_inner = 100interest_outer = 125
[runtime]# Embedded game-logic runtime. With language unset, Citadel autodetects by# priority in scripts_dir: main.lua (default build), main.py# (runtime-python builds), then main.js (runtime-js builds). With no entrypoint,# the built-in relay runs.enabled = true# Strict GameScript readiness gate. When true, matches require a validated,# loaded script with a healthy execution backend: the node refuses to list,# create, or admit players into matches until one is ready, and a missing# entrypoint boots the node not-ready instead of silently falling back to the# relay. The first-run wizard enables this when it scaffolds a scripted project.require_script = false# Optional explicit language. Omit for autodetect. Supported values today:# "lua"; "python" when compiled with --features runtime-python; "js" or# "javascript" when compiled with --features runtime-js.# language = "lua"# Runtime hosting model. Only embedded/trusted is implemented today.adapter = "embedded"tier = "trusted"# Lua is sandboxed even on a trusted node unless this is explicitly changed.lua_execution_mode = "sandboxed"scripts_dir = "./game"maps_dir = "./maps"# Optional, read-only JSON/CSV gameplay data root. It is separate from scripts;# Citadel does not create or write it.# static_data_dir = "./common"# static_data_max_file_bytes = 1048576# Per-invocation budget (ms) for message and lifecycle (on_join/on_leave) handlers.deadline_ms = 100# Server game-loop rate for citadel.on_tick, in ticks/sec. 0 disables the tick.tick_hz = 0# Optional per-tick budget (ms). Omit for auto: min(50, tick period / 2), min 1.# tick_deadline_ms = 25# Watch the selected entrypoint and reload it live on change (opt-in; dev convenience).hot_reload = false# Change-poll interval (ms) when hot_reload is on.hot_reload_poll_ms = 500
# Rust-owned outbound HTTP for trusted runtime code. Empty allowed_hosts means# any public DNS hostname; IP-literal URLs are always rejected.[runtime.capabilities.outbound_http]enabled = truemax_concurrent_requests = 16max_requests_per_minute = 120allowed_hosts = []allowed_ports = [80, 443]# Only enable for an operator-controlled private integration.allow_private_networks = false
# Server-owned receipt-validation egress. The only enabled validator is custom# development receipts; Apple, Google, and Huawei remain disabled until their# dedicated verified adapters ship. credential_env names an environment variable# but never places its secret value in TOML or configuration output.[purchases]max_receipt_bytes = 65536max_concurrent_requests = 16max_requests_per_minute = 120timeout_ms = 5000max_retries = 2allowed_hosts = ["api.storekit.itunes.apple.com", "androidpublisher.googleapis.com"]
[purchases.apple]enabled = false# credential_env = "CITADEL_APPLE_PRIVATE_KEY"
[purchases.google]enabled = false# credential_env = "CITADEL_GOOGLE_SERVICE_ACCOUNT_JSON"
[purchases.huawei]enabled = false# credential_env = "CITADEL_HUAWEI_CLIENT_SECRET"
# Secure chat fixed-window policies. Every value must be positive; limits are# shared by nodes that use the same durable database.[chat.limits]join = { limit = 12, window_ms = 60000 }history = { limit = 60, window_ms = 60000 }send_user = { limit = 8, window_ms = 10000 }send_user_channel = { limit = 12, window_ms = 10000 }send_channel = { limit = 160, window_ms = 10000 }mutation_user = { limit = 4, window_ms = 60000 }mutation_user_channel = { limit = 8, window_ms = 60000 }moderation_operator = { limit = 30, window_ms = 60000 }moderation_channel = { limit = 60, window_ms = 60000 }
# Public HTTP authentication admission controls. Limits are durable and shared# by nodes using the same database. Citadel uses the direct TCP peer address;# forwarded headers are intentionally not trusted.[authentication.limits]source = { limit = 30, window_ms = 60000 }email = { limit = 10, window_ms = 900000 }registration_source = { limit = 10, window_ms = 3600000 }
# [database] is OPTIONAL and omitted by default (no url => in-memory backend).# Shown here with its non-url defaults; add a url to enable a durable backend.[database]# Connection URL. The backend is chosen by scheme. Omit to run on the in-memory# backend. Also settable via CITADEL_DATABASE_URL. Carries credentials; never logged.# Postgres: url = "postgres://citadel:citadel@localhost:5432/citadel"# CockroachDB: url = "cockroach://root@localhost:26257/citadel?sslmode=disable"# SQLite: url = "sqlite:data.sqlite" # one embedded file, created on first run# MongoDB: url = "mongodb://user:password@db-1,db-2/citadel?replicaSet=rs0"max_connections = 10connect_timeout_ms = 5000acquire_timeout_ms = 5000# MongoDB consistency policy; the transactional foundation requires these values.mongodb_read_preference = "primary"mongodb_write_concern = "majority"mongodb_read_concern = "majority"
# Disabled by default. Enabling this starts the durable multi-node matchmaker# control listener and requires the database and certificate paths below.[cluster]enabled = falsecontrol_bind = "127.0.0.1:7390"matchmaker_shard = 0lease_ttl_ms = 5000handoff_ttl_ms = 30000command_timeout_ms = 2000
[cluster.tls]# ca_certificate_file = "./certs/cluster-ca.pem"# certificate_file = "./certs/node.pem"# private_key_file = "./certs/node-key.pem"
# [[cluster.peers]]# node_id = "node-b"# control_addr = "127.0.0.1:7391"# server_name = "node-b.local"# certificate_file = "./certs/node-b.pem"
# Storage indexes are optional. Each declaration is operator-controlled and# creates a matching physical JSON-expression index at server startup.[[storage.indexes]]name = "profiles_by_score"collection = "profiles"# key = "main" # optional: restrict the index to one object keyfields = ["score", "region"]Sections
Section titled “Sections”[server]
Section titled “[server]”| Key | Type | Default | Notes |
|---|---|---|---|
node_id |
string | "dev-1" |
Must not be empty. |
public_addr |
socket address | "127.0.0.1:7350" |
Validated as a socket address. |
[http]
Section titled “[http]”| Key | Type | Default | Notes |
|---|---|---|---|
bind |
socket address | "127.0.0.1:7350" |
HTTP / health listener. Validated. |
[logging]
Section titled “[logging]”| Key | Type | Default | Notes |
|---|---|---|---|
level |
string | "info" |
Tracing directive. Must not be empty. |
format |
enum | "pretty" |
"pretty" or "json". |
[telemetry.authoritative_decisions]
Section titled “[telemetry.authoritative_decisions]”This node-local recorder observes only decisions that have already passed the
authoritative bridge validator at the gateway. It retains a bounded FIFO of
opaque numeric match/batch/event correlations, generic accepted/rejected/
corrected outcomes, and an opaque numeric rejection code when supplied. It
never retains client payloads, replies, script commands, corrected values,
participant IDs, or account IDs. Trusted runtime-controlled slices can derive
private bounded reports from this recorder; the reports are visible only through
the authenticated operator console and never through a public endpoint.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
Composes the process-local recorder at application startup. |
capacity |
integer | 1024 |
Retained records; must be 1..=100000 when enabled. On overflow, the oldest record is deterministically evicted first. |
[telemetry.slices]
Section titled “[telemetry.slices]”Trusted embedded runtime logic can open, mark, and finish a telemetry slice for the server-owned room context of its active invocation. The lifecycle APIs take no room or match identifier. This allows a game to choose diagnostic windows without making Citadel assume that a room is a short-lived match. The API receives the Citadel-created runtime context rather than a client-provided match, session, account, report, or payload identifier. Like the other runtime host APIs, this is a trusted server-script boundary; it is not a sandbox for malicious scripts.
| Key | Default | Meaning |
|---|---|---|
max_active |
32 |
Maximum active process-local slices; the oldest is closed when the bound is reached. |
max_markers |
32 |
Maximum namespaced marker names retained per slice. |
ttl_ms |
300000 |
Maximum slice duration, 1..=86400000 ms. Expired slices close during normal service maintenance or an operator report read. |
max_closed_reports |
128 |
FIFO retention limit for redacted closed reports. |
Closed reports are available only to authenticated console operators at
/console/v1/telemetry/slices; each read is audited. They contain generic
decision totals, marker counts, close reason, duration, and a truncation
indicator. Marker text is never retained. They never contain raw payloads, player identity, account data,
replies, commands, or corrected values.
Durable log tables
Section titled “Durable log tables”SQLite, PostgreSQL, and CockroachDB carry four tables for durable server
history, plus a nullable match_id column on lag_diagnostic_reports:
| Table | Holds | Never holds |
|---|---|---|
matches |
The server-owned shape of a match: room, map, mode, player cap, open and close time, termination reason, and a script-supplied result_json. |
Participant identities, account ids, session or transport identifiers. |
match_logs |
Your game script’s own log lines — level, tag, message, and a verbatim payload_json. |
Credentials, bearer tokens, session ids, participant ids. |
console_audit_entries |
The console action trail: who acted, with which role and credential id, what they did, on what, and when. | Passwords, bearer tokens, API-key secrets, console session tokens, raw request or response payloads. |
telemetry_slice_reports |
Aggregate slice outcomes: close reason, duration, marker count, truncation flag, and accepted/rejected/corrected totals. | Marker text, event payloads, participant or account identity, replies, commands, corrected values. |
Three facts about these tables are deliberate and worth knowing before you plan around them:
- The server owns match open and close. A game script can never open or
close a match record; its only influence is stamping
result_jsonon a match that is still open. That is what makes the record trustworthy as evidence. match_idis nullable everywhere. A game with no match concept — an MMORPG world, a global scheduled job — still writes logs; the row simply carries no match. A log outside a match is stored, never rejected.- Operator actions are not match-scoped.
console_audit_entries.match_idexists but is almost always empty, and nothing forces an operator action into a match.
credential_id is a public machine-credential identifier, but the database
explorer auto-redacts any column whose name contains credential, so it appears
redacted there. That is expected, not a defect.
On the in-memory and MongoDB backends none of this is persisted. Those backends expose no durable log capability: audit entries and telemetry slices stay in their bounded in-process rings, script log writes are dropped at the sink with a counter, and no match rows are created.
lag_diagnostic_reports.match_id ships as a working read filter over a column
nothing populates yet: a lag capture is node-scoped, so associating one with a
match is separate work. Treat it as always empty for now.
[errors]
Section titled “[errors]”The local incident journal is always enabled. It writes redacted failure and
panic summaries to citadel-errors.jsonl beside the running executable, so a
standalone deployment keeps its diagnostics with the binary. It never stores
panic payloads, internal error detail, connection strings, tokens, or passwords.
The Error Journal section in /dashboard reads the retained summaries through
the authenticated console API.
| Key | Type | Default | Notes |
|---|---|---|---|
max_bytes |
integer | 8388608 |
Journal size cap. Must be between 65536 and 1073741824; oldest entries are pruned when reached. |
max_entries |
integer | 2000 |
Maximum retained entries. Must be between 1 and 100000. |
[transport.quic], [transport.websocket], [transport.webtransport]
Section titled “[transport.quic], [transport.websocket], [transport.webtransport]”All three share the same shape:
| Key | Type | Default (quic / ws / wt) | Notes |
|---|---|---|---|
enabled |
bool | false |
Whether the listener starts. |
bind |
socket address | 7351 / 7352 / 7353 on 127.0.0.1 |
Validated only when enabled. |
outbound_queue_capacity |
integer | 1024 |
Per-connection outbound queue in envelopes; must be >= 1 when enabled. A full or closed queue drops the current outbound attempt rather than blocking realtime routing. |
WebSocket additionally supports heartbeat_interval_ms (default 15000) and
heartbeat_timeout_ms (default 45000). After authentication Citadel sends
native Ping control frames, not game envelopes. A peer that misses the Pong
deadline is closed normally, which runs the usual session cleanup and on_leave
hooks. Set heartbeat_interval_ms = 0 to disable probes; when enabled, the
timeout must be at least 1 ms. The /status metrics include aggregate ping,
pong, and liveness-timeout totals.
Notes:
- WebTransport negotiates the HTTP/3 ALPN
h3and runs on its own UDP endpoint, separate from native QUIC (citadel/0). - A transport’s
bindandoutbound_queue_capacityare only validated when that transport isenabled. - All enabled transports share one gateway room.
[transport.network_peer]
Section titled “[transport.network_peer]”This optional section activates the NetworkPeer authority at the production gateway. It is off by default. Enabling it routes replication delta/ack frames and sends schema/full-baseline bootstrap at gateway admission, but it does not let a client register a class or object. Trusted server lifecycle code must use the gateway registration/spawn/despawn seams. The authority-level shared grid uses the three distance values below; this is not automatic room or matchmaker AOI integration.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Attach the NetworkPeer authority to the gateway. |
shared_quantized_state |
bool | false |
Reuse prepared quantized payloads only for equivalent per-receiver bunches. Enable after measuring real fan-out; tokens/baselines remain receiver-specific. |
interest_cell_size |
integer | 100 |
Uniform shared-grid cell size in world units; must be positive. |
interest_inner |
integer | 100 |
Enter-relevance distance in world units; must be positive. |
interest_outer |
integer | 125 |
Exit-relevance distance in world units; must be at least interest_inner. |
[transport.tls]
Section titled “[transport.tls]”This optional section configures the PEM certificate chain used directly by the
public QUIC and WebTransport UDP listeners. It is intentionally separate from
[cluster.tls], which only secures node-control traffic.
| Key | Type | Default | Notes |
|---|---|---|---|
certificate_file |
path | (unset) | PEM leaf certificate followed by intermediates. |
private_key_file |
path | (unset) | Matching PEM private key (PKCS#8, RSA/PKCS#1, or SEC1). |
Set both paths or neither. With both omitted, Citadel generates local-only
self-signed certificates; do not expose that mode publicly. Set both paths to
a CA-issued chain/key before publishing QUIC or WebTransport. Citadel reads the
files at startup, so renew a certificate then restart the service. This section
does not turn the built-in WebSocket listener into wss://; use a reverse
proxy for WebSocket and the HTTP dashboard.
[chat.limits]
Section titled “[chat.limits]”Secure chat uses repository-owned fixed windows, not a process-local throttle.
Each rule is an inline TOML table with limit and window_ms; both must be in
the inclusive range 1..=1_000_000 and 1..=86_400_000 respectively. The
server hashes user/channel identities before storing counters. send_* applies
user, user/channel, and channel keys together; mutation_* applies to author
edits/deletes; moderation uses operator and channel keys. Expired counter and
audit rows are eligible for bounded background cleanup. A persistence failure
fails a chat action closed rather than bypassing its configured policy.
[authentication.limits]
Section titled “[authentication.limits]”Public device, custom-id, and email/password authentication is admitted before
credential lookup or Argon2 verification. source limits all auth requests by
the direct TCP peer address; email additionally limits normalized email
attempts, so a distributed password-guessing attack cannot bypass the source
limit; and registration_source applies only to create:true requests. Each
rule is an inline table with positive limit and window_ms values in the same
inclusive ranges as [chat.limits].
The durable counter keys are SHA-256 hashes, never raw IP addresses or emails.
SQLite, PostgreSQL, and CockroachDB nodes sharing a database enforce the same
atomic multi-key plan; the in-memory backend is intentionally single-process.
When any allowance is exhausted, Citadel returns 429 rate_limited and a
conservative whole-second Retry-After that covers the full matching plan. It
does not identify the limited email, source, or rule. Do not rely on
X-Forwarded-For: Citadel intentionally ignores forwarded-address headers
until trusted-proxy support is configured and authenticated.
[database]
Section titled “[database]”Optional durable persistence. The whole section can be omitted: with no url
the node keeps using the in-memory repositories, so a default config runs with no
database. When set, the URL may carry credentials and is never echoed in
diagnostics. The backend is chosen by the URL scheme:
postgres:///postgresql://→ the networked PostgreSQL backend.cockroach:///cockroachdb://→ CockroachDB, served through the same Postgres backend over CockroachDB’s PostgreSQL-wire protocol. See the Running on CockroachDB guide for the dialect details.sqlite:URL or a bare file path (e.g.sqlite:data.sqlite,sqlite::memory:,./data.sqlite) → the embedded SQLite backend: one self-contained file, created on first run, with no server to operate.mongodb:///mongodb+srv://→ the durable MongoDB backend. The URI is parsed by MongoDB’s official Rust driver, so standard TLS, SCRAM, and X.509 URI options remain supported. It requires a replica set or sharded cluster; standalonemongodis rejected because it cannot meet the transaction contract. Citadel never falls back to in-memory state when a MongoDB URL is configured: an unreachable, non-transactional, or incompatible deployment fails startup clearly.
| Key | Type | Default | Notes |
|---|---|---|---|
url |
string | (unset) | postgres:// / postgresql:// (Postgres), cockroach:// / cockroachdb:// (CockroachDB), mongodb:// / mongodb+srv:// (MongoDB), or sqlite: / a file path (SQLite). Unset runs in-memory. Also settable via CITADEL_DATABASE_URL. |
max_connections |
integer | 10 |
Connection pool size. Must be >= 1 when a url is set. SQLite in-memory databases are forced to a single connection. |
connect_timeout_ms |
integer | 5000 |
Timeout for the initial connection. Must be >= 1 when a url is set. |
acquire_timeout_ms |
integer | 5000 |
Timeout for acquiring a pooled connection. Must be >= 1 when a url is set. |
mongodb_read_preference |
string | "primary" |
Must remain primary for MongoDB transactional consistency. |
mongodb_write_concern |
string | "majority" |
Must remain majority for MongoDB transactional consistency. |
mongodb_read_concern |
string | "majority" |
Must remain majority for MongoDB transactional consistency. |
Schema reconciliation is applied on connect. For Postgres, run a
throwaway local database and migrate it with make db-up (Windows cmd: make db-up; PowerShell: .\make db-up); SQLite needs no setup — the file is
created and migrated automatically.
See the persistence feature docs for the schema and transaction model.
On citadel serve, the node selects its backend from this section before it
starts serving: it picks Postgres, CockroachDB, SQLite, or MongoDB by URL scheme
(connecting, applying migrations), or runs in-memory with no url. If a
configured database is unreachable (or a migration fails), startup fails
fast with a clear error — the node never starts on a silent in-memory fallback.
The selected backend (in-memory, postgres, cockroach, sqlite, or mongodb, never
the URL) is reported in the backend field of the /status endpoint and shown on
the /dashboard console.
CockroachDB rides the Postgres backend but is a distinct flavor: it uses
CockroachDB-compatible migrations and skips two PostgreSQL-only mechanisms it does
not implement (COLLATE "C" and pg_advisory_xact_lock). Point a
cockroach:///cockroachdb:// URL at the cluster — a plain postgres:// URL
aimed at CockroachDB would try to apply the PostgreSQL migrations and fail. See
the Running on CockroachDB guide.
The SQLite backend is a full sibling of Postgres behind the same seam: it serves
storage plus identity and sessions, so accounts and sessions persist durably
to a single data.sqlite. With a sqlite: URL the node self-bootstraps on
first run — it creates the database file, applies the embedded migrations, and
creates an empty scripts_dir (./game) — so the standalone flow is unzip and
run, with no migration command or mkdir.
[cluster] and [cluster.tls]
Section titled “[cluster] and [cluster.tls]”The optional cluster section enables the live cross-node matchmaker and party
authority. It is a durable feature: startup rejects cluster.enabled = true
without a database.url, and accepts only PostgreSQL or CockroachDB. SQLite and
MongoDB are supported in their appropriate non-clustered deployments but are
rejected for clusters because this owner/fencing path requires portable atomic
multi-object writes. The active shard or party owner is selected by a stored
generation-fenced lease; a non-owner forwards ticket submit/cancel/status,
handoff delivery, admission, and party mutations through a bounded mutual-TLS
connection. The same typed control plane also carries bounded runtime-event
fan-out and fenced cache mutations; Citadel does not proxy realtime sockets or
expose a general inter-node message tunnel.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Starts the cross-node matchmaker path. Requires [database], mTLS paths, and valid peer entries. |
control_bind |
socket address | 127.0.0.1:7390 |
TCP listener for matchmaker-only mTLS commands. |
matchmaker_shard |
integer | 0 |
Queue partition resolved by this node. Nodes sharing a queue configure the same value. |
lease_ttl_ms |
integer | 5000 |
Positive durable ownership lease duration. Renewals keep the same fencing generation. |
handoff_ttl_ms |
integer | 30000 |
Positive player join-capability lifetime. |
command_timeout_ms |
integer | 2000 |
Positive deadline for a node-control command. |
tls.ca_certificate_file |
path | (empty) | PEM private-CA certificate trusted for peer TLS handshakes. Required when enabled. |
tls.certificate_file |
path | (empty) | This node’s PEM certificate chain. Required when enabled; its leaf needs both TLS server-auth and client-auth usages. |
tls.private_key_file |
path | (empty) | This node’s PKCS#8 private-key PEM. Required when enabled; never logged. |
Every [[cluster.peers]] entry requires node_id, control_addr,
server_name, and certificate_file. The certificate file is the peer’s leaf
certificate used to pin that identity after the CA validates TLS. The peer leaf
must carry server_name in its DNS SAN. See the complete ordered setup in
Run a two-node matchmaker.
With cluster.enabled, a locally accepted runtime event is also offered to
configured peers through a bounded asynchronous queue. Delivery is
best-effort delivery attempt: peer outages or a full queue can lose a remote
copy, and no retry or replay is promised. Cache writes, CAS writes, and deletes
first commit to the caller’s node-local cache, then are offered to a bounded
queue for the current durable global cache-writer lease. The writer propagates
last-writer-wins fenced mutations with an absolute expiry timestamp. Queue
saturation, a peer outage, or a failover can lose that remote attempt; a
successful local call is never a global commit. A writer failover advances the
durable fence, so delayed mutations from an older writer are rejected. Cache
values remain memory-only: they are neither globally linearizable nor durable,
local CAS versions are node-local, and a peer restart does not replay cache
contents.
[storage]
Section titled “[storage]”Optional operator-declared JSON storage indexes. Add one
[[storage.indexes]] item per index. Citadel validates the definition during
citadel check, then creates a matching physical expression index on SQLite,
PostgreSQL, or CockroachDB during server bootstrap. In-memory mode preserves the
same observable query/permission behavior for development.
| Key | Type | Required | Notes |
|---|---|---|---|
name |
string | Yes | Unique ASCII identifier (1..=40 characters, letters/digits/underscores; cannot start with a digit). |
collection |
string | Yes | Storage collection this index covers. |
key |
string | No | Restricts the index to one object key in the collection. |
fields |
string array | Yes | One or more unique top-level JSON identifiers (1..=64 characters). They are the only fields that may be queried. |
The trusted Lua, Python, and JavaScript runtimes query a declaration with
storage_index_query(index_name, filters_json, limit). filters_json is a JSON
object of equality predicates over declared fields; values may be strings,
numbers, or booleans, and limit is from 1 through 100. Results are
identity-ordered and respect the normal storage read permissions for the
accessor. Trusted server scripts use the authoritative accessor, so this is not
a player-facing database endpoint.
At script initialization, game logic may call
register_storage_index_filter(index_name, callback). The callback runs before
that runtime’s storage_write commits for a matching declaration. It receives
the candidate identity, value_json, permissions, expected version, and index
name; return true to include the object in the index or false to exclude it
without deleting the object. A callback error, deadline, or non-boolean return
rejects the write and preserves the previous object and index membership. The
callback inherits the enclosing runtime-handler deadline and Citadel never
retries it automatically; scripts explicitly choose whether to retry a failed
write, avoiding repeated callback side effects.
Indexes are intentionally not player- or script-created DDL. There is no arbitrary query language, range/full-text filter, or generic client SDK endpoint: index search is trusted game-logic work and clients consume an application RPC. A changed definition produces a new, safe physical index on the next startup; schedule normal database maintenance to remove an obsolete old expression index if the configuration changes frequently.
[runtime]
Section titled “[runtime]”The embedded game-logic runtime. With the default language unset, Citadel
autodetects the conventional entrypoint in scripts_dir by priority:
main.lua (Lua, default build), then main.py (Python when compiled with
--features runtime-python), then main.js (JavaScript when compiled with
--features runtime-js). If multiple entrypoints are present, Lua wins over
Python and Python wins over JavaScript. An explicit language takes precedence
and only looks for that language’s entrypoint.
Lua is always available. Python is available only in builds compiled with the
runtime-python Cargo feature; selecting Python in a lean/default build fails
with a clear config error instead of silently falling back to Lua. JavaScript is
available only in builds compiled with the runtime-js Cargo feature; selecting
it in a lean/default build fails the same way. When disabled, or when the
selected entrypoint is absent, the node uses the built-in position relay.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
Consult the script for inbound messages; false forces the built-in relay. |
require_script |
bool | false |
Strict GameScript readiness gate. When true, no match exists without a validated, loaded script and a healthy execution backend: match listing, match creation, and player admission are all refused with a stable game script unavailable error until a script is ready, and every match is born bound to the loaded script revision and generation (admission into a match whose revision is no longer loaded is refused). A missing entrypoint boots the node not-ready instead of silently falling back to the relay; the node becomes ready when a valid script loads (for example via hot_reload or a later deploy). A present-but-broken script remains a hard startup error. Requires enabled = true. The first-run wizard sets this when it scaffolds a scripted project; the default false keeps unzip-and-run relay behavior unchanged. |
language |
enum | (unset / autodetect) | Optional explicit language. Current accepted values: "lua", "python", "js" ("javascript" is an alias for JS). Lua is always implemented; Python requires a runtime-python build; JavaScript requires a runtime-js build. |
adapter |
enum | "embedded" |
Runtime hosting adapter. "embedded" (the default) executes game scripts in-process. "external-worker" executes them in a supervised worker process instead: each live match runs in its own engine context (own Lua/JS state, own Python module namespace) with fair scheduling and crash containment — a wedged or crashed script costs the worker process, never the server; affected matches close with a requeue hint. Match-independent surfaces (global messages, RPC, lifecycle hooks, custom HTTP endpoints) are not routed to the worker yet. "wasm" is reserved and currently fails validation. |
tier |
enum | "trusted" |
Runtime trust tier. "trusted" is implemented. "hardened" is reserved for the future WASM/capability-gated tier and currently fails validation. |
lua_execution_mode |
enum | "sandboxed" |
Lua-only capability mode. "sandboxed" retains the scoped loader and handler deadlines. Explicit "trusted" enables Lua’s extended safe standard libraries (os, io, package/unrestricted require, and coroutine) and disables the Lua deadline hook. Use it only for operator-owned game code; startup logs a warning and /status reports the selected mode. |
scripts_dir |
string | "./game" |
Directory holding main.<ext> entrypoints, relative to the working dir. Must not be empty when enabled. |
maps_dir |
string | "./maps" |
Directory of cooked .map level geometry, scanned once at startup. A room’s map name resolves to a loaded map here. Absent/empty is fine — the node just has no server-side geometry. |
static_data_dir |
string, optional | (unset) | Read-only root for Lua, Python, and JavaScript static JSON/CSV gameplay files. It is separate from scripts_dir, must already resolve to a directory when the selected game runtime loads, and Citadel never creates or writes it. Unset games retain the existing runtime behavior; citadel.static_data then returns an explicit access-denied error. |
static_data_max_file_bytes |
integer | 1048576 |
Maximum bytes read from one static JSON/CSV file. Must be >= 1 when static_data_dir is set. The check applies before parsing and a bounded read defends against a file growing between stat and read. |
deadline_ms |
integer | 100 |
Per-invocation budget for message and lifecycle handlers. Must be >= 1 when enabled. |
tick_hz |
integer | 0 |
citadel.on_tick rate (ticks/sec). 0 disables the game loop; no tick task is spawned. |
tick_deadline_ms |
integer | (auto) | Optional per-tick budget. Omitted derives min(50ms, tick period / 2) (at least 1ms). An explicit 0 is a config error. |
hot_reload |
bool | false |
Watch the selected entrypoint (main.lua, main.py, or main.js) and reload it live on change (dev convenience; opt-in). Reloads are failure-safe: a broken edit is rejected and the previous script keeps serving. In-VM globals reset on each reload. |
hot_reload_poll_ms |
integer | 500 |
How often (ms) to poll the script for changes when hot_reload is on. Must be >= 1 when hot_reload is enabled. Ignored when off. |
Python builds use embedded CPython through PyO3. Local development selects the
interpreter with PyO3’s normal discovery; set PYO3_PYTHON=python or an
absolute interpreter path if discovery picks the wrong install. On Windows,
embedded CPython also needs its standard-library prefix (PYTHONHOME) to point
at the same Python distribution. scripts/check.sh sources
scripts/python-runtime-env.sh, which sets these defaults from the active
python command for local verification.
Python-enabled release artifacts use an explicit bundle layout next to
citadel.exe:
citadel.exepython313.dllpython/ Lib/ DLLs/scripts/main.pyAt process start, a runtime-python build detects that layout and sets
PYTHONHOME to ./python plus PYTHONPATH to the bundled Lib/ and DLLs/
before PyO3 initializes CPython. If no bundle is present, behavior is unchanged
and PyO3 uses the local/global interpreter it was built to use. Static
libpython builds are not the default packaging path.
JavaScript builds use embedded QuickJS through rquickjs. The capped runtime
loads main.js only; it does not transpile TypeScript and does not expose npm,
Node built-ins, or worker threads. Run it with cargo run --features runtime-js
or a release artifact that was built with the same feature.
The tick loop starts only when tick_hz > 0 and the script registered an
on_tick handler. Each handler invocation is isolated and time-bounded: a slow
or erroring handler can never wedge the node.
The selected runtime is reported in the /status JSON under runtime and in
the console’s GET /console/v1/runtime response. The status object includes the
configured language (when set), selected language, selection source
(explicit/autodetected), entrypoint path, adapter, tier, and scripts
directory.
[runtime.capabilities.outbound_http]
Section titled “[runtime.capabilities.outbound_http]”Operator policy for Rust-owned outbound HTTP used by trusted Lua, Python, and
JavaScript runtime code (citadel.http.fetch, start, poll, and cancel).
It is enabled by default for compatibility, but operators should set an exact
allowed_hosts list for production integrations. This section does not
grant client access, raw sockets, or access from realtime interceptors.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
Disables every script-visible HTTP operation, including poll and cancel, when false. |
max_concurrent_requests |
integer | 16 |
Per-runtime maximum in-flight outbound requests; must be 1..=1024. |
max_requests_per_minute |
integer | 120 |
Per-runtime rolling 60-second request-acquisition limit; a request is counted when it acquires execution capacity, not when start returns. Must be 1..=1000000. |
allowed_hosts |
array of hostname strings | [] |
Exact DNS hostnames allowed for egress. An empty list permits any public DNS hostname; it does not allow IP-literal URLs. At most 128 hostnames. |
allowed_ports |
array of integers | [80, 443] |
Permitted TCP ports; the array must be non-empty and has at most 128 entries. |
allow_private_networks |
bool | false |
Permits resolved private, loopback, link-local, and other non-public addresses only for an explicit operator-controlled private integration. |
Citadel accepts only http/https URLs with DNS hostnames and rejects URL
credentials, forbidden Host/:authority overrides, ports outside the list,
and hostnames outside allowed_hosts. It resolves and pins the approved address
for the request, preventing DNS-rebinding from changing the connection target.
The Rust client denies ambient proxies and redirects. Independent fixed bounds
also apply: 64 KiB request body, 1 MiB response body, 64 headers / 16 KiB
aggregate header bytes, five-second wall-clock deadline, and 128 retained or
outstanding async handles per runtime. Network/runtime results returned by
poll use stable, redacted error_code values. Local language argument or
option validation can instead raise a language-visible validation message.
[purchases]
Section titled “[purchases]”Server-owned receipt-validation policy. This foundation never gives a game runtime a provider credential, a raw socket, or a way to bypass the configured HTTPS hosts. It applies a receipt-size cap before provider dispatch, blocks redirects/proxies/private-network targets through the shared client, records aggregate-only validation metrics, and exposes only sanitized failures.
All real providers are intentionally disabled in this release. Setting
apple.enabled, google.enabled, or huawei.enabled to true is a config
error until that provider’s verified adapter lands. The custom deterministic
validator remains available for local prototypes and console tests.
| Key | Type | Default | Notes |
|---|---|---|---|
max_receipt_bytes |
integer | 65536 |
Raw receipt cap before parsing or egress; must be 1..=1048576. |
max_concurrent_requests |
integer | 16 |
Server-owned simultaneous provider request bound; must be 1..=1024. |
max_requests_per_minute |
integer | 120 |
Server-owned rolling 60-second provider request bound; must be 1..=1000000. |
timeout_ms |
integer | 5000 |
Per-provider request deadline; must be 1..=60000. |
max_retries |
integer | 2 |
Reserved retry budget for future explicitly idempotent provider operations; 0..=3. This foundation never retries arbitrary POSTs. |
allowed_hosts |
hostname array | Apple/Google endpoints | Exact public DNS host allowlist; required, at most 16 entries. HTTPS port 443 only. |
apple.enabled, google.enabled, huawei.enabled |
bool | false |
Rejected if true until each provider adapter ships. |
*.credential_env |
optional string | unset | Environment-variable name for a future provider credential; no credential value is accepted in TOML. |
Restart the node after changing this policy. Validate the exact
citadel.toml before deployment:
citadel check --config /etc/citadel/citadel.toml[runtime.capabilities.custom_http_endpoints]
Section titled “[runtime.capabilities.custom_http_endpoints]”Opt-in policy for script registrations made through citadel.http.register.
Every registered route is served under the reserved /ext prefix; the script
never receives router access or the session bearer used for authentication.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Enables endpoint registration during runtime startup and reload. Disabled scripts cannot add external routes. |
max_request_bytes |
integer | 65536 |
Maximum buffered request body per invocation. |
max_response_bytes |
integer | 1048576 |
Maximum script response body. An oversized response becomes a generic 500. |
max_requests_per_minute |
integer | 120 |
Node-local fixed-window limit per endpoint, caller identity (or anonymous), and source IP. |
Only GET, POST, PUT, PATCH, and DELETE can be registered. A script
chooses auth = "public" or auth = "session" for each path. Runtime source
reloads preserve this operator-owned policy and publish a complete new registry
only after all registrations succeed.
[runtime.capabilities.events]
Section titled “[runtime.capabilities.events]”Opt-in, process-local event queue for citadel.events.emit and
citadel.events.subscribe. Version one is best-effort: Citadel does not
persist, retry, replicate, or replay accepted events after restart. Events are
therefore not a substitute for storage, a job queue, or cluster messaging.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Enables the node-local event surface. emit returns false while disabled or when an event is dropped. |
queue_capacity |
integer | 1024 |
Fixed maximum number of pending events for this node. |
max_event_bytes |
integer | 16384 |
Maximum opaque binary payload accepted by emit. |
max_events_per_minute |
integer | 600 |
Node-local fixed-window rate limit per namespace. |
[runtime.capabilities.shared_cache]
Section titled “[runtime.capabilities.shared_cache]”Opt-in mutable cache shared by all Lua, Python, and JavaScript runtime VMs on
this process. Without [cluster] it is node-local. With cluster.enabled,
nodes offer mutations to a bounded queue for a single durable global writer
lease and receive best-effort fenced fan-out. A successful API call is a local
cache update, not a cluster-wide commit. Values themselves stay non-durable and
are not replayed after restart; use durable storage when a value must survive a
restart. Script hot reloads retain the same node-owned cache.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Enables citadel.cache; calls fail while disabled. |
max_entries |
integer | 1024 |
Maximum entries across all namespaces on this node. Inserting a new key at capacity evicts the entry nearest expiry. |
max_value_bytes |
integer | 65536 |
Maximum opaque binary value accepted by set or cas. |
max_ttl_ms |
integer | 3600000 |
Maximum TTL for one value; expiration is lazy on subsequent cache access. |
Namespaces and keys use 1–80 ASCII alphanumeric, ., _, or - characters.
The API exposes get, set, delete, and versioned atomic cas; all four
are unavailable in before_realtime and after_realtime hooks, which remain
observational. Expired entries disappear lazily when that key or the cache is
accessed. Node metrics expose capacity evictions.
maps_dir is scanned once at startup: every well-formed .map (CMAP level
geometry, produced by the map cook tool) is loaded and indexed by file stem. When
a room is created, its chosen map name (from on_room_create) is resolved
against this catalog — a match logs the loaded geometry; a name with no matching
.map logs a warning so you catch a typo or an uncooked level. A malformed or
unreadable file is skipped, never fatal. The loaded geometry is the input a later
navmesh bake will consume; today loading validates the map and exposes its bounds
and triangle counts.
Hot-reload (opt-in via hot_reload) watches the selected entrypoint
(main.lua for Lua, main.py for Python, main.js for JavaScript) by polling its modification time and
size every hot_reload_poll_ms. For Lua, it additionally watches each static
JSON/CSV file successfully loaded during top-level initialization. On change it builds a fresh VM and static-data
catalog and swaps them in
under the same lock that serializes dispatch and the tick, so a reload never
interleaves with a running handler. It is failure-safe: a script that fails to
read/parse/register, a data file that fails containment/size/parse/schema
validation, or a script that registers no handlers at all, is rejected and the
previously-loaded script and parsed data catalog keep serving. In-VM globals
reset on each successful reload.
For static data, Citadel accepts only /-separated relative .json and .csv
paths. It canonicalizes both the configured root and each requested target,
rejects paths that escape through a symbolic link, and never reveals the host
path to Lua. It also never exposes io, os, or package. Citadel itself does
not write the data tree; use operating-system permissions or a read-only volume
when the operator also needs to prevent other local processes from editing it.
See Use shared static gameplay data for the ordered
setup and reload workflow.
[lag_diagnostics]
Section titled “[lag_diagnostics]”Optional, server-owned ingestion for the explicit JavaScript lag-recorder
debug feature. It is disabled by default. It has no player-facing HTTP API and
does not enable recording remotely: opted-in, authenticated SDKs still require
a trusted native START followed by a per-client FLUSH grant. See
Lag diagnostics operations for the
collection lifecycle and metric limitations.
Raw ingestion is node-local private filesystem storage. Derived analysis and
the report Console require the SQLite, PostgreSQL, or CockroachDB report
adapter. A MongoDB deployment may collect raw artifacts only; analyze = true
is rejected fail-closed until MongoDB gains a durable report adapter. Set
analyze = false for MongoDB captures.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled |
bool | false |
Enables trusted capture control and state-gated ingest. Disabled nodes reject uploads. |
raw_root |
absolute path | (unset) | Required when enabled. Private root for staging, manifests, leases, and compressed raw artifacts; never configure a served/public path. |
active_key_id |
string | (unset) | Required when enabled. Selects the signing key for new one-use uploads. |
upload_hmac_keys |
object | {} |
Base64url-without-padding HMAC-SHA256 keyring. The active decoded key must be at least 32 bytes and is redacted from diagnostics. |
allowed_origins |
string array | [] |
Exact HTTPS browser origins; loopback HTTP is allowed only for local development. Empty disables CORS. Wildcards, paths, queries, and fragments are rejected. |
max_compressed_bytes |
integer | 4194304 |
Global gzip upload cap; every grant can narrow it. |
max_decompressed_bytes |
integer | 67108864 |
Bounded post-gzip CLAG validation cap. |
max_decompression_ratio |
integer | 32 |
Maximum decompressed/compressed expansion ratio (1–128). |
max_concurrent_uploads |
integer | 4 |
Node-local concurrent upload admissions (1–64). |
max_raw_bytes |
integer | 4294967296 |
Node-local raw-root quota; must cover at least one maximum upload. |
retention_hours |
integer | 168 |
Raw-artifact retention period (1 through 8760 hours). Derived reports may remain after raw removal. |
shared_raw_store |
bool | false |
Reserved compatibility field. It does not enable clustering today. |
The current lease, replay-marker, recovery, and capture-control implementation
is node-local. Therefore lag_diagnostics.enabled = true is rejected whenever
cluster.enabled = true, even if shared_raw_store is set. This is a
fail-closed safety rule, not a request to place raw files in the database.
[console]
Section titled “[console]”Static operator credentials for the admin console and
its /console/v1 API. Passwords are never echoed in diagnostics or the
/console/v1/config browser.
| Key | Type | Default | Notes |
|---|---|---|---|
username |
string | "admin" |
Operator login username. Must not be empty. |
password |
string | "password" |
Grants the admin role (full access). Also settable via CITADEL_CONSOLE_PASSWORD. The startup banner warns while the default is unchanged. |
viewer_password |
string | (unset) | Optional password granting the read-only viewer role for the same username. Must not be empty when set. |
token_expiry_sec |
integer | 3600 |
Console bearer-token lifetime in seconds. Must be >= 1. |
Environment overrides
Section titled “Environment overrides”CITADEL_-prefixed variables override file values:
| Variable | Overrides |
|---|---|
CITADEL_LOG_LEVEL |
logging.level |
CITADEL_HTTP_BIND |
http.bind |
CITADEL_NODE_ID |
server.node_id |
CITADEL_PUBLIC_ADDR |
server.public_addr |
CITADEL_DATABASE_URL |
database.url |
CITADEL_CONSOLE_PASSWORD |
console.password |
CITADEL_SENTRY_DSN is deliberately not a TOML field or config-browser value:
when set, it enables optional Sentry telemetry. The server starts and the local
journal continues normally when the variable is absent or the endpoint is
unavailable. Set CITADEL_ENVIRONMENT to label those telemetry events; it
defaults to production. CITADEL_BUGSINK_DSN remains a lower-priority
compatibility alias; see Telemetry for
Sentry and Bugsink setup.
Unknown CITADEL_ variables are ignored by configuration loading so future
keys do not break older binaries. CLI flags (see the
CLI reference) override these.
Validation
Section titled “Validation”citadel check resolves and validates the config, builds the selected runtime
entrypoint if one is present, then prints a non-secret summary. Validation checks
socket-address syntax, non-empty node_id and logging.level, per-transport
rules for enabled transports, and the [runtime] rules (implemented
adapter/tier, non-empty scripts_dir, non-blank static_data_dir when
set, a non-zero static_data_max_file_bytes when that root is set,
deadline_ms >= 1, a non-zero tick_deadline_ms when set, and a non-zero hot_reload_poll_ms when
hot_reload is on) when the runtime is enabled. A broken main.lua or
main.py is reported during check, before listeners start. Diagnostics name
the offending field and never echo secrets.