Skip to content

TextFilter

Filters text one player writes before other players see it, through TextService on the server, and returns nothing when filtering fails.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.TextFilter
Since
v0.9.0
Source
TextFilter.luau

TextFilter runs player-written text through TextService:FilterStringAsync on the server before anyone else sees it. It has two entry points: broadcast for text every player may see, and forUser for text one recipient sees. Both fail closed: any error returns nil, and the caller then shows nothing. The raw text is never a fallback. Player Settings rules take Filter = "Broadcast" to run the same filter on stored strings. Text is a separate module that localizes the game’s own strings.

Roblox requires text that one player writes and another player sees to pass the text filter, or the experience risks moderation. That covers pet names, nicknames, sign and plot text, guild and team names, item inscriptions, trade notes and any custom chat outside TextChatService. Filter on the server, where the client cannot skip it.

It does not cover the game’s own strings (item names, quest text) or chat sent through TextChatService, which the engine filters. A ban’s DisplayReason is filtered by the engine as well (see Consequences).

filter:broadcast(session, text) calls FilterStringAsync with the author’s session.Player.UserId and Enum.TextFilterContext.PublicChat, then result:GetNonChatStringForBroadcastAsync(). The result is filtered for the strictest audience, so it is safe for every player in the server and for players who join later. It is the right call for text that outlives the moment it was written: a sign, a guild name, a plot title.

filter:forUser(session, text, toPlayer) calls FilterStringAsync with Enum.TextFilterContext.PrivateChat, then result:GetNonChatStringForUserAsync(toPlayer.UserId). The result is filtered for that recipient’s age and settings. Use it for text addressed to one player, such as a note attached to a trade offer.

The engine reference says the context argument does not change the result today; the wrapper passes the matching one anyway.

Both functions yield and return a string or nil. They return nil when:

  • text is not a string, the session is not a table, or the session has no Player.
  • forUser gets no toPlayer.
  • FilterStringAsync raises. It raises when the author is not in this server, among other failures.
  • The result method raises or returns something other than a string.

Neither function retries. FilterStringAsync retries internally, and the engine reference says not to retry it. Neither caches, so each call is one filter request.

Settings rules with Filter = “Broadcast”

Section titled “Settings rules with Filter = “Broadcast””

A Player Settings rule of kind "string" or "strings" can take Filter = "Broadcast". Settings.attach raises for any other Filter value and for a non-string kind. After the validation chain passes, each string of the value goes through broadcast, and the filtered result is what gets stored, synced and published. No path can show the raw text, and the author sees the filtered version too.

A failed filter rejects the write. An acknowledged write answers { Accepted = false, Value } and the client reports "Rejected" once, an intent write pushes a Settings_Sync rollback, and a server set returns false. A newer write to the same key while one is still filtering supersedes it, and so does the session ending or a trade taking the profile during the filter. A server set on a filtered rule yields while it filters.

A sign that shows text its owner wrote. The channel caps the length before the filter ever runs:

src/Shared/Game/Channels.luau
SetSignText = {
Kind = "Intent",
Schema = { "String" },
RateLimit = 1,
Validate = { [1] = { MaxLength = 100 } },
},
src/Server/Game/Services/SignService.luau
--!strict
-- SignService: each player's plot sign shows text they wrote, filtered for everyone.
local ServerScriptService = game:GetService("ServerScriptService")
local TextFilter = require(ServerScriptService.ChloeKernelServer.TextFilter)
local SignService = {
Name = "SignService",
}
function SignService.init(self: any, kernel: any)
self.Kernel = kernel
self.Filter = TextFilter.new()
-- Latest submission per session; a slower filter call must not overwrite a newer one
self.Tokens = setmetatable({}, { __mode = "k" })
end
function SignService.start(self: any)
self.Net.SetSignText.on(function(session, text: string)
local Token = (self.Tokens[session] or 0) + 1
self.Tokens[session] = Token
-- Yields. Filter at submission: the author must still be in the server.
local Shown = self.Filter:broadcast(session, text)
if Shown == nil or session.Active == false or self.Tokens[session] ~= Token then
return -- failed, left or superseded: show nothing, never the raw text
end
local Sign = workspace:FindFirstChild(`Sign_{session.Player.UserId}`)
if Sign then
Sign:SetAttribute("Text", Shown)
end
end)
end
return SignService

Store the filtered result and drop the raw text. Text that outlives its author cannot be filtered later, because FilterStringAsync raises once the author has left the server.

A note for one recipient uses forUser:

local Note = self.Filter:forUser(session, text, Recipient)
if Note and Recipient.Parent then
self.Net.TradeNote.send(Recipient, session.Player.UserId, Note)
end

Preferences that other players see are simpler through Settings, which filters, stores and syncs in one place:

local Prefs = Settings.attach(kernel, {
Schema = {
PetName = { Kind = "string", Default = "", MaxLength = 32, Filter = "Broadcast" },
MusicVolume = { Kind = "number", Default = 0.5, Min = 0, Max = 1 },
},
})
Member Description
TextFilter.new(options?) Returns a filter. options.Service replaces TextService, for specs. Attaches nothing to the kernel.
filter:broadcast(session, text) Yields. Text filtered for every player (PublicChat, GetNonChatStringForBroadcastAsync), or nil on any failure.
filter:forUser(session, text, toPlayer) Yields. Text filtered for one recipient (PrivateChat, GetNonChatStringForUserAsync), or nil on any failure.

TextFilter fires no hooks and publishes nothing. Settings publishes Settings.Changed(session, key, value) with the filtered value after a Filter = "Broadcast" write lands.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.