Luau ยท ModuleScript ยท Chat
ChatTags
Coloured chat prefixes from player attributes. One callback, every tag in one place.
What it is
Turns a chat line into [#1 ๐ฐ] [VIP] [SHARK] CoolGuy. Roblox allows exactly one OnIncomingMessage callback. This module owns that hook and composes every tag in CONFIG.TAGS order.
See it in chat
The window below is the same composition the module does. Toggle attributes to watch the prefix change.
[#1 ๐ฐ] [VIP] [SHARK] CoolGuy: just sold a godly
[VIP] BlueFox: nice flip
[#3 ๐ฆ] [LEGEND] Mira: new record?
Nova: anyone buying?
Compose a prefix
Flip the attributes your server would stamp. The prefix updates the same way chat does.
[#1 ๐ฐ] [VIP] [SHARK] You: hello
Install
Drop ChatTags.luau into ReplicatedStorage as a ModuleScript. Call start once from a LocalScript. Needs TextChatService. Legacy chat is a no op with a warning.
LocalScript
require(game.ReplicatedStorage.ChatTags).start()Stamp attributes on the server. Clear a tag by setting the attribute to nil or false.
server.luau
player:SetAttribute("ChatVIP", true)
player:SetAttribute("EquippedTitleId", "Shark")
player:SetAttribute("ChatRank", 1)
player:SetAttribute("ChatBoard", "Cash")The three shapes
A flag tag is a boolean. A variant tag uses the attribute value as a key into colours and text. A value tag prints a number and lets a second attribute pick the emoji and colour.
CONFIG.TAGS
-- Flag tag
{ attribute = "ChatVIP", text = "VIP", color = Color3.fromRGB(255, 200, 60) }
-- Variant tag
{
attribute = "EquippedTitleId",
variants = {
Shark = { text = "SHARK", color = Color3.fromRGB(90, 200, 255) },
Legend = { text = "LEGEND", color = Color3.fromRGB(200, 120, 255) },
},
}
-- Value tag, styled by a second attribute
{
attribute = "ChatRank",
min = 1,
text = "#{value} {emoji}",
variantFrom = "ChatBoard",
variants = {
Cash = { emoji = "๐ฐ", color = Color3.fromRGB(120, 255, 130) },
Items = { emoji = "๐ฆ", color = Color3.fromRGB(120, 200, 255) },
},
}Reuse it
tagsFor returns the same rich text string. Nameplates and leaderboards stay consistent with chat.
nameplate.luau
nameplate.Text = ChatTags.tagsFor(player) .. " " .. player.DisplayName
nameplate.RichText = trueFull source
ChatTags.luau
--!nonstrict
--[[
ChatTags โ coloured chat prefixes: "[#1 ๐ฐ] [VIP] [SHARK] CoolGuy"
Client module. Owns the single TextChatService.OnIncomingMessage callback (Roblox only
allows one), so ALL of your chat decorations compose here instead of fighting over it.
Every tag is driven by a PLAYER ATTRIBUTE that your server stamps. Attributes set on the
server replicate to every client, so one `player:SetAttribute("ChatVIP", true)` gives that
player their tag in everyone's chat โ no remotes, no per-join broadcast, no desync.
Tags render in CONFIG.TAGS order, each optional; a player with none gets no override.
API:
ChatTags.start() -- hook OnIncomingMessage (call once, on the client)
ChatTags.stop() -- unhook
ChatTags.tagsFor(player) -- "" or "[VIP] [SHARK]" โ reuse on nameplates/leaderboards
ChatTags.Config -- the CONFIG table below (editable at runtime)
Requires TextChatService (ChatVersion = TextChatService). Legacy chat is a no-op.
]]
local Players = game:GetService("Players")
local TextChatService = game:GetService("TextChatService")
--====================================================================================
--// CONFIG
--
-- Each entry in TAGS is one tag, rendered left-to-right in array order.
--
-- attribute (string, required) player attribute that drives the tag. Missing,
-- nil, false or "" hides the tag.
-- text (string) the tag body. "{value}" = the attribute's value,
-- "{emoji}" = the matched variant's emoji.
-- color (Color3) fallback colour; a variant's colour wins.
-- min (number, optional) numeric attributes below this are hidden.
-- variants (table, optional) keyed lookup -> { text?, color?, emoji? }.
-- When present and the key has no entry, the tag is hidden.
-- variantFrom (string, optional) a SECOND attribute to key `variants` by.
-- Defaults to `attribute`.
--====================================================================================
local CONFIG = {
ENABLED = true, -- master kill-switch
OPEN = "[", -- wraps each tag
CLOSE = "]",
SEPARATOR = " ", -- between tags, and between the last tag and the name
TAGS = {
-- 1) Leaderboard rank โ two attributes: the rank number, and which board it came
-- from (the board supplies the colour + emoji). Hidden when rank < 1.
{
attribute = "ChatRank",
min = 1,
text = "#{value} {emoji}",
color = Color3.fromRGB(255, 255, 255),
variantFrom = "ChatBoard",
variants = {
Cash = { emoji = "๐ฐ", color = Color3.fromRGB(120, 255, 130) },
Items = { emoji = "๐ฆ", color = Color3.fromRGB(120, 200, 255) },
Time = { emoji = "โฑ", color = Color3.fromRGB(255, 210, 120) },
},
},
-- 2) Gamepass tag โ a plain boolean attribute.
{
attribute = "ChatVIP",
text = "VIP",
color = Color3.fromRGB(255, 200, 60),
},
-- 3) Equipped title โ the attribute holds the title id, which keys `variants`.
{
attribute = "EquippedTitleId",
variants = {
Shark = { text = "SHARK", color = Color3.fromRGB(90, 200, 255) },
Hustler = { text = "HUSTLER", color = Color3.fromRGB(255, 130, 60) },
Legend = { text = "LEGEND", color = Color3.fromRGB(200, 120, 255) },
},
},
},
}
--====================================================================================
--// INTERNAL
--====================================================================================
local ChatTags = {}
ChatTags.Config = CONFIG
-- Gate that stops the callback doing work once the LOCAL player is leaving: client
-- scripts are torn down on disconnect and an in-flight message would otherwise call
-- into a destroyed script and surface as a TextChatService async-callback error.
local active = false
local connected = false
local warned: { [string]: boolean } = {}
local function warnOnce(key: string, message: string)
if warned[key] then return end
warned[key] = true
warn(`[ChatTags] {message}`)
end
local function colorToRgbString(color: Color3): string
local r = math.clamp(math.floor(color.R * 255 + 0.5), 0, 255)
local g = math.clamp(math.floor(color.G * 255 + 0.5), 0, 255)
local b = math.clamp(math.floor(color.B * 255 + 0.5), 0, 255)
return `rgb({r}, {g}, {b})`
end
-- Build one tag's rich-text span for a speaker, or nil if it doesn't apply.
local function buildTag(speaker: Player, def): string?
local value = speaker:GetAttribute(def.attribute)
if value == nil or value == false or value == "" then
return nil
end
if type(value) == "number" then
if def.min and value < def.min then
return nil
end
value = math.floor(value)
end
local variant = nil
if def.variants then
local key = if def.variantFrom then speaker:GetAttribute(def.variantFrom) else value
if type(key) ~= "string" then
return nil
end
variant = def.variants[key]
if not variant then
warnOnce(def.attribute .. key, `unknown "{def.attribute}" variant: {key}`)
return nil
end
end
local template = (variant and variant.text) or def.text
if not template then
return nil
end
local fields = {
value = tostring(value),
emoji = (variant and variant.emoji) or "",
}
local body = (template:gsub("{(%w+)}", function(key)
return fields[key] or ""
end))
-- "{emoji}" resolving to "" can leave a double space / trailing space.
body = body:gsub("%s+", " "):match("^%s*(.-)%s*$")
if body == "" then
return nil
end
local color = (variant and variant.color) or def.color or Color3.new(1, 1, 1)
return `<font color='{colorToRgbString(color)}'>{CONFIG.OPEN}{body}{CONFIG.CLOSE}</font>`
end
-- All of a player's tags, joined. "" when they have none. Safe to reuse on nameplates.
function ChatTags.tagsFor(speaker: Player): string
if not CONFIG.ENABLED then
return ""
end
local parts: { string } = {}
for _, def in CONFIG.TAGS do
local tag = buildTag(speaker, def)
if tag then
table.insert(parts, tag)
end
end
return table.concat(parts, CONFIG.SEPARATOR)
end
local function getSpeakerFromMessage(message: TextChatMessage): Player?
local textSource = message.TextSource
if not textSource then
return nil
end
local userId = textSource.UserId
if not userId or userId <= 0 then
return nil
end
return Players:GetPlayerByUserId(userId)
end
local function applyChatPrefix(message: TextChatMessage): TextChatMessageProperties?
local speaker = getSpeakerFromMessage(message)
if not speaker then
return nil
end
local basePrefix = message.PrefixText
if basePrefix == "" then
basePrefix = speaker.DisplayName
end
-- OnIncomingMessage runs twice per send (local echo + filtered). On the second pass
-- the prefix may already carry our injected rich text โ never compound it.
if string.find(basePrefix, "<font", 1, true) then
return nil
end
local tags = ChatTags.tagsFor(speaker)
if tags == "" then
return nil
end
local overrideProperties = Instance.new("TextChatMessageProperties")
overrideProperties.PrefixText = tags .. CONFIG.SEPARATOR .. basePrefix
return overrideProperties
end
-- The single OnIncomingMessage callback. The body is pcall-guarded so a transient error
-- (e.g. touching a leaving player's instance mid-teardown) degrades to "no override"
-- instead of surfacing as an async-callback error and dropping the message entirely.
local function onIncomingMessage(message: TextChatMessage): TextChatMessageProperties?
if not active then
return nil
end
local ok, result = pcall(applyChatPrefix, message)
if not ok then
return nil
end
return result
end
--====================================================================================
--// PUBLIC
--====================================================================================
function ChatTags.start()
if active then
return
end
if TextChatService.ChatVersion ~= Enum.ChatVersion.TextChatService then
warn("[ChatTags] Legacy chat detected โ chat tags disabled.")
return
end
active = true
TextChatService.OnIncomingMessage = onIncomingMessage
-- Stop feeding the callback once OUR player leaves (see the `active` note above).
-- Connected once, so a stop() โ start() cycle can't stack listeners.
if not connected then
connected = true
Players.PlayerRemoving:Connect(function(leaving: Player)
if leaving == Players.LocalPlayer then
ChatTags.stop()
end
end)
end
end
function ChatTags.stop()
active = false
pcall(function()
if TextChatService.OnIncomingMessage == onIncomingMessage then
TextChatService.OnIncomingMessage = nil
end
end)
end
return ChatTags