Transform sync (snapshots)
Citadel ships authoritative transform synchronization out of the box: the
server owns every networked object’s transform, advances it on a fixed sim tick,
and streams per-client delta snapshots on the unreliable QUIC/WebTransport
datagram path. Clients render remote/server objects interpolated in the past
(roles RemoteInterpolated / ServerSimulated / StaticReplicated). It also
ships client-side prediction + reconciliation, server rewind (lag
compensation), and adaptive congestion for the OwnerPredicted role — see
Owner prediction, reconciliation & server rewind
below.
It is built on the shared netcode codecs & wire foundation
(bit packing, quantized position/rotation, the ack window, the interest grid), so
every SDK encodes identical bits. The legacy KIND_POSITION relay
(envelope format) is unaffected — transform sync is additive.
For drop-in player replication (a client announces its avatar on connect and the server spawns it on every peer, with no per-object wiring), see networked actors, which layers presence + dynamic spawn on top of this snapshot path.
Enabling it
Section titled “Enabling it”Off by default. In citadel.toml:
[transport.transform_sync]enabled = truesend_rate_hz = 20 # snapshot packets/sec (the client sizes its buffer from this)sim_hz = 60 # world simulation ticks/secbudget = 16 # safe full-baseline budget; 0 deliberately opts out of the MTU capdemo_movers = 2 # spawn N built-in server-simulated demo avatars (0 = none)player_slots = 0 # hand each client an owner-predicted player object (0 = off)# Networked-Actor archetypes that use server validation + client prediction.# All unlisted archetypes stay byte-identical Relay by default.predicted_authoritative_archetypes = [2]Two zero-config demo modes (pick one — they share the low object ids, so
player_slots takes precedence and suppresses demo_movers):
demo_movers = Nspawns server-simulated avatars (object ids1..=N) on opposing paths. They move on their own, so a two-client demo shows smooth remote interpolation with no client input and no game script.player_slots = Nis the client-owned player demo: the server hands each connecting transform-sync client ownership of one object (ids1..=N, by join order) asOwnerPredicted, and frees it on disconnect. Each client drives its own object with input (client-side prediction + server authority) while every other client sees it interpolated — “two clients see each other move.” See Owner prediction.
The handshake and frames
Section titled “The handshake and frames”Four envelope kinds on the reserved transform range (see netcode codecs for the range reservation):
| Kind | Const | Direction | Delivery | Purpose |
|---|---|---|---|---|
| 7 | KIND_TSYNC_HELLO |
C↔S | reliable | Negotiate world bounds, precision, quat mode, send/sim rate |
| 8 | KIND_TSYNC_SNAPSHOT |
S→C | unreliable | Per-client delta snapshot (the hot path) |
| 9 | KIND_TSYNC_INPUT |
C→S | unreliable | Owner input bundle (redundant frames + optional fire) |
| 10 | KIND_TSYNC_ACK |
C→S | unreliable | Ack the newest applied snapshot (+ 32-bit history) |
| 11 | KIND_TSYNC_ROLE |
S→C | reliable | Ownership/role/relevancy transition |
| 12 | KIND_TSYNC_REWIND |
S→C | reliable | Authoritative lag-compensated hit result |
Flow:
- The client sends
KIND_TSYNC_HELLO(empty body) over the reliable path to opt in. The server replies with its negotiation — position bounds, velocity bounds, quaternion mode (9/10/15-bit smallest-three), and the send/sim rates. Both sides build the identical codec. - The server streams
KIND_TSYNC_SNAPSHOTdatagrams. Each carries an absolutesnapshot_id, the absolutebase_snapshot_idit was diffed against (0= full baseline), the current send rate, a list of removed object ids, and the changed objects. Per object:object_id,gen_epoch, a 3-bit changed mask, then the present fields (quantized position, smallest-three rotation, quantized velocity). Absent fields are unchanged and filled from the base. - The client reconstructs
full[id] = full[base] − removed + updates, renders each object interpolated in the past, and acks withKIND_TSYNC_ACK.
Correctness under loss and reorder
Section titled “Correctness under loss and reorder”QUIC datagrams are unreliable and unordered, so the snapshot protocol is explicitly loss/reorder-safe:
- Every snapshot names an absolute
base_snapshot_id. The server only ever diffs against a baseline the client has acked (and the server still holds), so a delta can never reference a base the client lacks. - The client discards any snapshot whose base it does not hold, and applies only snapshots strictly newer than the last it applied (monotonic guard). A discarded snapshot is recovered by the next one whose base the client holds — no explicit retransmit.
- Objects entering/leaving a client’s area of interest are handled by set
membership: an exiting object is listed in
removed; a re-entering object is absent from the base and sent as a fresh full baseline.gen_epochguards only object-id reuse / respawn. - QUIC owns pacing. The application never runs a second congestion controller;
under pressure it sends fewer/lower-priority objects per snapshot (
budget), and QUIC decides when bytes go out.
Client rendering
Section titled “Client rendering”Clients render remote/server objects in the past, interpolating between the two buffered snapshots that bracket render time: Hermite position (when velocity is replicated) + slerp rotation, with a jitter buffer sized from the send rate and bounded extrapolation when the buffer drains. This is the reusable client runtime shared across engine SDKs.
The jitter buffer is adaptive. Its render delay is a multiple of the send
interval that starts at a safe ceiling (2.5×) and, per applied snapshot, decays
toward a floor (1.5×) on a clean link and grows back toward the ceiling when a
snapshot is lost (detected as a gap in applied snapshot ids). On localhost/LAN
with no loss it converges to the floor automatically — the lowest latency that
still guarantees two samples to interpolate between — while a lossy link keeps the
larger margin. No configuration; the same logic runs in the Rust RemoteWorldView
(unit-tested) and its faithful C++ port. Higher send_rate_hz shrinks the delay
further because the buffer is sized in packets, not seconds (e.g. 2.5× is
125 ms at 20 Hz but 42 ms at 60 Hz; the floor is 75 ms vs 25 ms).
Unreal
Section titled “Unreal”The UCitadelTransformSync component (clients/unreal/) binds a replicated actor
to a server object id and applies the interpolated authoritative transform each
frame:
GetGameInstance->GetSubsystem<UCitadelTransformSyncSubsystem>->OptIn;
UCitadelTransformSync* Sync = Actor->CreateDefaultSubobject<UCitadelTransformSync>(TEXT("Sync"));Sync->ObjectId = 1;Sync->Role = ECitadelSyncRole::ServerSimulated;Sync->bHermitePosition = true; // needs replicated velocitySee clients/unreal/README.md for the full setup and the manual two-client demo
(including injecting loss/latency with a network conditioner).
CitadelTransformSync is a MonoBehaviour over the shared native transform
runtime. Route KIND_TSYNC_HELLO and KIND_TSYNC_SNAPSHOT from your connection’s
single poll loop to HandleEnvelope; it creates the Rust view, applies snapshots,
and sends KIND_TSYNC_ACK itself. Remote objects receive the adaptive
Hermite+slerp sample on their Unity Transform.
var sync = remoteActor.AddComponent<CitadelTransformSync>;sync.Client = connection.Client;sync.ObjectId = objectId;
// In the one envelope dispatcher:sync.HandleEnvelope(kind, payload, payloadLength);For a local predicted owner, set IsLocalOwner = true. Your input controller
still moves immediately; the component consumes the authoritative state plus the
highest contiguous input acknowledgement to make visual corrections. Unity uses
metres by convention, while Citadel’s shared runtime uses centimetres, so the
component converts positions at its engine boundary.
CitadelTransformSync is the matching Node3D surface. Bind the scene’s shared
CitadelClient, then route transform envelopes from its one poll loop. The
GDExtension calls the same citadel_transform_view_* C ABI as Unity; Godot does
not maintain a second snapshot decoder.
var sync := CitadelTransformSync.newsync.object_id = object_idsync.bind_client(client)add_child(sync)
# In the connection dispatcher:sync.handle_envelope(client, kind, payload)The Godot native GDExtension remains a manual in-editor integration step. Its required behavior is identical to Unity: remote actors interpolate; local owners apply input immediately and correct from the acknowledged authoritative state.
P3 hardening defaults
Section titled “P3 hardening defaults”- MTU budget: snapshots use a 1,200-byte safe payload ceiling, including the two-byte envelope kind. The default snapshot budget is capped at 16 full object updates for this envelope size; larger worlds split naturally across snapshot ticks rather than relying on IP fragmentation.
- AOI scale: the uniform grid stress test covers 4,096 entities. A viewer’s precise relevance pass sees only the 3×3 neighboring cells (at most nine entities in the test distribution), not a global fan-out.
- Adaptive buffer: the shared runtime starts at 2.5 send intervals, decays to 1.5 on a clean link, and regrows on snapshot-id gaps. This retains the existing 20–60 pps tuning; do not add an engine-local multiplier.
- Loss-tail harness: at deterministic 5% loss with a six-tick recovery, the model pins delivered datagram p99 at 1 tick versus at least 6 ticks for an ordered reliable stream. It demonstrates the head-of-line tradeoff; Quinn/QUIC still owns real path pacing and congestion control.
Owner prediction, reconciliation & server rewind
Section titled “Owner prediction, reconciliation & server rewind”For the object a client owns, interpolating in the past would feel laggy, so
the client predicts and the server lag-compensates hits. The server assigns
ownership on the reliable KIND_TSYNC_ROLE frame (OwnerPredicted + a monotonic
ownership_epoch).
Ownership can be assigned two ways:
- Server-driven player slots (config
player_slots > 0): the gateway assigns a player object to each client the moment it opts in (HELLO) and sends it theKIND_TSYNC_ROLE. Since that role frame reaches only the owner, the Unreal client latches its own participant id from it automatically — no manual id wiring is needed for the built-in demo. - Game-driven: game logic calls the server ownership API to hand a specific object to a specific participant (e.g. on spawn), for full control over which actor a player drives.
Prediction & reconciliation
Section titled “Prediction & reconciliation”- The owner applies its input immediately (input-latency-free) and sends
KIND_TSYNC_INPUT— a redundant bundle of its last N individually-sequenced input frames (input_seq,sim_tick,dt,object_id,ownership_epoch, a kinematicmove_velocity, opaque game payload, optional fire), plus a piggybacked snapshot ack. Redundancy makes a single lost input self-heal. - The server validates each input (ownership, epoch, rate, bounds), applies inputs
strictly in seq order (buffering out-of-order frames until the gap fills),
and echoes the highest contiguous applied seq back to the owner as
last_input_seq(a per-owner field on that client’s snapshot). - On receiving the ack the owner reconciles: it snaps its simulation/collision
state to the authoritative post-input state, drops inputs
<= last_input_seq, and replays only inputs> last_input_seqin order. Error smoothing applies to the rendered visual offset only (0.95 for ≤ 25 cm, 0.85 for ≥ 1 m), never the sim state; a teleport-scale error hard-snaps. The result: no visible snap-back.
Networked-Actor owner modes
Section titled “Networked-Actor owner modes”Networked Actors choose movement policy on the server per archetype:
- Relay (default): the owner sends the existing
KIND_NA_STATEraw transform. Its wire bytes and behavior are unchanged for existing clients; it is convenient for prototypes but client-authoritative. - PredictedAuthoritative: list the archetype in
transform_sync.predicted_authoritative_archetypes. The server sends the normalKIND_TSYNC_ROLEafter the owner’s spawn, rejectsKIND_NA_STATEfor that object, and reusesKIND_TSYNC_INPUTvalidation, contiguous acknowledgements, and reconciliation. A client cannot select this policy itself.
For Unreal, call SetPredictedAuthoritative(ArchetypeId, true) before
AnnouncePresence. The local native actor is bound to UCitadelTransformSync as
soon as its self-spawn arrives, then the ordered role frame activates prediction.
Server rewind (favor-the-shooter)
Section titled “Server rewind (favor-the-shooter)”- A fire command rides the input bundle and is resolved server-side exactly once, in seq order. The client never resolves the hit.
- The server computes and clamps the rewind time from its own per-connection state — measured one-way delay (not RTT/2) + the client’s interpolation delay + a max-unlag clamp. A client-supplied timestamp is only a hint, never trusted.
- It rewinds hit-eligible objects (~1 s of history) to the state the shooter saw,
runs the hit test, and returns the authoritative result on
KIND_TSYNC_REWIND. Lag compensation disables above an RTT cutoff (~220 ms), where the shot resolves at present state instead. - Hit registration is against server-side kinematic capsules, not per-bone animated hitboxes (a later initiative).
Adaptive congestion
Section titled “Adaptive congestion”QUIC owns byte pacing; Citadel never runs a second congestion window. The application only steps a coarse send rate (good ↔ floor) and the per-snapshot object budget from composite signals (datagram loss / ack age / jitter / send-queue drops, with RTT as one input — not a bare RTT threshold), with hysteresis so the rate cannot flap, and ramps interpolation delay slowly so a rate step never jerks the rewind time.
Unreal (owner)
Section titled “Unreal (owner)”// Player-slot mode auto-latches the participant id from the assign-ROLE, so this// is only needed when your game assigns ownership itself:// Sub->SetLocalParticipantId(MyParticipantId);
// In your pawn each input tick (only the owner write path — e.g. bind your// movement axis to this). The component drives the owned actor; other clients see// it interpolated:Sync->SubmitInput(MoveVelocityCmPerSec, DeltaSeconds);
// Fire: resolved server-side; the result arrives on OnRewindResult.Sync->RewindHitTest(MuzzleWorldPos, AimWorldDir);Sync->OnReconciled.AddDynamic(this, &AMyPawn::HandleReconciled);Sync->OnRewindResult.AddDynamic(this, &AMyPawn::HandleHit);Limitations
Section titled “Limitations”- Kinematic capsule hitreg only (no per-bone animated hitboxes); no server-side physics — prediction is kinematic (CMC-style), not full-physics resimulation.
- WebSocket clients (reliable-only) cannot use the unreliable hot path.
- A single global interest grid until matches land; per-match scoping is a soft dependency.
- The lag profile (one-way delay / interpolation delay / RTT) is set explicitly; wiring it from live QUIC path stats is a follow-up.