Authoritative gameplay bridge
import { Tabs, TabItem } from ‘@astrojs/starlight/components’;
The authoritative gameplay bridge makes your GameScript the sole authority over protected gameplay. In an authoritative match every protected client action — transform-sync input, relay-mode owner state, replicated-variable writes, and avatar spawn requests — is decoded and ownership-verified by Rust’s structural stage, delivered to your script as a normalized event, and only mutates authoritative state or replicates to peers after your script’s fenced, batch-atomic answer authorizes it. Nothing protected mutates before your script decides.
The script surface is one handler, citadel.on_input, plus the existing
broadcast/send/actor command APIs and the citadel.rewind_query host API.
It is available identically in all three shipped runtimes (Lua, JavaScript,
Python).
Authoritative vs. relay
Section titled “Authoritative vs. relay”The bridge activates only for an authoritative match: a node started with
runtime.require_script = true (see the readiness gate) whose room is bound to a
loaded script. For those matches:
- Transform input,
KIND_NA_STATE,KIND_NA_PRESENCE, andKIND_REP_DELTAroute throughon_input; the direct apply paths are unreachable. - A player-slot grant is refused inside a bound match (the script owns spawns).
- Custom message kinds and
KIND_POSITIONare not relayed inside a bound match. The legacyon_messagerelay path is closed there, so a script cannot reachmove_actor/set_transformor an unscoped cross-room send outside the validator. Delivering custom kinds through the fenced batch (themessageevent) is planned; until then such frames are dropped fail-closed.
When require_script = false (the default unzip-and-run mode) there is no
bridge: the built-in relay applies owner state, integrates input, and fans out
spawns exactly as before, byte for byte. on_input is simply never called.
Normalized events
Section titled “Normalized events”citadel.on_input(handler) registers a per-event handler. The bridge calls it
once for every normalized event in a delivered batch. Each event carries a stable
kind tag plus the decoded, ownership-verified intent:
kind |
Fields |
|---|---|
transform_input |
object_id, ownership_epoch, input_seq, sim_tick, dt, move_velocity, payload, has_fire, fire? |
actor_state |
object_id, transform |
replicated_var |
object_id, class_id, result_id, field_count |
spawn_request |
archetype_id, transform |
Every event also carries event_id, participant, user_id (absent for
guests), match_id, and tick. Vectors are {x, y, z} tables in Lua and
[x, y, z] arrays in JavaScript and Python.
Decisions
Section titled “Decisions”The handler returns one decision per event. The bridge collects all decisions into a fenced answer for Rust to validate and materialize:
- Accept — materialize the client’s canonical effect (integrate the input,
apply the reported state, apply the replicated write, register the spawn).
Return
nil/undefined/None,true,"accept", or{ decision = "accept" }. - Reject — nothing mutates or replicates. Return
false,"reject", or{ decision = "reject", reason_code = N, reply = "..." }. - Correct — materialize your value instead of the client’s (the
authoritative state carries your value, never the client’s bytes). Return
{ decision = "correct", transform = { position, rotation, velocity } }.
def on_input(event): if event[“kind”] == “transform_input”: if event[“move_velocity”][0] > 2000: return {“decision”: “reject”, “reason_code”: 1} # speed hack return None # accept if event[“kind”] == “actor_state”: return { “decision”: “correct”, “transform”: {“position”: [0, 0, 0], “rotation”: [0, 0, 0, 1], “velocity”: [0, 0, 0]}, } return None
citadel.on_input(on_input)
</TabItem></Tabs>
## Batch-level commands
Inside `on_input` your script may also emit effects with the existing commandAPIs — `citadel.broadcast`, `citadel.send`, `citadel.move_actor`,`citadel.spawn_actor`, `citadel.despawn_actor`, and the physics commands. Theyare collected into the same fenced answer and validated before they materialize:messages are scope-checked against match membership, object mutations against thematch's world, and physics commands against the match's declared physicscapability.
:::caution[`persist` and `schedule` are validated but not yet executed]The messaging, actor, and physics commands above materialize today. Persist andschedule commands are capability-gated and quota-bounded by the validator, butno executor is wired behind them yet: an authorized `persist` or `schedule`validates and then no-ops — it does **not** write durable state or enqueue atask. The durable-effect executor is planned.:::
## Fire/hit — the rewind host API
Owner decision: **Rust owns the bounded lag-compensated rewind query; your scriptdecides the consequence.** A `transform_input` event whose `has_fire` is truecarries the client's `fire` intent (origin, direction). Rust does **not**auto-resolve the hit. Instead, call `citadel.rewind_query` from `on_input` to getthe server-computed candidate hits, then decide damage/death/cooldown yourself(e.g. with a `send`/`broadcast`). The rewind window is server-clamped from theshooter's measured lag and the hub's `RewindConfig` — the client's tick is nevertrusted.
<Tabs syncKey="engine"><TabItem label="Lua">```luacitadel.on_input(function(event) if event.kind == "transform_input" and event.has_fire then local result = citadel.rewind_query( event.participant, event.fire.origin, event.fire.direction, event.tick) for _, hit in ipairs(result.hits) do citadel.send(hit.participant, 100, "hit") -- your consequence end end return nilend)def on_input(event): if event[“kind”] == “transform_input” and event[“has_fire”]: result = citadel.rewind_query( event[“participant”], event[“fire”][“origin”], event[“fire”][“direction”], event[“tick”]) for hit in result[“hits”]: citadel.send(hit[“participant”], 100, “hit”) # your consequence return None
citadel.on_input(on_input)
</TabItem></Tabs>
Each hit is `{ object_id, participant, point, distance }`. `rewind_query` nevermutates state; it is a read-only query.
## Fencing and fail-closed guarantees
Every batch that crosses the script boundary carries six mandatory fencingfields — `protocol_version`, `generation`, `match_id`, `clock_epoch`, `tick`, and`batch_id` — and the answer must echo them exactly. The validator is the soleauthority on acceptance and is **batch-atomic**: a single invalid, out-of-scope,over-quota, or unauthorized command rejects the *entire* batch; nothing in itmaterializes. A stale-generation (post-reload), cross-match, cross-epoch,duplicate, or incomplete answer is rejected whole.
The bridge is fail-closed end to end. If your `on_input` handler errors, timesout, or the batch is never answered (script fault, worker death), **nothingmutates** — the match is closed match-locally with a requeue hint rather thanapplying a default. Per-batch effects are bounded by measure-first`BridgeQuotas` (command count, body bytes, reply bytes, recipients, persist andschedule ops).
:::caution[Known limitation: transform snapshots are node-global]Authoritative *writes* are fenced per match, but the transform **snapshot** thatfans out to clients is still node-global today: `snapshot_tick` reads one sharedtransform world with no per-room filter. Running two or more concurrentauthoritative matches on a single node can therefore leak transform state acrossmatches — a client in match A can receive snapshots of match B's objects(cross-match visibility). This is a known limitation, not a feature: per-roomsnapshot scoping is required before any multi-match-per-node authoritativedeployment. Until it lands, run a single authoritative match per node.:::