Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Text chat

Every game that plays in a room has text chat with no code of its own: one channel, all, the engine’s window, the platform’s filter, and each player’s mute, block and report. The room decides every message, so a game shapes who reads one and never what it says. Roblox’s TextChatService works the same way, and its channels and receive callback are the model for the names here.

A game played alone has no one to type to. Its window shows only the game’s own messages, it draws no input, and the engine reserves no key for chat.

Turn chat off, or shape who sends and reads

Section titled “Turn chat off, or shape who sends and reads”

A game declares its chat in src/game.ts with defineTextChat. The room’s scene file re-exports it beside game and room, as What the room’s scene exports lists.

The declaration names the channels a player writes to, with the first as the default. It may define who can send a message and who receives it. A game that exports none gets one all channel.

Import the declarations and the Entity type from @spawnite/engine/core, as the following samples do.

The following declaration is a graveyard game’s. Ghosts read everyone, the living never read a ghost, and ghosts have a channel of their own. A ghost cannot write to all, and nobody writes while a match plays:

packages/room/test/outside/graveyardChat.ts (part)
export const textChat = defineTextChat({
channels: {
// A ghost writes to ghosts, never to the living.
all: textChannel({
canSend: (world, sender) =>
!isGhost(world, sender) ||
"Ghosts write on the ghosts channel.",
}),
team: teamTextChannel(),
// Ghosts talk among themselves; a living player reads none of it.
ghosts: textChannel({
canReceive: (world, _sender, recipient) =>
isGhost(world, recipient),
}),
},
// On every channel: the living never read a ghost, a ghost reads all.
canReceive: (world, sender, recipient) =>
!isGhost(world, sender) || isGhost(world, recipient),
// Chat closes while a match plays, on every channel.
canSend: (world) =>
!world.has(MatchPlayingTrait) || "Chat opens when the match ends.",
});

The channels and rules work as follows:

  • textChannel() reaches every player in the room. With canReceive, it reaches the players the rule admits, such as a jury.
  • teamTextChannel() reaches the sender’s teammates, by the team teams gives the sender. A player on no team cannot write to it: the room refuses the player’s message no-channel.
  • matchTextChannel(), from @spawnite/engine/matches, reaches the members and spectators of the sender’s match. A player who neither plays nor watches one cannot write to it. Its canSend narrows who writes, such as a court whose spectators read alone.
  • The game’s canSend(world, sender) runs for every message a player sends, before a channel’s own canSend. It returns true to accept the message. It refuses the message with false, or with the words the sender’s window shows, at most 150 characters. The room answers the sender game-rule with those words, and the message reaches no one and enters no history.
  • The game’s canReceive(world, sender, recipient) runs before a channel’s own, once for each other player. The recipient is a player entity, and the sender is a player entity or an NPC’s entity.
  • A message’s channel is named by its key, such as trades, in useTextChat()’s messages and in the window’s tabs. The window shows every declared channel as a tab to every player, one the player may not write to included.

Both rules read the world as the last step left it, as a World, and write nothing. canSend never sees the message’s text, so a game still reads no player’s words. A game’s own message and an NPC’s line skip canSend.

A channel’s canReceive decides who receives, not who may send. A living player who writes to ghosts is not refused: the room accepts the sender’s message and sends it to no living player. To refuse the sender, give the channel a canSend.

Chat for a phase. canSend reads the world, so it can follow the game’s phase, such as no chat while a match plays. Once the player sends, the player’s window shows the game’s words under the input for 6 seconds, as it shows every refusal; nothing shows before the player types. Close a phase with canSend rather than canReceive: a message canReceive holds back is accepted, shows as sent in the sender’s window, and enters history.

History. A player who joins late receives up to the last 30 messages from the last 10 minutes on every channel with no rule of its own, and the game’s messages to everyone. The room runs the game’s canReceive again for the joiner at the joiner’s join, against the world as it is then. A message on a channel with its own rule never enters history, so a ghost’s words never reach a living player who joins after. A message the game’s canReceive held back during a match does enter it, and reaches a player who joins in the next lobby.

A rule that fails. A rule that throws, or a canSend that returns neither a boolean nor words, throws in a development room, naming the rule. A deployed room logs it once a heartbeat: it refuses the message unavailable for a canSend and skips the recipient for a canReceive. Words past 150 characters throw in development too, and refuse with no words when deployed.

Chat off for good. The room’s settings turn chat off for the game. The room then refuses every message chat-off, and the engine draws no window:

packages/room/test/outside/graveyardChat.ts (part)
export const quietRoom = defineRoom({ textChat: false });

The graveyard’s test runs these rules through a real room, and also calls them headless, with createGameWorld from @spawnite/engine/core and players addPlayer from @spawnite/engine/testing adds:

