Chat
Channels and whispers over Core, with profanity masking and rate caps.
Seams you implement 3
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayChat(); and it wins.
IChatAudienceResolver game SPI
Resolves WHO hears a channel whose audience is not a subscriber list (GAP-15). The gap this closes. Every channel Chat had was a membership list: a room you joined, a guild id, a name to whisper. A "say" channel is not that — its audience is whoever happens to be standing near you, changing every time anyone moves, so there is nothing to join and no list to keep. The piece could not express it, and it could not learn to: Chat references nothing spatial by design, and it must keep working in a card game that never installed World. So the spatial half lives OUTSIDE the piece. Chat asks this seam; the Hosting bridge answers it from World interest, and when World is absent nothing implements it and the sender is TOLD the channel is unavailable — never left shouting into a void that looks like success.
-
bool TryResolveAudience(ISession sender, ChatChannelType channel, string channelId, List<ISession> into)Fills into with the sessions that should receive this message.
IChatChannelDirectory game SPI
The game's list of room channels a player may see: area channels from the game's rules, a help channel, a global one. Nothing is registered by default, and then the channel-list and channel-members requests are not registered either (a client asking gets no answer). Register one and a client can list its channels (with member counts) and ask who is in any channel listed for it. A listed channel the game's IChatChannelPolicy would not let the player join is left out.
public sealed class AreaChannels : IChatChannelDirectory
{
public IReadOnlyList<ChatDirectoryEntry> ChannelsFor(ISession session) => new[]
{
new ChatDirectoryEntry(ChatChannelType.Custom, "help", "Help"),
new ChatDirectoryEntry(ChatChannelType.Custom, "area-" + AreaOf(session), AreaName(session)),
};
}
services.AddSingleton<IChatChannelDirectory, AreaChannels>(); -
IReadOnlyList<ChatDirectoryEntry> ChannelsFor(ISession session)The channels session may see, in display order. Called once per list request and once per members request, so keep it cheap.
IChatChannelPolicy game SPI
The game's say over room channels (Party, Guild, Custom): who may join a Custom channel, who may send to a room, who may read and delete its history. Nothing is registered by default, and then Chat behaves exactly as it always has - any signed-in player may join any Custom channel, members may send. Register one to tie channels to the game's own membership (a region's room, a guild's room). Every member has a default, so implement only what you need. Sending and reading history still require being IN the room; the policy can only narrow that.
-
bool MayDelete(ISession session, ChatChannelType channel, string channelId, ChatHistoryRecord message)May this member delete this history message (a moderator, the channel's owner)? Default: no.
-
bool MayJoin(ISession session, ChatChannelType channel, string channelId)May this session join this Custom channel by itself? (Server code joins through IChatMembership and is not asked.) Default: yes.
-
bool MayReadHistory(ISession session, ChatChannelType channel, string channelId)May this member read this channel's history and unread count? Default: yes.
-
bool MaySend(ISession session, ChatChannelType channel, string channelId)May this member send to this room channel? Default: yes.
Providers you can swap 3
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IChatClock provider
Wall-clock seam (unix UTC ms) so mute-expiry math is testable — implement it to feed a fake clock in tests. Piece-local on purpose (Chat does not depend on any shared clock abstraction). The default SystemChatClock reads the system clock; override before AddCrossplayChat.
-
long UtcNowMs { get; }The current time as Unix UTC milliseconds.
IChatHistoryStore provider
Where chat history and read markers are kept. The default is in memory; persistence composes a durable one. A channel is keyed by its RoomId.
-
IReadOnlyList<ChatHistoryRecord> Load(string channelKey)A channel's kept messages, oldest first.
-
IReadOnlyDictionary<string, long> LoadMarkers(long accountId)One player's read markers: channel key to the last read id.
-
long NextId()The next history id.
-
void Save(string channelKey, IReadOnlyList<ChatHistoryRecord> messages)Replaces a channel's kept messages (already capped by the caller).
-
void SaveMarkers(long accountId, IReadOnlyDictionary<string, long> markers)Replaces one player's read markers.
IChatMuteStore provider
Mute-persistence SPI — the seam that decides where mutes are stored. The framework ships an in-memory default (InMemoryChatMuteStore); the persistence package's AddCrossplayPersistentStores swaps in a document-backed store so mutes survive restarts.
-
IReadOnlyList<ChatMuteRecord> All()Every stored mute — read once at service construction to warm the cache. Expired entries may still be present (the service prunes them on load); no ordering is required.
-
void Put(ChatMuteRecord record)Inserts or replaces the mute for AccountId.
-
bool Remove(long accountId)Deletes the account's mute. Returns false if the account had none.
Services you call 4
Crossplay implements these. Resolve them from DI and call them from your own systems.
IChatHistory
Chat history from server code: read a channel's kept messages, delete one (a moderation tool).
-
bool Delete(ChatChannelType channel, string channelId, long id)Deletes one message and tells the channel. False when there is no such message.
-
IReadOnlyList<ChatHistoryRecord> Page(ChatChannelType channel, string channelId, long beforeId, int count, out bool more)Up to count messages older than beforeId (0 = the newest), oldest first.
IChatMembership
Server code puts sessions into, and takes them out of, room channels - join a player to their region's and guild's rooms at login, drop them when they lose a village or leave the guild. Not subject to IChatChannelPolicy: the server is the authority.
-
bool IsIn(ISession session, ChatChannelType channel, string channelId)Is the session in this room channel?
-
bool Join(ISession session, ChatChannelType channel, string channelId)Adds a session to a room channel. False when the channel is not a room channel, the id is invalid, or the session was already in it.
-
bool Leave(ISession session, ChatChannelType channel, string channelId)Removes a session from a room channel. False when it was not in it.
IChatMuteService
Server-enforced chat mutes — the enforcement check the chat send path runs on every message, plus the mute / unmute / list operations a moderation surface drives (Hosting exposes those as /admin/mute etc.). The piece deliberately ships no wire message for muting, so a client can never mute another player: muting is a server/admin authority only.
-
IReadOnlyList<ChatMuteRecord> Active(int count)The active (unexpired) mutes, soonest-to-expire first, capped at count entries (for an admin listing).
-
bool IsMuted(long accountId)True while the account has an unexpired mute; expired entries are pruned on access.
-
ChatMuteRecord Mute(long accountId, string name, int seconds)Mutes the account for seconds from now; re-muting replaces the previous record. Returns the stored record.
-
bool Unmute(long accountId)Lifts the account's mute. Returns false when the account was not muted.
IChatService
Server-side entry point for System-channel messages — the only way to put text on the System channel (clients cannot post to it). A game or another piece injects this to surface announcements ("maintenance in 5 min", "X joined the server").
-
void BroadcastSystem(string text)Sends a System-channel message to every online session, across all cluster nodes.
-
void SendSystem(ISession session, string text)Sends a System-channel message to one session.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
ChatOptions
Configurable chat rules — every tunable for the Chat piece. Defaults live here (never inline in a handler), so a game overrides only what it needs via the configure callback of AddCrossplayChat. All values have working defaults; the object is safe to use unconfigured.
-
int DefaultMuteSeconds { get; set; }Mute length (seconds) the /admin/mute admin call applies when no seconds argument is given. Default 300 (5 minutes).
-
double HistoryMaxAgeSeconds { get; set; }History older than this many seconds is dropped. 0 = no age cap. Default 7 days.
-
int HistoryMessages { get; set; }Keep the last N messages of every room channel (Party, Guild, Custom) so members can page back through them, with per-player read markers and unread counts. 0 (the default) = no history at all: nothing is stored, and the history requests are not registered (a client asking gets no answer). Whispers, Proximity and System are never kept.
-
int HistoryPageSize { get; set; }The most history entries one page returns. Default 50.
-
int MaxChannelIdLength { get; set; }Maximum channel-id length; longer ids are treated as invalid. Default 64.
-
int MaxChannelMembers { get; set; }The most members one channel-members request returns (needs an IChatChannelDirectory). Default 100.
-
int MaxMessageLength { get; set; }Maximum message length after trimming; longer sends are rejected. Default 256.
-
int MaxUnreadChannels { get; set; }The most channels one unread-count request may name. Default 50.
-
bool MuteAppliesToWhispers { get; set; }Server-enforced mutes also silence whispers (default). Whispers are the direct harassment channel, so a mute that only covers public rooms has no teeth exactly where it matters most; a game that wants "public-channels-only" mutes turns this off.
-
int MutesAdminPageSize { get; set; }Maximum active mutes the /admin/mutes listing returns. Default 100.
-
string ProfanityMask { get; set; }Replacement string for a masked profanity word. Default "***".
-
List<string> ProfanityWords { get; }Words masked in outgoing messages (whole-word, case-insensitive). Empty = no filter (the default). The game supplies its own list; keeping it here (not inline) means no hardcoded content in the handler. The regex is compiled once at handler construction, so add words before AddCrossplayChat runs.
-
float ProximityRadius { get; set; }Hearing distance for the Proximity ("say") channel, in world units. 0 (the default) = everyone who can SEE the sender, i.e. the world's own interest radius. Setting it smaller than interest is the usual choice and the reason it exists: you can see much further than you should be able to hear, so a say channel that reached the whole interest set would carry across a whole town square. It is a plain number, so the Chat piece still references nothing spatial — resolving it is the audience resolver's job.
-
string SystemChannelId { get; set; }Channel id carried on System-channel messages. Default "system".
-
string SystemSenderName { get; set; }Display name stamped as the sender of System-channel messages. Default "System".
Wire messages 16
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
ChatSend Client -> server: post a message to a channel.
ChatMessage Server -> client: a delivered chat message.
ChatJoinChannel Client -> server: subscribe to a (party/guild/custom) channel's broadcasts.
ChatLeaveChannel Client -> server: unsubscribe from a channel.
ChatHistoryRequest Client -> server: a page of a channel's history, newest last. Only for a channel you are in (and the game's channel policy allows). Needs history turned on (ChatOptions.HistoryMessages > 0) - otherwise unanswered.
ChatHistory Server -> client: a page of a channel's history, oldest first.
ChatMarkReadRequest Client -> server: mark a channel read up to a history id.
ChatUnreadRequest Client -> server: unread counts for a list of channels (channels you may not read come back as 0).
ChatUnreadCounts Server -> client: unread counts, in the order asked.
ChatDeleteRequest Client -> server: delete one history message. Who may is the game's channel policy (nobody by default).
ChatHistoryResult Server -> client: the answer to a mark-read or a delete.
ChatMessageDeleted Server -> client (push, to everyone in the channel): a history message was deleted.
ChatChannelListRequest Client -> server: the channels the game's directory offers me. Only answered when the server composed an IChatChannelDirectory - otherwise the call times out.
ChatChannelList Server -> client: the channels the directory offers the caller, in the directory's order.
ChatChannelMembersRequest Client -> server: who is in one channel. Only for a channel the directory offers the caller.
ChatChannelMembers Server -> client: the members of one channel.
Unity services you inject 3
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IChatClient
Data-layer chat service (no UI). Registers the inbound ChatMessage handler and exposes send/join/leave plus a MessageReceived event for presenters. Self-registers under the Crossplay client context via the framework's attribute discovery.
-
event Action<ChatMessage> MessageReceivedRaised (main thread) for each inbound chat message.
-
void Send(ChatChannelType channel, string channelId, string text)Sends a message on a channel (whisper → channelId is the target name).
-
void JoinChannel(ChatChannelType channel, string channelId)Joins a party/guild/custom channel to receive its broadcasts.
-
void LeaveChannel(ChatChannelType channel, string channelId)Leaves a channel.
IChatDirectoryClient
The game's channel directory: the channels the server lists for me (with the game's labels and member counts), and who is in one of them. Only answered when the server composed an IChatChannelDirectory - otherwise every call completes as a timeout. Each call is one awaitable request (Core's request/reply helper; it never throws). How often to refresh is the game's choice. Pure SDK, no UI.
-
UniTask<ChatChannelList> ListChannelsAsync(CancellationToken ct = default)The channels the server's directory offers me, in its order.
-
UniTask<ChatChannelMembers> MembersAsync(ChatChannelType channel, string channelId, int count = 0, CancellationToken ct = default)Who is in one listed channel (names + account ids). count 0 = the server's cap.
IChatHistoryClient
Room-channel history (Party, Guild, Custom): page back, mark read, unread counts, delete. Only answered when the server turned history on (ChatOptions.HistoryMessages > 0) - otherwise every call completes as a timeout. Each call is one awaitable request (Core's request/reply helper; it never throws). MessageDeleted fires when a message in a channel you are in is deleted. Pure SDK, no UI.
-
event Action<ChatChannelType, string, long> MessageDeletedA history message was deleted: (channel, channel id, history id).
-
UniTask<ChatHistory> HistoryAsync(ChatChannelType channel, string channelId, long beforeId = 0, int count = 0, CancellationToken ct = default)A page of a channel's history, oldest first. beforeId = the first id of the page you have (0 = the newest page); count 0 = the server's page size.
-
UniTask<ChatHistoryResult> MarkReadAsync(ChatChannelType channel, string channelId, long upToId = 0, CancellationToken ct = default)Marks a channel read up to a history id (0 = everything so far).
-
UniTask<ChatUnreadCounts> UnreadAsync(ChatChannelRef[] channels, CancellationToken ct = default)Unread counts for a list of channels, in the order asked.
-
UniTask<ChatHistoryResult> DeleteAsync(ChatChannelType channel, string channelId, long id, CancellationToken ct = default)Deletes one history message (the server's channel policy decides who may).
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayChatView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.