All modules

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.

Chat

[#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 = true

Full 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