packages/room/test/outsideGraveyardChat.test.ts (part)
it("answers the game's rule headless, for a test with no room", () => {
const world = createGameWorld(
defineGame({ name: "test", start: "test", plugins: [graveyard()] }),
);
const ada = addPlayer(world, { name: "Ada" });
const bo = addPlayer(world, { name: "Bo" });
addTrait(requireAuthority(world), bo, GhostTrait);
expect(textChat.canReceive?.(world, ada, bo)).toBe(true);
expect(textChat.canReceive?.(world, bo, ada)).toBe(false);
expect(textChat.channels.all.canSend?.(world, bo)).toBe(
"Ghosts write on the ghosts channel.",
);
addTrait(requireAuthority(world), world, MatchPlayingTrait);
expect(textChat.canSend?.(world, ada)).toBe(
"Chat opens when the match ends.",
);
});

addTextChatMessage(step, options) writes a message from an authoritative system, whose step is an AuthoritativeStep, or from a join hook, whose step is a JoinStep. The room sends it after the step, and it takes two forms:

  • A system message comes from the game, to every player or to the players to names. It skips the game’s rule and every channel’s, so it reaches a player mid-match too. It shows with an icon and the word System.
  • An NPC’s line names the entity that says it in from, such as a shopkeeper, and may pass a channel, such as textChat.channels.team, whose rule reads from as the sender. It shows with an NPC mark.

The text is a string or a list of parts: strings, entities, which read as their display names, and styled runs such as { text: "gold", color: "#e8b923", bold: true }. It holds at most 200 characters once each entity reads as its name, and addTextChatMessage throws for a longer one. The graveyard greets each player as the player joins, with the player’s own name:

packages/room/test/outside/graveyardChat.ts (part)
export const graveyard = definePlugin(() => ({
name: "graveyard",
// Greets each player as the player joins, by a system message to
// that player alone with their name as a part.
onPlayerJoin: ({ step, player }) =>
addTextChatMessage(step, {
to: [player],
text: ["Welcome to the graveyard, ", player, "."],
}),
}));

A message to a player who has joined reaches the recipient once the recipient’s chat opens, whatever the room’s send rate. A join that is refused takes back every message its hooks wrote, as it takes back what they spawned. In a game played alone, the player’s screen shows a message to the player or to everyone, and none written to a bot. addPlayer from @spawnite/engine/testing runs a game’s join hooks in a headless test.

A game may write its messages from a language model, so the room filters them too. A system message has its slurs, sexual words, threats and personal information masked, and keeps the rest, links included, as written. A canSend rule’s words are masked the same way. An NPC’s line is filtered for each reader like a player’s message. from never names a player, since a player’s words are the player’s to send. In a game played alone, the page shows the game’s own messages as written.

The platform’s filter masks words one # per character. Each player picks a level in the player’s settings, and the room holds the player to what the player’s age group allows. The levels mask the following:

  • Strict masks every word the filter knows, mild words such as “crap” among them. Every player starts at Strict.
  • Standard shows mild words.
  • Mature: shows swearing shows swearing too.

Slurs, sexual words, threats, personal information such as a phone number or a social handle, and links stay masked at every level. A reader sees a message at the stricter of the reader’s own level and the sender’s loosest, so a young teen’s swearing never reaches an adult unmasked.

The platform reads a player’s age group from the birth year on the player’s account, and keeps it from the game. The following table gives each group’s rules:

Age group Loosest level the player may choose Reads, and is read by
Unknown: a guest, or an account with no birth year Strict Every group
13 to 15 Strict Unknown, 13 to 15, and 16 to 17
16 to 17 Standard Unknown, 13 to 17, and 18 to 20
18 to 20 Mature Unknown, 16 to 17, and 18 and over
21 and over Mature Unknown, 18 to 20, and 21 and over

A game cannot read a player’s age group or the player’s blocks, send across a block or an age group, read a message unfiltered, or choose a reader’s level. Each of these is the platform’s to answer for.

The room answers each message the sender types: the message as the sender reads it, or why it refused. sendTextChatMessage resolves with the same answer, and the window shows a refusal under the input. The following list gives each limit and its refusal:

  • Length: at most 200 characters, too-long.
  • Rate: a burst of 5, then one message every 2 seconds, rate-limited with retryAfterSeconds.
  • Repeats: the third copy of one message within 30 seconds, repeated. Messages under 4 characters, such as “gg”, are exempt.
  • Empty: nothing left once the filter cleaned it, empty.
  • The sender’s account and the room: not-allowed where the sender’s account may not send, chat-banned under a moderator’s ban, busy where the room’s chat queue is full, and unavailable where the room cannot take it now.
  • Channels: a channel the game does not declare, or the team channel for a player with no team, no-channel.
  • Chat off and alone: chat-off in a room whose settings turn chat off, and alone in a game played alone.
  • The game’s rule: game-rule where a canSend refused it, with the game’s words in explanation where it gave some.

