Nakama House Chat Architecture and Implementation¶
Implementation status (2026-08-09)¶
The match-chat slice described below is implemented in:
DevOps/Nakama/data/modules/edgegap/chat.goDevOps/Nakama/data/modules/edgegap/house.goDevOps/Nakama/data/modules/edgegap/main.goSource/Metal_terra/Public/Core/Subsystems/NakamaChatSubsystem.hSource/Metal_terra/Private/Core/Subsystems/NakamaChatSubsystem.cpp
Implemented now:
- server-authorized
chat_setup, derivingMatchIdfrom the authenticated user's House; - persistent
m:<HouseId>membership for the active realtime session; - protected client join rejection, versioned text validation, and send rate limiting;
- client history flag with
CHAT_ALLOW_CLIENT_HISTORY=trueby default; - House member-removal, close, and lease-expiry stream cleanup;
- GameInstance-scoped setup/reconnect/travel behavior, live message routing, bounded history, cursor paging, and message-id deduplication;
- a future-compatible optional team channel in the setup contract, while team chat remains unavailable until an authoritative LS/DS team bridge exists.
Local verification passed with go test ./... and UnrealBuildTool -NoLink, including
UnrealHeaderTool and compilation of NakamaChatSubsystem.cpp. A normal linked Editor build
must be run after closing the currently running Unreal Editor, because its project/Wwise DLLs
are locked. Backend deployment and the multiplayer verification gate remain operational steps.
Goal and first delivery slice¶
Implement persistent Nakama chat with server-controlled socket-stream membership.
The first delivery and verification slice is match chat only. Team chat types, state, and extension points should be present, but no current Brickleaf team value may authorize a Nakama team-chat stream yet.
Decisions for this project:
MatchIdis exactly the authoritative NakamaHouseId.- Match chat lives for the entire House lifetime, across repeated
LS -> DS -> LS -> DStravel. - Match room label:
m:<houseId>. - Reserved future team room label:
t:<houseId>:<teamId>. - Messages are persistent.
- Client history access is controlled by a positive configuration flag named
CHAT_ALLOW_CLIENT_HISTORY; its default istruefor the first implementation. - Clients never call
JoinChat/ChannelJoinfor protectedm:ort:rooms. - Unreal treats returned channel IDs as opaque.
Correction to the original proposal¶
This project does not use a Nakama authoritative gameplay match as the LS/DS gameplay authority. The House is the durable Nakama authority, while UE runs the House listen server and Edgegap battle dedicated server.
Therefore, do not wait for a Nakama match loop or add a gameplay-match ChatSetup opcode.
Use a realtime RPC such as chat_setup:
- UE has an authenticated, connected Nakama realtime client.
- UE has successfully opened or joined a House.
- UE calls
chat_setupthroughUNakamaRealtimeClient::RPC, not the HTTP client RPC path. - Nakama obtains
user_idand the activesession_idfrom the realtime RPC context. - Nakama derives the House from the authenticated user's private
user_house/currentrecord; it does not trust a client-supplied House or match ID. - Nakama verifies the House exists and the user is still in its authoritative roster.
- Nakama joins that exact socket presence to
m:<houseId>with persistence enabled. - Nakama builds the channel ID using
NakamaModule.ChannelIdBuildand returns it in the RPC response. - UE caches the opaque channel ID and uses normal
WriteChatMessagecalls afterward.
This matches the existing house_list_watch_start realtime-RPC pattern and guarantees that RUNTIME_CTX_SESSION_ID belongs to the socket being authorized.
Lifecycle¶
Initial House host¶
After house_open succeeds and CurrentHouse is populated, request chat_setup once the Nakama realtime backend is ready.
House joiner¶
After house_player_join succeeds, request chat_setup. Chat setup does not need to wait for travel to the House LS.
LS and DS travel¶
Do not leave or recreate match chat merely because the UE world or GameNetDriver changes. The Nakama client and chat state belong in GameInstance subsystems, above the per-world LS/DS lifecycle.
The same House and therefore the same match channel remain active through:
House LS -> battle DS -> rehosted House LS -> next battle DS
Because the room is House-scoped and persistent, its history also spans those rounds. A future per-round chat would need a different identity; do not add a deployment ID or round suffix now.
Nakama realtime reconnect¶
Stream membership is a (UserId, SessionId) presence. When realtime reconnects, call chat_setup again and replace cached channel IDs with the response, even when the House ID has not changed.
House leave, close, or abandoned workflow¶
Clear cached match/team channel IDs when authoritative House identity is cleared.
Integrate protected-stream cleanup into the authoritative backend House removal paths. house_player_leave, member detach, and house_close already know the old House and can use getTrackedUserSessions(userId) to call StreamUserLeave for each active session. This avoids ordering problems where a client tears down chat but its House leave is then rejected. A socket disconnect also naturally removes that session's presences.
Do not clear chat state on ordinary LS/DS map travel or successful match-end rehost.
Server room membership¶
Centralize room naming and stream operations in backend helpers:
func matchChatLabel(houseID string) string
func teamChatLabel(houseID string, teamID int32) string
func joinProtectedChatRoom(...)
func leaveProtectedChatRoom(...)
func buildRoomChannelID(...)
For match chat, call the installed runtime's exact StreamUserJoin signature with:
mode = 2 (room chat)
subject = ""
subcontext = ""
label = "m:" + houseId
userId = authenticated user
sessionId = realtime RPC session
hidden = false
persistence = true
Use ChannelIdBuild; do not reproduce the current 2...<label> representation in UE. Isolate any unavoidable server-side compatibility handling behind buildRoomChannelID.
The initial chat_setup response should be versioned and team-ready without claiming that team chat is authorized:
{
"v": 1,
"match_id": "<houseId>",
"match_channel_id": "<opaque Nakama channel id>",
"team_chat_ready": false
}
Do not return a fabricated team ID or team channel ID.
Required hooks¶
Protected ChannelJoin¶
Register a realtime before hook for ChannelJoin.
Reject client room joins whose requested target begins with m: or t:. Server runtime StreamUserJoin is not a client ChannelJoin, so it remains available.
This prevents a modified client from joining another House or future team room even when the label is predictable.
Configurable history access¶
Register a before hook for ListChannelMessages, controlled by:
CHAT_ALLOW_CLIENT_HISTORY=true
Use positive semantics to avoid an inverted configuration mistake:
if !chatAllowClientHistory {
return nil, runtime.NewError("client chat history is disabled", 7)
}
return request, nil
Default the parsed value to true. With that default, authenticated clients can retrieve persistent history during the first implementation and verification pass. Server-runtime history inspection remains unaffected if the flag is later disabled.
The first implementation may apply this flag globally to client ListChannelMessages; it does not need to parse opaque channel IDs to identify protected namespaces.
Message validation¶
Add a ChannelMessageSend before hook for protected chat payload validation and rate limiting. It must not query House/team storage on every send; stream presence is the membership authorization.
Accept a small versioned JSON payload, for example:
{
"v": 1,
"kind": "text",
"text": "push left",
"client_id": "<uuid>"
}
Never trust payload fields for sender, House, match, or team identity. Nakama supplies the authenticated sender metadata.
Unreal ownership and API¶
Create a GameInstance-scoped chat service/subsystem. It should depend on UNakamaConnectionSubsystem and the current House state, not on a particular world, GameState, LS, or DS.
Suggested public state/API:
bool HasMatchChat() const;
bool HasTeamChat() const;
void EnsureChatSetup();
void SendMatchChat(const FString& Message);
void SendTeamChat(const FString& Message); // returns unavailable for now
FString GetMatchChatChannelId() const;
FString GetTeamChatChannelId() const;
Suggested cached identity:
FString ChatHouseId;
FString MatchChatChannelId;
TOptional<int32> TeamId;
FString TeamChatChannelId;
Bind incoming messages through the SDK's additive ChannelMessageReceivedNative multicast delegate. Do not use SetChannelMessageCallback, which owns a single replaceable callback.
Route incoming messages by exact cached channel ID. Do not infer their destination by parsing the channel ID.
History behavior for the first slice:
- After match setup, list the newest bounded page (for example 50 messages).
- Merge history and live delivery by Nakama
MessageId. - Page older messages with the SDK cursor only when requested by UI.
- Treat history failure as non-fatal to live chat.
Team chat preparation only¶
The current UBrickleafTeamSubsystem is not yet a valid Nakama chat authority:
- It is a
UWorldSubsystem, so its counters and lookup reset on world travel. InitMatchTeamcurrently gives every player a distinct incrementing match-team ID.- Its replicated assignments are actor/world state, not a durable
NakamaUserId -> TeamIdmapping. - There is no implemented LS/DS-to-Nakama team-assignment update path.
Consequently, the first implementation must not read a local/replicated MatchTeamId and directly join t:<houseId>:<teamId>.
Prepare these boundaries for future work:
- The chat setup response and UE cache support an absent/present team channel.
- Backend helpers support leaving an old team stream before joining a new one.
- A future server-authorized operation accepts a target user and authoritative team assignment from the active LS or DS, using the existing LS/DS secret-authenticated RPC pattern.
- That operation updates backend team assignment, changes all relevant active session presences, and sends/returns a new opaque team channel ID to the affected client.
- Team changes are revisioned/idempotent so delayed LS/DS calls cannot restore an old team after a newer assignment.
- Spectators receive no team channel unless a later explicit policy says otherwise.
Dynamic team changes may originate in either LS or DS, but only the authoritative server for that phase may publish them to Nakama. A client-side PlayerState OnRep_MatchTeamId event may refresh UI or request a setup refresh; it must never be the authority that chooses its chat team.
First-slice implementation order¶
Nakama server¶
- Add chat configuration parsing with
CHAT_ALLOW_CLIENT_HISTORY=truedefault. - Add protected room-label and stream/channel helpers.
- Add realtime
chat_setupfor House-derived match chat. - Integrate stream removal with member detach, House leave, and House close.
- Add protected
ChannelJoinrejection. - Add configurable
ListChannelMessageshook. - Add message payload/rate-limit hook.
- Add Go tests for config, authorization, idempotent setup, cleanup, and reconnect behavior.
Unreal¶
- Add the GameInstance chat subsystem and match-only public API.
- Trigger setup after House open/join and after realtime recovery.
- Bind live channel messages additively.
- Implement match send, bounded history load, deduplication, and state clear on House abandonment.
- Keep team API present but unavailable until a valid setup supplies a team channel.
Match-chat verification gate¶
Complete these before implementing team authorization:
- Host opens a House and receives
match_id == HouseIdplus a non-empty opaque match channel ID. - Joiner enters the same House and receives the same match channel ID.
- Both exchange live messages using ordinary
WriteChatMessage. ListChannelMessagessucceeds whileCHAT_ALLOW_CLIENT_HISTORY=trueand returns persisted messages.- A manual client
ChannelJoin("m:<houseId>")is rejected. - Sending to a fabricated protected channel without stream membership is rejected.
- LS -> DS travel preserves the cached channel and live chat.
- DS -> LS rehost preserves the cached channel and live chat.
- A subsequent LS -> DS cycle continues using the same House match channel.
- A Nakama realtime reconnect calls
chat_setupagain and restores sending for the new session. - Graceful House leave or abandoned-House recovery clears chat state; a fresh House receives a different match channel.
- Setting
CHAT_ALLOW_CLIENT_HISTORY=falseand restarting/reloading the server causes client history calls to be rejected while live chat still works.
Security invariant¶
Nakama decides which protected chat streams an authenticated socket session belongs to. UE receives opaque channel IDs only after server-side House/team authorization and never selects its own protected room.