Unity QUIC sample (C#)
clients/unity/ is the Unity C# SDK — hand-written bindings (Citadel/) plus a
minimal sample (Demo/) — that connects to a Citadel server through the native
C ABI (citadel-client-ffi, ABI version 1) over QUIC.
clients/ is SDK-only: source bindings and an import README, with the native
plugin built at package time (not committed). The sample runs the
move-and-broadcast loop end to end: a local
cube streams its position to the server as KIND_POSITION, and the server relays
it to peers as KIND_PEER_POSITION, which the sample renders as one cube per
remote session. It mirrors the native demo-client but
drives the shared C ABI that Unreal or Godot would also bind.
Build and install the native plugin
Section titled “Build and install the native plugin”From the repo root, build the cdylib and install it into the SDK:
:: Windows (cmd)make unity-plugin# Windows (PowerShell).\make unity-plugin# macOS / Linuxmake unity-pluginBoth run cargo build --release -p citadel-client-ffi. On Windows the target
copies citadel_client_ffi.dll into clients/unity/Plugins/x86_64/; on macOS
it copies libcitadel_client_ffi.dylib into clients/unity/Plugins/macOS/.
These files are built, not committed. The matching release package contains the
same platform-native library.
Managed C# API
Section titled “Managed C# API”The scripts under clients/unity/Citadel/ bind the C ABI 1:1:
-
CitadelNative— raw[DllImport("citadel_client_ffi")](Cdecl) entry points plus theCitadelStatusenum. Strings are marshaled as NUL-terminated UTF-8byte[], Cboolas a 1-byte value, anduintptr_tasUIntPtr, so the binding works on Unity’s Mono and IL2CPP marshaling.ExpectedAbiVersion = 1. -
CitadelClient : IDisposable— the managed wrapper you use:CitadelClient.CheckAbiVersion; // throws on ABI mismatchvar client = CitadelClient.ConnectQuic("127.0.0.1:7351", "localhost", insecure: true);// also: CitadelClient.ConnectWebSocket("ws://127.0.0.1:7352/")AuthHandshakeResult auth = client.AuthenticateGuest;// or: client.AuthenticateWithToken(sessionToken)byte[] body = CitadelProtocol.EncodePosition(x, y);client.Send(CitadelProtocol.KindPosition, body, reliable: false);var buffer = new byte[256];PollResult r = client.Poll(buffer, out ushort kind, out int length, out bool truncated);// r is Message, Again, or Disconnectedstring err = client.LastError; // native message after a failureclient.Dispose; // frees the native handlePollis non-blocking:Messagewrites an envelope into your caller-ownedbuffer(withkind/length, andtruncatedif it did not fit),Againmeans nothing is ready this frame, andDisconnectedmeans the connection is closed and drained. A finalizer frees the handle ifDisposeis missed. -
CitadelProtocol— wire kinds and (de)serialization:KindPosition = 1,KindPeerPosition = 2,KindRpcRequest = 3,KindRpcResponse = 4,RpcStatusOk/RpcStatusError,EncodePosition,TryDecodePosition,TryDecodePeerPosition, and the RPC helpersEncodeRpcRequest/TryDecodeRpcResponse. It handles the mixed endianness explicitly so it is correct on any host.
Wire protocol
Section titled “Wire protocol”Matching citadel-wire and the native demo:
KIND_POSITION= 1 — body: two little-endianf32(x, y).KIND_PEER_POSITION= 2 — body: an 8-byte big-endian sender session id followed by the same two-f32position payload.
The sample maps world (x, y) to Unity (x, 0, y) so cubes slide on the ground
plane.
Sample components
Section titled “Sample components”The MonoBehaviours under clients/unity/Demo/:
CitadelConnection— owns the client, verifies the ABI version, connects over QUIC to127.0.0.1:7351(insecure dev cert), performs the guest realtime handshake onStart, and disposes onOnDestroy.LocalPlayer— reads WASD/arrow input, moves its transform on the X/Z plane, and sendsKIND_POSITIONunreliable at a fixed cadence.PeerManager— the single owner of the poll loop. It drains the shared native poll queue each frame and dispatches by kind:KIND_PEER_POSITIONis rendered as one cube per sender session id, andKIND_RPC_RESPONSEis forwarded to the optionalRpcClient.HandleResponse.RpcClient— issues request/response RPCs and correlates replies byrequest_id. See Calling an RPC.
Calling an RPC
Section titled “Calling an RPC”RpcClient is the client half of the RPC
request/response wire format, built on
the unchanged poll-based C ABI — correlation lives entirely in the managed
layer. The flow:
CallRpc(string method, byte[] payload, Action<CitadelRpcResult> onReply)generates a monotonicrequest_id, encodes the body withCitadelProtocol.EncodeRpcRequest, and sends it as aKindRpcRequestreliable message. Once the send goes out, it registersonReplyin a pending map keyed byrequest_id.- Because the native poll queue is shared across kinds, exactly one component
drains it —
PeerManager. When it polls aKindRpcResponse, it forwards the body toRpcClient.HandleResponse. HandleResponsedecodes the body withCitadelProtocol.TryDecodeRpcResponse, looks up the pending callback byrequest_id, and invokes it with aCitadelRpcResult { RequestId, Ok, Payload }. Unknown or duplicate ids are dropped with a warning.
CitadelRpcResult also offers TryReadBeInt32(out int) and PayloadAsText
helpers for common reply shapes.
Press R to fire the built-in sample, which calls two handlers defined in
game/main.lua:
add— two big-endianint32operands; the reply is theirint32sum.ping— a liveness check; the reply is the textpong.
Both results are logged via Debug.Log.
// Two big-endian int32 operands -> the handler replies with their int32 sum.byte[] payload = /* 7, 35 as big-endian int32 */;rpcClient.CallRpc("add", payload, result =>{ if (result.Ok && result.TryReadBeInt32(out int sum)) Debug.Log($"add = {sum}"); else Debug.LogWarning($"add failed: {result.PayloadAsText}");});Run it (manual)
Section titled “Run it (manual)”-
Build the plugin:
make unity-plugin(cmd or macOS/Linux) or.\make unity-plugin(PowerShell). -
Import
clients/unity/Citadel/andDemo/into a Unity project’sAssets/. On Windows copy the DLL intoAssets/Plugins/x86_64/and set x86_64 / Standalone Windows. On macOS copy the dylib intoAssets/Plugins/macOS/and enable macOS plus the matching Apple Silicon or Intel CPU. -
Build a scene with a
CitadelConnectionobject, a cube withLocalPlayer, and aPeerManagerobject; wire the connection reference intoLocalPlayerandPeerManager. Add a top-down camera and a light. To try RPC, add anRpcClientcomponent, wire itsconnectionreference, and set thePeerManager.rpcClientreference so polled responses are dispatched to it. -
Start the server:
Terminal window cargo run -- --config examples/configs/demo.toml serve -
Press Play, move the cube with WASD/arrows, and open a second client (another Unity instance or
cargo run -p demo-client) to watch its cube track in real time. Press R to fire the sampleadd/pingRPCs and watch the replies in the console.
See clients/unity/README.md for detailed scene setup and
troubleshooting.