Skip to content

Chat (console API + realtime relay pattern)

Citadel’s chat surface has two distinct parts today:

  1. A console-operator API (/console/v1/chat*) that models channels, paged message history, and moderation (tombstone delete). This is a history/moderation model, not a delivery mechanism.
  2. A realtime delivery pattern built from primitives that already ship: your Lua script relays a custom envelope kind with citadel.on_message + citadel.broadcast/citadel.send, and each client SDK sends/receives that kind. This relay pattern needs no chat-specific wire kind or client method — see Realtime delivery today.

Source: src/services/chat.rs (thin service over the repository), src/repository/chat.rs (channel/history model + in-memory backend), src/repository/pg/chat.rs and src/repository/sqlite/chat.rs (durable backends), src/http/console_api/chat.rs (HTTP handlers), src/runtime/lua.rs (the citadel.on_message/citadel.broadcast/citadel.send host API described on the Lua runtime API page).

pub enum ChannelType {
Room, // "room" — a named, joinable room (the common "world chat"/lobby shape)
Group, // "group" — a group's channel
Direct, // "direct" — a direct (1:1) channel between two accounts
}

Serialized as the lowercase snake_case tokens shown above. A channel is created implicitly by its first appended message; the type given at creation is fixed for the channel’s lifetime — a later append’s channel_type is ignored once the channel exists.

History is kept in a per-channel bounded ring (default 1000 messages, DEFAULT_CHANNEL_HISTORY_CAP): appending past the bound evicts the oldest message. Message ids are per-channel, sequential, and monotonic starting at 1 — they are never reused, even past eviction, so a page’s before cursor stays meaningful across the channel’s whole lifetime.

Channels and their history are persisted behind the repository seam: on the Postgres and SQLite backends they survive a node restart (stored in a single chat_messages table keyed by (channel_id, id), with the retention bound enforced on append). The in-memory backend stays non-durable by design — a restart clears it.

Every route below requires a console bearer token from POST /console/v1/login (see Login and roles):

Authorization: Bearer <token>
Role Access
admin Read and mutate (append, delete/tombstone).
viewer Read-only. A mutation attempt returns 403 forbidden.

List every channel, most-recently-active first (ties broken by channel id).

Auth: bearer token, any role.

Query parameters

Name Type Required Meaning
filter string no Case-sensitive substring match over the channel id.
limit integer no Max channels returned. Default 100, capped at 500.

Response 200 OK

{
"items": [
{ "channel": "raid-1", "channel_type": "group",
"messages": 12, "last_activity_unix_ms": 1751791003000 },
{ "channel": "lobby", "channel_type": "room",
"messages": 2, "last_activity_unix_ms": 1751791000123 }
],
"total": 2
}
Field Type Meaning
channel string Channel id, chosen by whoever first appended to it.
channel_type string room, group, or direct.
messages integer Total messages ever appended (monotonic; unaffected by eviction/tombstoning — an activity counter, not the retained row count).
last_activity_unix_ms integer Most recent append’s time (Unix milliseconds).
total integer Total channel count before filtering/limiting.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.

Example

Terminal window
curl -s "http://127.0.0.1:7350/console/v1/chat?filter=lobby&limit=50" \
-H "Authorization: Bearer $TOKEN"

Append a message. This is the console-side message producer — until realtime wire delivery lands, this route is how history gets populated end to end. Creates the channel (as channel_type) on first use; channel_type is ignored if the channel already exists.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
channel string yes The channel id (created if it doesn’t exist).

Request body

{ "sender": "alice", "content": "hello world", "channel_type": "room" }
Field Type Required Meaning
sender string yes The sending identity, as presented by the operator. Not validated against the account/session stack at this layer.
content string yes The message body.
channel_type string no room (default), group, or direct. Only used if the channel does not exist yet. Unknown fields in the body are rejected.

Response 200 OK

{ "id": 1, "sender": "alice", "content": "hello world",
"created_at_unix_ms": 1751791000000, "deleted": false }

Errors

Status Code Cause
400 invalid_request Malformed body, unknown field, or unknown channel_type token.
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.

Audited as chat.message.append.

Example

Terminal window
curl -s -X POST http://127.0.0.1:7350/console/v1/chat/lobby/messages \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"sender":"alice","content":"hello world"}'

Paged history for one channel, newest first. An unknown channel returns an empty page rather than an error — reading history never requires the channel to have been created yet.

Auth: bearer token, any role.

Path parameters

Name Type Required Meaning
channel string yes The channel id.

Query parameters

Name Type Required Meaning
limit integer no Max messages returned. Default 50, capped at 200.
before integer no Resume cursor: only messages with id < before (pass the previous page’s oldest returned id).

Response 200 OK

{
"channel": "lobby",
"items": [
{ "id": 2, "sender": "bob", "content": "hi alice",
"created_at_unix_ms": 1751791000123, "updated_at_unix_ms": 1751791000123,
"revision": 1, "last_event_id": 2, "deleted": false },
{ "id": 1, "sender": "alice", "content": "",
"created_at_unix_ms": 1751791000000, "updated_at_unix_ms": 1751791000345,
"revision": 2, "last_event_id": 3, "deleted": true }
],
"next": 1
}
Field Type Meaning
channel string The channel this page belongs to.
items array Messages, newest first. Every message includes created_at_unix_ms, updated_at_unix_ms, revision, and last_event_id. content is blanked and deleted: true for a tombstoned message.
next integer | absent Cursor for the next page (pass as before); present only when the page was full and older messages may remain.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.

Example

