Text players write
Words a player writes for other players, such as a pet’s name, a sign, a team’s name or a guess, arrive in a command field of their own kind, playerText(). The command’s reader never reads them unfiltered: it reads a PlayerText, which it passes to filterText for the words filtered for every player, or to findMatchingText to match them against the game’s own words. The platform’s text filter masks slurs, sexual words, threats, swearing, mild words, personal information such as a phone number or a social handle, and links, one # per character. Roblox asks the same of every experience: a player’s text that others see is filtered, a pet’s name and a sign included.
A field that holds a code, such as an item’s id or a colour from a list, is oneOf() instead, and a field that names an entity is entity(), as commands describes.
In a room today, filterText refuses every text with unavailable. A room publishes a player’s text only once it can check that its author may, and that check ships with the platform’s text chat. A game played alone filters and shows the text, so a playtest alone shows what a room will show then; a game handles the refusal now, as the sample below does, and the sender hears it as her command’s result.
A pet’s name, a guess and a colour
Section titled “A pet’s name, a guess and a colour”The following plugin lets a player rename a pet, guess a riddle’s answer and dye the pet, and is the file a test runs:
import type { World } from "koota";import { boolean, defineCommand, definePlugin, defineTrait, entity, oneOf, readCommands, type AuthoritativeStep,} from "@spawnite/engine/core";import { filterText, findMatchingText, playerText,} from "@spawnite/engine/player-text";
/** A pet every player sees: its name, filtered for everyone, and its * colour. */export const PetTrait = defineTrait("pet", { name: "Pet", colour: "red" });
/** A riddle players guess the answer to, and whether one has. */export const RiddleTrait = defineTrait("riddle", { answer: "crane", solved: false,});
// The game's own words: a guess becomes one of them, or nothing.const dictionary = ["crane", "slate", "flint", "otter"];
/** Names each pet a player renamed, with the name filtered for every * player, or refuses the rename with the filter's reason, which only * the sender hears. */function renamePets(_world: World, step: AuthoritativeStep) { for (const command of readCommands(step, pets.commands.rename)) { const { pet, name } = command.payload; const result = filterText(step, name); if (!result.ok) { command.refuse(result.reason); continue; } pet.set(PetTrait, { name: result.text }); command.accept(); }}
/** Solves a riddle when a guess matches its answer: the guess becomes the * dictionary's own word, which the game may read letter by letter. */function takeGuesses(_world: World, step: AuthoritativeStep) { for (const command of readCommands(step, pets.commands.guess)) { const { riddle, word } = command.payload; const match = findMatchingText(word, dictionary); const solved = match !== null && riddle.get(RiddleTrait)?.answer === match; if (solved) riddle.set(RiddleTrait, { solved: true }); command.accept({ solved }); }}
/** Dyes each pet the colour a player picked from the set. */function dyePets(_world: World, step: AuthoritativeStep) { for (const command of readCommands(step, pets.commands.dye)) { command.payload.pet.set(PetTrait, { colour: command.payload.colour }); command.accept(); }}
export const pets = definePlugin({ name: "pets", commands: { // Words a player writes: the reader holds a PlayerText. // The filter's reasons come with the field: no refusals to list. rename: defineCommand({ pet: entity(), name: playerText(24, { perMinute: 2 }), }), guess: defineCommand( { riddle: entity(), word: playerText(12) }, { returns: { solved: boolean() } }, ), // A code from a closed set: the reader reads the value itself. dye: defineCommand({ pet: entity(), colour: oneOf(["red", "teal", "gold"]), }), }, systems: { rules: { renamePets: { system: renamePets, answers: ["rename"] }, takeGuesses: { system: takeGuesses, answers: ["guess"] }, dyePets: { system: dyePets, answers: ["dye"] }, }, },});The page sends the string the player typed with sendCommand from @spawnite/engine, sendCommand(world, pets.commands.rename, { pet, name: "Biscuit" }), and awaits the command’s one result: accepted, or refused with the filter’s reason. playerText, PlayerText, filterText and the other names of a player’s text are imported from @spawnite/engine/player-text, an entry of their own, so a game that declares no playerText() field loads none of the filter; defineCommand, oneOf, entity and the plugin’s other names come from @spawnite/engine/core. A command with a playerText() field may be refused with each FilterTextRefusal reason with no refusals to list, so command.refuse(result.reason) needs no declaration; the refusal goes to the sender alone, since a reason such as not-allowed says she may not publish text, which another player has no business reading. A real game also checks that the sender may rename that pet, as checkTarget does for a target.
The field kinds
Section titled “The field kinds”playerText(maxLength, { perMinute })holds at mostmaxLengthUTF-16 units, as a string’slengthcounts them, within the 16,384 bytes of a command’s whole payload,maximumPayloadBytes.perMinute, from 1 to 30 and 6 when left out, is how often one player’s text of this field is published, so a sign renamed every second is not a way around a chat ban. The reader reads aPlayerText; the page sends astring. A command’sreturnsholds noplayerText()field, since its result goes to its sender alone.oneOf(values)holds one value of a closed set: a list,oneOf(["red", "teal"]); a string enum,oneOf(DoorState); or a registry a world holds, read as each value arrives. A system reads the value typed by the set, such as"red" | "teal", and the room refuses a command whose value is outside itmalformed.
What a game does with a PlayerText
Section titled “What a game does with a PlayerText”filterText(step, text), in the command’s reader, which takes anAuthoritativeStep, returns{ ok: true, text }, the words filtered for every player, or{ ok: false, reason }. It filters at the filter’s strictest level, the one fit for every player, because a trait’s value goes to every page at once, and a stored name is read later by players nobody knows yet; a game does not choose the level. The text was judged once, as the command arrived, so every call returns the same result. A text the filter masked whole comes backokand all#;readTextLength(text) === 0is the check for an empty one.findMatchingText(text, values, options?)returns the value of the game’s ownvaluesthe text equals, or null. Spaces at the ends never count, case does not by default, and accents do:{ ignoreCase: false }and{ ignoreAccents: true }change that. The value it returns is the game’s own string, which it may read letter by letter and show as it likes.isTextEqual(text, value, options?)returns whether the text equalsvalue, compared the same way.readTextLength(text)counts the characters a player sees, so a family emoji counts as one.filterSavedText(step, value)filters a string loaded from a save for every player. A save of a game played alone can be written by a modified page, so pass a saved name through it before a room shows it to others.
filterText refuses with one of the FilterTextRefusal string enum’s reasons, which the command takes with no declaration; a game lists its own reasons, such as a pet that is not hers, in refusals beside them:
| Member | Reason | When |
|---|---|---|
NotAllowed |
not-allowed |
The author may not publish text in this room, as a player who may not chat may not |
TooSoon |
too-soon |
The author sent this field more often than its perMinute |
Unavailable |
unavailable |
The room cannot check the author’s right to publish text yet, so it publishes none: today, every room |
A game played alone never refuses, since nobody else reads its text, so a playtest alone shows the filtered words.
What the type keeps a game from
Section titled “What the type keeps a game from”A PlayerText has no property that gives the words. It cannot be written to a trait, a save or a stream: a trait’s text field takes a string, and writing one as JSON throws an error that names filterText. Turned into a string, it reads [PlayerText]. Only a system that decides outcomes, the command’s reader on the room or in a game played alone, calls filterText.
A game that joins filtered words can still spell what neither said, and a game written to defeat the filter breaks the platform’s content policy, which reports enforce, as on Roblox.
What the filter does
Section titled “What the filter does”The filter cleans the text first: it removes invisible characters and stacked marks and folds every run of spaces to one, and the cleaned text is what players see. It reads through lookalike letters, accents, digits for letters and spaced-out letters, so f u c k and a word in Cyrillic letters are masked. It masks a whole word, so xXfuckerXx shows as ##########. The word lists are English; a phone number, an email, a link and a handle are found in any language.
Roblox, Unity’s Vivox Safe Text and Fortnite each filter a player’s text the same way: on the server, with personal information and links masked. Godot and PlayCanvas give a game no filter.