The engine draws the window in every room game. On a desktop it is a pane in the bottom-left slot that shows the last 8 messages, fading to the last 3 after 10 seconds with nothing new. / focuses its input, as in Roblox, and so does Enter unless the game binds Enter. On a phone it is a button in the corner row that opens a window.

A <TextChat> placed among the game’s views replaces the engine’s window. It takes these props:

  • slot moves the pane, Slot.BottomLeft by default. A phone keeps its button and its centred window. The other bottom slots keep clear of its input, as the Panel page says.
  • className restyles the pane, such as its width.
  • message draws each message in place of the engine’s line, with the engine’s Text rather than raw HTML. A message’s kind is data, so a game’s own message and an NPC’s line keep their marks in any style.
  • hidden draws nothing and still replaces the engine’s window, for a game that draws its own on useTextChat().

useTextChat() returns the chat on this page: state, channels, send(text, { channel }), and messages. Each message carries isOwn, and another player’s carries mute, block and report. The following views move the window to the top right with each sender in bold, and show another player’s last message over the screen:

packages/engine/test/outside/ChatCorner.tsx
import {
Panel,
PanelVariant,
Slot,
Text,
TextChat,
useTextChat,
type TextChatMessageProps,
} from "@spawnite/engine";
/** Each message with its sender in bold. A game's own message has no
* sender, and its kind still marks it as the game's. */
function BoldSender({ message }: TextChatMessageProps) {
return (
<Text>
{message.senderName && (
<Text className="font-bold">{message.senderName}: </Text>
)}
{message.text}
</Text>
);
}
/** The window, moved to the top right and wider, in place of the
* engine's own. */
export function TopRightChat() {
return (
<TextChat slot={Slot.TopRight} className="w-96" message={BoldSender} />
);
}
/** The last message another player sent, over the top of the screen,
* beside the window. */
export function LastMessage() {
const { messages } = useTextChat();
const last = messages.filter((message) => !message.isOwn).at(-1);
if (!last) return null;
return (
<Panel slot={Slot.Top} variant={PanelVariant.Glass}>
{last.senderName}: {last.text}
</Panel>
);
}

A game whose controls need / moves the chat key with controls({ keys: { textChat: ["Period"] } }), or drops it with controls({ keys: { textChat: [] } }). The player page lists the engine’s keys.

Every player keeps the player’s safety controls whatever window a game draws, or none:

  • The People rows in the play menu’s Audio tab list each other player with mute, block and “report recent messages”. No <HideSetting> key hides them.
  • The Text chat rows in Settings hold Show chat and the player’s filter level. No key hides them either.
  • A message’s menu in the engine’s window holds mute, block and report.

Mute hides a player on the muting player’s page for this room, in chat and in voice. Block stops every message both ways in every game, and the platform keeps it on the blocking player’s account. A report sends the platform the message and up to 20 on each side of it that the reporting player received, and nothing about a message leaves the room otherwise.

A block takes hold in the room at once. In the app, the platform keeps it only once the blocking player confirms it in the app’s own sheet, which names the blocked player and that player’s handle and which no game can draw or press. The blocking player’s Block stays unpressed until then. Cancel there puts the block back where the blocking player’s press found it: the room’s block goes too, unless an earlier one held in this room alone. When the platform cannot keep it, the sheet offers Try again; turned down, the block holds in this room alone, and Block can be pressed again. A window a game draws reads where each block stands in useTextChatSession()’s platformBlocks: confirming, kept or room-only.

spawnite play start gives each seat of a room game’s room an age group with --age-groups, by the name it joins under, or page for the session’s own page. A seat it leaves out is the unknown group. spawnite play text-chat then sends a message from the page, or with --as from any seat, and prints the room’s answer and whether the page shows it:

Terminal window
spawnite play start --bots 2 --age-groups "page=13-15,Bot 1=16-17,Bot 2=21-plus"
spawnite play text-chat "what a game" --as "Bot 2"
# Sent as Bot 2 on all, as the sender reads it: what a game
# The page did not show it within 2 s: a block, an age group, the channel or the game's canReceive keeps it from the local player's seat.
spawnite play text-chat "crap, hello" --as "Bot 1"
# The page shows it: [all] Bot 1: ####, hello
spawnite play text-chat "to my team" --channel team
# The room refused the page's message: no-channel, ...
spawnite play state
# Text chat: on, channels all, read at strict; 1 message.

The MCP’s send_text_chat and read_text_chat do the same on the session the director tool holds, and its start takes ageGroups. Playing a held session covers the session’s other commands.