Groups, leaderboards, chat, and wallet
These authenticated built-in RPC methods use the existing generic RPC envelope;
they cannot be overridden by game logic. Every method returns status=0 and a
UTF-8 JSON body on success; guests receive "authentication required".
Groups
Section titled “Groups”All group RPCs use group_id and, where applicable, user_id; the server
always derives the acting user from the authenticated session. groups.create,
list, get, update, delete, add_member, leave, kick, promote, and
demote retain their existing metadata fields (name, description, open,
max_size). Updates/deletes/promotions require superadmin; the last
superadmin cannot leave, be kicked, or be demoted.
| Method | Parameters | Result / errors |
|---|---|---|
groups.join |
{ "group_id": number } |
Open groups add the caller and return { "state":"joined", "group":… }; closed groups return { "state":"requested", "admission":… }. Fails if full or already a member. |
groups.invite |
{ "group_id": number, "user_id": string } |
An admin or superadmin creates an invitation and receives { "state":"invited", "admission":… }. Fails for insufficient role, a member, or conflicting pending state. |
groups.approve_request |
{ "group_id": number, "user_id": string } |
An admin or superadmin admits a pending requester and returns the group. Fails if no request exists or capacity is exhausted. |
groups.accept_invitation |
{ "group_id": number } |
The invited caller becomes a member and receives the group. Fails without a pending invitation or when full. |
groups.cancel_admission |
{ "group_id": number } |
Cancels the caller’s request or invitation and returns {}. Repeating it is safe. |
groups.transfer_ownership |
{ "group_id": number, "user_id": string } |
Only the current superadmin may transfer ownership to an existing member; returns the group with the new superadmin. |
Only one pending admission state is allowed for a player in a group. Invitations and requests are persisted, so they survive server restart; they do not by themselves deliver a player notification.
Leaderboards
Section titled “Leaderboards”leaderboards.list({}) lists definitions. leaderboards.records({"board_id": "daily","limit":50,"offset":0}) pages ranked records. leaderboards.submit
accepts board_id, signed score, optional signed subscore, and optional
object metadata; it always writes the authenticated caller’s record. Board
creation and deletion remain operator operations.
| Method | Request | Success result | Errors |
|---|---|---|---|
chat.join |
{ "target": { "kind":"direct", "other_user_id":"player-b" } } (or current group / room) |
{ "channel_id", "channel_type", "presence", "watermark_event_id", "subscription" } |
CHAT_UNAVAILABLE, CHAT_RATE_LIMITED |
chat.leave |
{ "channel_id":"ch_…" } |
{ "left": boolean } |
CHAT_NOT_SUBSCRIBED only for a conflicting session identity |
chat.typing |
{ "channel_id":"ch_…", "typing":true } |
{ "typing", "expires_at" } |
CHAT_NOT_SUBSCRIBED, CHAT_UNAVAILABLE, CHAT_RATE_LIMITED |
chat.send |
{ "channel_id":"ch_…", "content":"hello" } |
{ "message", "event_id" } |
CHAT_NOT_SUBSCRIBED, CHAT_UNAVAILABLE, CHAT_RATE_LIMITED |
chat.history |
{ "channel_id":"ch_…", "limit":50, "before_message_id":123 } |
{ "items", "watermark_event_id" } |
CHAT_NOT_SUBSCRIBED, CHAT_UNAVAILABLE, CHAT_RATE_LIMITED |
chat.edit |
{ "channel_id":"ch_…", "message_id":123, "content":"replacement" } |
{ "message", "event_id" } |
author/window/access/rate errors |
chat.delete |
{ "channel_id":"ch_…", "message_id":123 } |
{ "message_id", "deleted", "event_id" } |
author/window/access/rate errors |
chat.moderate |
{ "channel_id":"ch_…", "message_id":123 } for a group subscription |
{ "message_id", "deleted", "event_id" } |
group-role/access/rate errors |
Direct targets need mutual friendship with no block in either direction; group
targets need current membership; room targets need current room presence.
Unknown, forbidden, and revoked targets all return CHAT_UNAVAILABLE.
KIND_CHAT_EVENT callback
Section titled “KIND_CHAT_EVENT callback”KIND_CHAT_EVENT (28) is a reliable UTF-8 JSON server-to-client envelope.
Feed it to the released SDK’s typed dispatcher rather than decoding JSON in game
code. Durable delivery spans the source node and current authenticated cluster
leases and remains at-least-once. The SDK owns duplicate/gap classification,
typing expiry, terminal revocation, reconnect fencing, transactional history
application, and the private correlated watermark acknowledgement. Receiving a
history page is not equivalent to applying it.
ChatLive->DispatchEnvelope(Envelope.Kind, Envelope.Payload);chat.HandleEnvelope(envelope.Kind, envelope.Payload);chat.OnDisconnected(); // fences pending apply/ACK authoritychat.handle_envelope(envelope.kind, envelope.payload)chat.on_disconnected() # only a correlated rejoin restores admissionclient.onChatEvent((event) => cursor.observe(event));await client.reconcileChat(cursor, applyHistorySnapshot);let event = ChatEvent::from_envelope(&envelope)?;let disposition = cursor.observe(&event)?;All decoders reject malformed JSON, duplicate or unknown fields, invalid numeric types, unsupported versions/variants, and inconsistent message state. The raw envelope callback remains available for diagnostics and forward-compatible instrumentation, but cannot manufacture joined cursors, applied snapshots, or acknowledgements.
chat.moderate is intentionally unavailable for direct and room targets. A
group superadmin can tombstone any retained group message; an admin can
tombstone a member’s (or former member’s) message but not one from a current
admin or superadmin. The mutation uses the same membership epoch fence as
send/history and writes a redacted durable audit record. Room moderation remains
an operator-console action until trusted room authorities are introduced.
Valid content is non-empty UTF-8 text up to 2,048 bytes with no control
characters other than line breaks. Fixed-window limits are shared by nodes using
the same database: send is 8/user, 12/user-channel, and 160/channel per 10 s;
typing is 20/user and 12/user-channel per 10 s and never consumes the
durable-message allowance; edit/delete are 4/user and 8/user-channel per minute. History and target access
are also limited. A denial returns CHAT_RATE_LIMITED without revealing private
channel state. Moderation is limited to 30/acting moderator and 60/channel per
minute. Local presence, ephemeral typing, and committed durable live fan-out
ship; cross-node typing delivery remains later work.
Wallet
Section titled “Wallet”wallet.balances({}) reads the caller’s currency map and wallet.ledger
reads the caller’s newest-first ledger (limit optional). Clients cannot adjust
balances; adjustment is trusted game-logic authority only.
Generic RPC call
Section titled “Generic RPC call”// Raw released seam: u64 request id + u16 method length are big-endian.TArray<uint8> Body;auto AppendBe = [&Body](uint64 Value, int32 Bytes) { for (int32 Shift = (Bytes - 1) * 8; Shift >= 0; Shift -= 8) Body.Add(static_cast<uint8>((Value >> Shift) & 0xff));};const uint64 RequestId = 1;const FTCHARToUTF8 Method(TEXT("groups.list"));const FTCHARToUTF8 Json(TEXT("{\"limit\":50}"));AppendBe(RequestId, 8);AppendBe(static_cast<uint64>(Method.Length()), 2);Body.Append(reinterpret_cast<const uint8*>(Method.Get()), Method.Length());Body.Append(reinterpret_cast<const uint8*>(Json.Get()), Json.Length());Client->Send(CitadelWire::KIND_RPC_REQUEST, Body, true);// The one poll owner uses Poll(...) and matches KIND_RPC_RESPONSE by RequestId.The released plugin has no generic Blueprint RPC node. Do not invent Send
RPC. Use the typed domain nodes instead—for chat, call Build Typing with
the channel id and true, then Send Chat Request and retain its output
request id. Generic methods such as groups.list currently require the C++ raw
envelope seam shown above.
bool sent = rpcClient.CallRpc( "groups.list", Encoding.UTF8.GetBytes("{\"limit\":50}"), result => { if (result.Ok) Debug.Log(Encoding.UTF8.GetString(result.Payload)); else Debug.LogError(result.PayloadAsText()); });if (!sent) Debug.LogError("groups.list was not sent");# There is no public call_rpc; use the released protocol + raw send seam.var on_result := func(status: int, payload: PackedByteArray) -> void: print("groups.list status=", status, " payload=", payload.get_string_from_utf8())var request_id := next_rpc_idvar body := CitadelProtocol.encode_rpc_request( request_id, "groups.list", '{"limit":50}'.to_utf8_buffer())var status := client.send(CitadelProtocol.KIND_RPC_REQUEST, body, true)if status == CitadelClient.Status.OK or status == CitadelClient.Status.AGAIN: pending_rpc[request_id] = on_result next_rpc_id += 1
# From the single poll owner's KIND_RPC_RESPONSE branch:var response := CitadelProtocol.decode_rpc_response(envelope.payload)if not response.is_empty(): var callback: Callable = pending_rpc.get(response.request_id, Callable()) pending_rpc.erase(response.request_id) if callback.is_valid(): callback.call(response.status, response.payload)const response = await client.callRpc( "groups.list", new TextEncoder().encode(JSON.stringify({ limit: 50 })),);let mut inbound = Vec::new();let response = client .call_rpc("groups.list", br#"{"limit":50}"#, |envelope| { inbound.push(envelope); // retain non-matching traffic for normal dispatch Ok(()) }) .await?;