Text chat
The in-game text chat is not built on the gameplay replication path (game server → Horizon → clients). It runs over a dedicated MQTT broker: each client connects to the broker directly, subscribes to the channels it is allowed to read, and publishes its own messages there. The broker — not the game server — is the authority for chat.
Why MQTT
MQTT is a lightweight publish/subscribe protocol. A broker holds topics; clients publish to a topic and subscribe to topics, and the broker fans messages out to all subscribers. This decoupled, many-to-many model fits chat well and, because the broker is a shared bus, it also scales to cross-server / cross-region channels later (alliance, global) without routing everything through one game server.
┌──────────┐ ws + JWT (prod) ┌─────────────────────┐
│ Client │ ───────────────────►│ Mosquitto broker │
│ (Godot) │ ◄─── messages ──────│ (k8s: textchat) │
└──────────┘ └─────────────────────┘
publish/subscribe on topics:
chat/general (open)
chat/region/<id> ┐
chat/group/<id> ├─ need a context id (filled later, see below)
chat/alliance/<id> ┘
chat/dm/<player_id>
The pieces
| Piece | Where | Role |
|---|---|---|
ChatNetwork | scenes/globals/chat_network.gd (autoload) | The transport. Owns the broker connection, publishes outgoing messages, re-emits incoming ones. |
DirectChat | ui/direct_chat/direct_chat.gd + .tscn | The UI (message log, input line, channel selector). Binds to ChatNetwork. |
ChatMessage | ui/direct_chat/chat_message.gd | Plain data: content / channel / author / timestamp. |
| MQTT client | addons/mqtt/ | GDScript MQTT-over-WebSocket implementation. |
| Broker | kubernetes repo, textchat/ Helm chart | Mosquitto (eclipse-mosquitto). |
Chat networking is intentionally kept out of NetworkOrchestrator (which handles the
Horizon gameplay path): it is a separate concern with its own transport.
Flow
- The local player's
DirectChat._readycallsChatNetwork.ensure_connected()(clients only — never the headless server, and not remote player copies). ChatNetworkconnects to the broker (WebSocket) and, once connected, subscribes to every active channel's topic.- Typing a message emits
DirectChat.send_message→ChatNetwork.publish_message, which stamps the author and publishes{author, content, channel}(JSON) on the channel topic. - The broker echoes the message to all subscribers (including the sender) →
ChatNetwork.message_received→DirectChat.receive_message_from_server→ displayed.
Channels and the extension point
All channels are listed in the selector, but a channel is only usable once its topic can be
resolved. chat/general is static (always active). The others —
region / group / alliance / dm — need a context id ("which group?") that does not
exist until those gameplay systems are designed. Until then they are greyed out ("À venir").
Wiring one up later is a single call — the extension point:
# When the group system knows the local player's group id:
ChatNetwork.set_id_provider(DirectChat.ChannelE.GROUP, func() -> String:
return current_group_id)
ChatNetwork then resolves chat/group/<id>, subscribes to it, and DirectChat shows the
channel as selectable — no other change needed.
Authentication
The chart has two auth modes (mosquitto.auth.mode):
- anonymous — no auth. Default for local dev (the official
eclipse-mosquittoimage). - jwt — clients authenticate with the same JWT they already use to join the game (Discord → Keycloak), so it is transparent: no second login. Used for preprod/prod.
In jwt mode the broker runs mosquitto-go-auth (remote backend) and delegates every
user/ACL decision to the small chat-auth adapter (services/chatAuth). The adapter
validates the token's RS256 signature against Keycloak's JWKS and checks the issuer —
the broker never holds the signing key, and key rotation is handled automatically.
The jwt broker image is the maintained ghcr.io/ajnasz/mosquitto-go-auth fork; the original
iegomez/mosquitto-go-auth is unmaintained. The fork keeps the same go-auth.so path and
auth_opt_* options, so the mosquitto config is unchanged — only mosquitto.goauthImage in the
chart values points at the fork (pinned to a stable release bundling mosquitto 2.0.x).
Wiring details that matter:
- The client sends the token as the MQTT username (not the password); go-auth forwards
it to the adapter as
Authorization: Bearer. The player token isnetwork_agent.token(from the--token=launch argument);ChatNetworksets it as the username. - ACL lives in the adapter. It is permissive today (any authenticated player may use
chat/#) and will tighten per JWT claims later (e.g. allowchat/group/<id>only if the token carries that group) — the same "sockets" the channel id-providers fill on the client side.
Testing it locally
The broker validation was verified end-to-end against the dev Keycloak realm (dyingstar):
deploy the chart with --set mosquitto.auth.mode=jwt, mint a token from the local Keycloak
(a service-account client via the admin API), then connect with mosquitto_pub/sub using the
token as username. Anonymous and invalid tokens are refused; a valid token exchanges messages
on chat/#; non-chat topics are denied by the ACL.
Infrastructure
The broker is deployed by the textchat/ Helm chart in the kubernetes repo (modelled on
dev-services). It exposes MQTT on 1883 and MQTT-over-WebSocket on 9001. In local dev,
skaffold port-forwards 9001 to 127.0.0.1:9001 (like horizon's 7040); the client reads
the broker URL from client.ini [chat] broker_url, defaulting to ws://127.0.0.1:9001.
For the chat keyboard shortcuts (F12 / Enter / Tab), see the Controls & shortcuts page.