Terminal window
curl -s "http://127.0.0.1:7350/console/v1/chat/lobby/messages?limit=50" \
-H "Authorization: Bearer $TOKEN"

DELETE /console/v1/chat/{channel}/messages/{id}

Section titled “DELETE /console/v1/chat/{channel}/messages/{id}”

Tombstone one message: blank its content and set deleted: true. The row (and its id) stays in history so ids and paging remain contiguous. A successful moderation also increments the message revision/event watermark and writes one redacted durable chat_moderation_audit record in the same transaction. Its independent retention never stores message text or raw player/operator ids.

Auth: bearer token, admin only. A viewer gets 403 forbidden.

Path parameters

Name Type Required Meaning
channel string yes The channel id.
id integer yes The per-channel message id (from a history page).

Response: 204 No Content (no body). Idempotent — deleting an already-tombstoned message still returns 204 and makes no further change.

Errors

Status Code Cause
401 authentication_failed Missing/invalid/expired bearer token.
403 forbidden Caller is a viewer.
404 not_found Unknown channel or unknown id within it.

Audited as chat.message.delete.

Example

Terminal window
curl -s -X DELETE http://127.0.0.1:7350/console/v1/chat/lobby/messages/1 \
-H "Authorization: Bearer $TOKEN"

Every error uses the console API’s shared JSON error body:

{ "code": "not_found", "message": "chat message not found" }

See the console API’s error table for the full status/code list.

This relay pattern carries no dedicated chat wire kind and no chat-specific client SDK method of its own — it is the same general-purpose relay pattern Rooms and transform sync are built on: your Lua runtime script registers a handler for an application-chosen envelope kind with citadel.on_message, and re-sends it to peers with citadel.broadcast (everyone but the sender) or citadel.send (one session). Citadel reserves and holds the low kind range through 99 (see the envelope reference and rooms), so an application-defined chat kind must be 100 or higher.

local KIND_CHAT_SEND = 100 -- client -> server: "post a chat line" (app-defined)
local KIND_CHAT_RECV = 101 -- server -> client: relayed chat line, sender-tagged
-- Whoever the server currently is (accounts, or nil for a guest) is on ctx.user_id;
-- the transport-level participant id is always on ctx.sender.
citadel.on_message(KIND_CHAT_SEND, function(ctx, body)
local tagged = string.pack(">I8", ctx.sender) .. body
citadel.broadcast(KIND_CHAT_RECV, tagged, false) -- reliable: chat lines must arrive
end)

citadel.broadcast never echoes to the sender, so a sender should render its own outgoing line locally instead of waiting for the relay. false (the unreliable flag) picks a reliable stream — appropriate for chat text, unlike the true used for the position-relay example, since a dropped chat line is a worse experience than a dropped position update.

Client: send and receive the custom chat kind

Section titled “Client: send and receive the custom chat kind”
constexpr uint16 KIND_CHAT_SEND = 100;
constexpr uint16 KIND_CHAT_RECV = 101;
auto* Citadel = GetGameInstance->GetSubsystem<UCitadelClientSubsystem>;
// Send a chat line (reliable).
FString Line = TEXT("hello world");
FTCHARToUTF8 Utf8(*Line);
TArray<uint8> Payload((const uint8*)Utf8.Get, Utf8.Length);
Citadel->Send(KIND_CHAT_SEND, Payload, /*bReliable=*/true);
// Poll each tick and dispatch by kind (the single reader of the envelope queue).
uint16 Kind;
TArray<uint8> Body;
while (Citadel->Poll(Kind, Body) == ECitadelStatus::Ok)
{
if (Kind == KIND_CHAT_RECV)
{
// First 8 bytes: big-endian sender session id; rest: the chat text.
FString Text = FString(UTF8_TO_TCHAR(Body.GetData + 8));
// ... render Text in your chat widget ...
}
}

See the envelope format reference for the exact byte layout of framed vs. datagram encodings, and the Lua runtime API for the full citadel.on_message / citadel.broadcast / citadel.send host surface.

Chat channel/history bounds, tombstone semantics, and paging are unit-tested in src/repository/chat.rs (the pure eviction/paging/listing helpers plus the in-memory reference) and src/services/chat.rs (the thin service). The same channel/history/eviction/tombstone contract is enforced across the in-memory, SQLite, and Postgres backends by tests/chat_repository_contract.rs (Postgres opt-in via DATABASE_URL). The HTTP handlers (channel listing, append, history paging, tombstone delete, role gating) are unit-tested in src/http/console_api/chat.rs and covered end-to-end by tests/console_chat.rs. The Lua relay pattern (citadel.on_message + citadel.broadcast, sender-tagging, unreliable/reliable delivery) is exercised by the gateway integration tests in src/realtime/gateway.rs (lua_handler_drives_the_relay_end_to_end and neighboring tests).

  • Durable on Postgres/SQLite; in-memory backend is not. Channel/message history is persisted through the repository seam and survives a restart on the Postgres and SQLite backends; a node running the in-memory backend still loses it on restart (by design).
  • No realtime wire delivery for the console API. The console’s POST route is a manual producer, not a live feed — a message appended through the console is not pushed to any connected game client.
  • This relay pattern has no chat-specific SDK method. It uses only generic envelope send/receive; the durable chat domain feature described under Realtime delivery today is the chat-specific surface.
  • Blueprint cannot send/receive raw envelope kinds today. See the Blueprint tab above.
  • Godot transport needs the native GDExtension or the Web client. send/poll move bytes through the prebuilt CitadelClientNative (Windows release package); with only the GDScript files and no native binary they return an error status. Browser exports use CitadelWebClient (no GDExtension).