dot-chat decides who hears a line, what it may contain and what name is drawn
beside it. It ships no window and no transport: a send_fn takes a dictionary
and a list of peers, and your game draws the result.
Requires dot-core. dot-moderation is
optional — when present its gags apply here with no wiring, because the router
asks whatever is registered as dot_mute_source and neither addon names the
other.
Why the server decides all four
Chat is the only subsystem where one player puts arbitrary text in front of every other player, and the same four things go wrong:
- Markup. A client drawing chat in a
RichTextLabelwith BBCode on lets anybody put a colour, an image or a link in everybody else’s window. - Invisible text. Zero-width characters and direction overrides make a message that is empty, or that reads as something other than what it is.
- Attribution. A client sending a pre-formatted line has decided the name and the prefix on the one machine that must not, and can impersonate the server by typing its prefix.
- The audience. Team chat that reaches the other team is a bug you ship once.
Usage
# Server: accept a line and let the router decide who gets it.
var res := router.submit(peer, &"all", text_they_typed)
if not res.ok:
link.rpc_id(peer, "chat_notice", res.error.message)
Example: wiring both ends
On the server:
var router := DotChatRouter.new()
router.peers_fn = func() -> PackedInt32Array: return server.peer_ids()
router.name_fn = func(peer: int) -> String: return server.session_of(peer).name
router.key_fn = func(peer: int) -> String: return server.session_of(peer).uid
router.team_fn = func(peer: int) -> StringName: return game.team_of(peer)
router.send_fn = func(wire: Dictionary, to: PackedInt32Array) -> void:
for peer in to:
link.rpc_id(peer, "chat_line", wire)
add_child(router)On the client:
var chat := DotChatClient.new()
chat.channels = [DotChatChannel.everyone(), DotChatChannel.team()]
chat.send_fn = func(text: String, channel: StringName) -> void:
link.rpc_id(1, "chat_say", String(channel), text)
chat.message_received.connect(func(m: DotChatMessage, _c: StringName) -> void:
log_label.append_text(DotChatFormat.line(m, chat.channel(m.channel)) + "\n"))
add_child(chat)That is a working chat system: everybody, team, whispers, /me, rate limits,
repetition limits, escaping, scrollback and a backlog for whoever joins next.
Channels are an audience rule
EVERYONE, TEAM, RADIUS (proximity), DIRECT (whispers) and MEMBERS (a
clan, the dead, the admins — the host answers membership_fn).
var proximity := DotChatChannel.make(&"near", "Nearby", DotChatChannel.Scope.RADIUS)
proximity.radius = 20.0
router.add_channel(proximity)
A radius channel can also ask the host whether a listener inside the radius can actually hear: router.can_hear_fn(listener, speaker, listener_at, speaker_at) is consulted after the distance test, so the router still knows nothing about walls and the level decides. Unset, the radius is the whole answer.
Party chat is a grouped members channel, not a plain one. membership_fn is never told who is speaking, so a party channel built on it reaches every party member on the server. A grouped channel is one channel id with one conversation per party, and a line reaches only the people in the sender’s party:
router.add_channel(DotChatChannel.group(&"party", "Party"))
router.group_fn = func(peer: int, _channel: StringName) -> StringName:
return StringName(parties.party_of(uid_of(peer))) # "" for nobody's party
Somebody in no party reaches nobody but themselves, and a grouped channel with no group_fn reaches nobody.
Membership is asked, never stored: which team a peer is on is a fact your game owns and changes every round, and a second copy of it is the copy that goes stale.
Commands
A line starting with ! or / is not broadcast. It arrives as a signal, and one nobody claims is dropped unless DotChatRules.broadcast_unknown_commands is on:
router.command_entered.connect(func(peer, command, args, raw):
if command == "rtv":
router.claim_command()
vote.rock_the_vote(peer))
That is how a game hands !rtv to dot-vote without either addon knowing the other exists. dot-server’s own commands do not come through here: its chat manager turns a prefixed line into a console command itself, before the line reaches this router, so a game running both hooks dot-server’s player_command event as well.
Muting somebody for yourself
DotChatSelfMute is a player’s own block list, typed as sm in the console or !sm / /sm in chat: sm lists who is here, numbered; sm 2, sm ada or sm #14 (a userid) toggles one. It is a preference and never crosses the wire: the chat box consumes the line before it can be sent, the muted player cannot tell, and the server keeps no list it could leak. That is the difference from a moderator’s gag, which is the server’s and applies to everybody.
DotChatClient drops a muted player’s say, action and whisper lines before they reach history, backlog included; join and leave notices stay. Voice is muted too: the list binds the manager that dot-voice publishes as dot_voice_client (or anything passed to attach_voice() with a set_local_mute(peer, muted)), so neither addon loads the other.
There is one list per process, in the registry as dot_self_mute (DotChatSelfMute.shared()), kept in user://dot_self_mute.json so a game the shell switches to finds the same list. It is keyed by the server scoreboard’s per-player key, which survives a reconnect where the session’s userid does not, and falls back to the name for a server without one. A sender key finds a roster row only when the name agrees, so a game whose chat key is its own player number is not decided by somebody else’s row.
