Skip to content

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.go
  • DevOps/Nakama/data/modules/edgegap/house.go
  • DevOps/Nakama/data/modules/edgegap/main.go
  • Source/Metal_terra/Public/Core/Subsystems/NakamaChatSubsystem.h
  • Source/Metal_terra/Private/Core/Subsystems/NakamaChatSubsystem.cpp

Implemented now:

  • server-authorized chat_setup, deriving MatchId from 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=true by 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:

  • MatchId is exactly the authoritative Nakama HouseId.
  • Match chat lives for the entire House lifetime, across repeated LS -> DS -> LS -> DS travel.
  • 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 is true for the first implementation.
  • Clients never call JoinChat/ChannelJoin for protected m: or t: 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:

  1. UE has an authenticated, connected Nakama realtime client.
  2. UE has successfully opened or joined a House.
  3. UE calls chat_setup through UNakamaRealtimeClient::RPC, not the HTTP client RPC path.
  4. Nakama obtains user_id and the active session_id from the realtime RPC context.
  5. Nakama derives the House from the authenticated user's private user_house/current record; it does not trust a client-supplied House or match ID.
  6. Nakama verifies the House exists and the user is still in its authoritative roster.
  7. Nakama joins that exact socket presence to m:<houseId> with persistence enabled.
  8. Nakama builds the channel ID using NakamaModule.ChannelIdBuild and returns it in the RPC response.
  9. UE caches the opaque channel ID and uses normal WriteChatMessage calls 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.
  • InitMatchTeam currently gives every player a distinct incrementing match-team ID.
  • Its replicated assignments are actor/world state, not a durable NakamaUserId -> TeamId mapping.
  • 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:

  1. The chat setup response and UE cache support an absent/present team channel.
  2. Backend helpers support leaving an old team stream before joining a new one.
  3. 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.
  4. 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.
  5. Team changes are revisioned/idempotent so delayed LS/DS calls cannot restore an old team after a newer assignment.
  6. 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

  1. Add chat configuration parsing with CHAT_ALLOW_CLIENT_HISTORY=true default.
  2. Add protected room-label and stream/channel helpers.
  3. Add realtime chat_setup for House-derived match chat.
  4. Integrate stream removal with member detach, House leave, and House close.
  5. Add protected ChannelJoin rejection.
  6. Add configurable ListChannelMessages hook.
  7. Add message payload/rate-limit hook.
  8. Add Go tests for config, authorization, idempotent setup, cleanup, and reconnect behavior.

Unreal

  1. Add the GameInstance chat subsystem and match-only public API.
  2. Trigger setup after House open/join and after realtime recovery.
  3. Bind live channel messages additively.
  4. Implement match send, bounded history load, deduplication, and state clear on House abandonment.
  5. Keep team API present but unavailable until a valid setup supplies a team channel.

Match-chat verification gate

Complete these before implementing team authorization:

  1. Host opens a House and receives match_id == HouseId plus a non-empty opaque match channel ID.
  2. Joiner enters the same House and receives the same match channel ID.
  3. Both exchange live messages using ordinary WriteChatMessage.
  4. ListChannelMessages succeeds while CHAT_ALLOW_CLIENT_HISTORY=true and returns persisted messages.
  5. A manual client ChannelJoin("m:<houseId>") is rejected.
  6. Sending to a fabricated protected channel without stream membership is rejected.
  7. LS -> DS travel preserves the cached channel and live chat.
  8. DS -> LS rehost preserves the cached channel and live chat.
  9. A subsequent LS -> DS cycle continues using the same House match channel.
  10. A Nakama realtime reconnect calls chat_setup again and restores sending for the new session.
  11. Graceful House leave or abandoned-House recovery clears chat state; a fresh House receives a different match channel.
  12. Setting CHAT_ALLOW_CLIENT_HISTORY=false and 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.