How to implement a Global Chat system using the new TextChatService

[Edit 27/12/24]: Seems like this topic has been getting some views recently.

This tutorial was written quite poorly, so I’ll soon rewrite it.


Heya!

In this tutorial, I’ll show you how to create a simple Global Chat system using TextChatService.

Please read this before proceeding:

This tutorial uses TextChatService (the new one)! If you’re using LegacyChatService (the old one), you can try this guide, or use this tutorial, by Hashtag Blu!

This tutorial is focused on creating the mere global chat system (that allows users from multiple servers to talk to each other). For other features such as custom commands, prefixes and so, you will have implement them yourself. I don’t know what you want. Except colors. I’ll let you know how to do that.

If you don’t want to bother following the tutorial, go below for the code. Except it’s more fun to build it yourself! You can add anything you’d like.

Tutorial

Setup

  • Prepare a RemoteEvent and place it under ReplicatedStorage. Name it anything you’d like. For this tutorial, we’ll name it “Messenger”.
    image

  • Create two scripts:

    • A Script under ServerScriptService
      image

    • A LocalScript under StarterPlayer - StarterPlayerScripts
      image

The next part? The scripting!

Client code

  • Let’s name some variables below.
---- Services ----
local TextChatService = game:GetService('TextChatService')
local ReplicatedStorage = game:GetService('ReplicatedStorage')
local Players = game:GetService('Players')

---- Variables ----
local Messenger = ReplicatedStorage:WaitForChild('Messenger') -- Our RemoteEvent
local channel = TextChatService:WaitForChild('TextChannels').RBXGeneral

RBXGeneral is the default general TextChannel where users send their messages. We’ll call methods using the general channel.

  • First, we want to listen for the local player sending a message. For this, we’ll use TextChatService.SendingMessage.
  • SendingMessage will fire when TextChannel:SendAsync() is called from the client. When you send a message, SendAsync() will be called by the default chat scripts, which will trigger the event.
TextChatService.SendingMessage:Connect(function(message)
     Messenger:FireServer(message.Text)
end)

SendingMessage passes a TextChannelMessage as parameter, which contains information about the message. For now, we only want the message, so we get it by doing message.Text, which is the string of the actual message the player sent.

  • Next, we’ll want to listen for when the server receives a message from the other players. You can send the data however you want, but I’ll use two variables here: the player who sent it, and their message.
Messenger.OnClientEvent:Connect(function(fromPlayer, playerMessage)
    channel:DisplaySystemMessage(fromPlayer .. ": " .. playerMessage)
end)

If you prefer to use string interpolation instead:

channel:DisplaySystemMessage(`{fromPlayer}: {playerMessage}`)

Because TextChatService doesn’t have a method that allows us to send messages as a player, we’ll use a System message instead.

Your code should look like this:
---- Services ----
local TextChatService = game:GetService('TextChatService')
local ReplicatedStorage = game:GetService('ReplicatedStorage')
local Players = game:GetService('Players')

---- Variables ----
local Messenger = ReplicatedStorage:WaitForChild('Messenger') -- Our RemoteEvent
local channel = TextChatService:WaitForChild('TextChannels').RBXGeneral

TextChatService.SendingMessage:Connect(function(data)
    Messenger:FireServer(data.Text)
end)

Messenger.OnClientEvent:Connect(function(fromPlayer, playerMessage)
    channel:DisplaySystemMessage(data.fromPlayer .. ": " .. data.Message)
end)

This is the client part! Now for the complicated part…

Server code

  • Again, some variable declarations…
---- Services ----
local TextChatService = game:GetService("TextChatService")
local MessagingService = game:GetService("MessagingService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

---- Variables ----
local Messenger = ReplicatedStorage:WaitForChild("Messenger")
local topic = "GlobalChat"
  • A topic is what MessagingService uses to listen to and publish messages. Like DataStores, it’s a unique string that identifies it. When listening or publishing a message, the topic must be the same one, or messages will not deliver. And again, like DataStores, you can name them however you’d like, as long as you… remember it.

  • First, let’s begin with the function that will send the message to other servers. For this, we’ll use MessagingService:PublishAsync(), which, when called, well, sends the message to other servers.

Messenger.OnServerEvent:Connect(function(player, message)
end)

Now here’s the thing:

  • You cannot send a dictionary via MessagingService! You can only send a piece of string, number or boolean, and they have to be UTF-8 characters. This means that we cannot send something like this:
local data = {
    Player = player,
    Message = "hi"
}
  • To compensate that, we’ll include both the player and their message as a single string, by separating them with a few characters:
local dataToBeSent = player.Name .. '\\' .. message

The slash should be enough. Course, you can also use other ways of separating it. But your question must be “how would i separate that when i get it from other servers?” We’ll figure that out in a second.

  • Now, we’ll put that inside the event connection, then send it on the way using PublishAsync()!
Messenger.OnServerEvent:Connect(function(player, message)
    local dataToBeSent = player.Name .. '\\' .. message
    MessagingService:PublishAsync(topic, dataToBeSent)
end)

For calls to any API service, it is recommended to wrap them in pcalls since they tend to error (which would error your code as well).

If you do want to wrap it in a protected call:
Messenger.OnServerEvent:Connect(function(player, message)
    local dataToBeSent = player.Name .. '\\' .. message
    local success = pcall(MessagingService.PublishAsync, MessagingService, topic, dataToBeSent)
end)

PublishAsync() takes two parameters:

  • Logically, the topic the message it’ll be sent to,
  • and the actual data to be sent.

That should be it! Now, to listen for the message being sent from other servers:

  • We want to initialise the chat by subscribing to the topic. Using MessagingService.SubscribeAsync, we begin listening to the topic.
MessagingService:SubscribeAsync(topic, function(message)
  • The first argument of SubscribeAsync is topic, which explained before, can be any name you desire. Remember that it must be the same topic you send the messages to, and receive messages from!
  • The second argument is a function that will be called when PublishAsync() is called from another server. It passes an argument for the callback function, containing what the server sent as a dictionary.

Remember how we sent the player’s message? As a string, yes! Now, what you’ll receive from PublishAsync() will be a dictionary instead, that contains:

  • Data, what you’ve provided to be sent,
  • Sent, UNIX time in seconds, showing when the message was created and sent.
    In this function we’ll only use Data, the message we provided.

Remember that we had to concatenate the player and the message for it to send? Now let’s separate that!

local fromPlayer, playerMessage = string.match(message.Data, "(.-)\\(.*)")

“What the heck does this mean?”

  • Using string.match, we find the matching string. Previously, we concatenated it as player\\message, so now we’re separating it by finding the double backslash.
  • The patterns
    • (.-) will look for everything before the double backslash (the player),
    • (.*) will look for everything else after the double backslash (the message).

That means that the pattern (.-)\\(.*) will:

  • Return the first string, which is everything before the double backslash,
  • and return a second string, everything after said backslash.

This ensures that we’re not separating anything in the wrong way, even if the message happens to contain the actual backslashes. And, I mean, the player’s name can’t contain backslashes, so it’s okay.

Now let’s send that to every player in the server! Oh, wait, hold on. What happens if the player who’s sending is in the server? We would have two messages for that player! Let’s fix that with “a single line”.

if table.find(Players:GetPlayers(), Players:GetPlayerByUserId(Players:GetUserIdFromNameAsync(fromPlayer))) then return end
  • We cannot compare Player == string, so we have to convert the username to a Player instance in order to compare. For that, we first get their UserId, then their Player instance, as there’s no such thing as GetPlayerFromUsernameAsync to directly turn the username into a Player instance.
  • If found (which means they’re in the server), don’t send this to all players, because everyone else would already have their message.

Alright, that should be it, right?

Wooah, hold on!

The message is not filtered! Let’s appropriately filter the player’s message.

  • Because <13 and 13+ have different filtering restrictions, we want to iterate through all players first. Can we ignore this? Yes, you could filter for broadcast, but let’s do it the correct way, which is, in context of Chat.
local objectSuccess, object = pcall(function()
    return TextService:FilterStringAsync(message, fromUserId, Enum.TextFilterContext.PublicChat)
end)

if not objectSuccess then return end
-- We want to get the FilterObject before filtering its string

local filterSuccess, result = pcall(function()
    return object:GetChatForUserAsync(forUserId)
    -- ensures we're filtering this for the player appropriately
end)

How would I actually implement this into my code?

  • Remember, let’s iterate through all players first. After the check to see if the player is already in the server:
if not table.find(Players:GetPlayers(), Players:GetPlayerByUserId(Players:GetUserIdFromNameAsync(fromPlayer))) then
    for _, player in Players:GetPlayers() do
        local objectSuccess, object = pcall(function()
            return TextService:FilterStringAsync(
                playerMessage, -- the message
                Players:GetUserIdFromNameAsync(fromPlayer), -- from who
                Enum.TextFilterContext.PublicChat -- Context for filtering
            )
        end)

        if not objectSuccess then return end -- If error, do what you want. Here, do nothing.

        local filterSuccess, result = pcall(function()
            return object:GetChatForUserAsync(player.UserId) -- Filter for each player according to their restrictions
        end)

        if not filterSuccess then return end -- again, manage the error however you'd like.

        Messenger:FireClient(player, fromPlayer, result) -- we send the payload - Player, and their now filtered message!
    end
end

What to do in case of an error?

  • Your choice, really. Don’t send anything, or send a chat warning as a system message. NEVER send an unfiltered string, even if it actually is fine.
That would be the server part! Your code should look like this.
---- Services ----
local TextChatService = game:GetService("TextChatService")
local MessagingService = game:GetService("MessagingService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

---- Variables ----
local Messenger = ReplicatedStorage:WaitForChild("Messenger")
local topic = "GlobalChat"

Messenger.OnServerEvent:Connect(function(player, message)
    local dataToBeSent = player.Name .. '\\' .. message
    MessagingService:PublishAsync(topic, dataToBeSent)
end)

MessagingService:SubscribeAsync(topic, function(message)
    local fromPlayer, playerMessage = string.match(message.Data, "(.-)\\(.*)")
    if not table.find(Players:GetPlayers(), Players:GetPlayerByUserId(Players:GetUserIdFromNameAsync(fromPlayer))) then
        for _, player in Players:GetPlayers() do
            local objectSuccess, object = pcall(function()
                return TextService:FilterStringAsync(
                    playerMessage, -- the message
                    Players:GetUserIdFromNameAsync(fromPlayer),
                    Enum.TextFilterContext.PublicChat -- Context for filtering
                )
            end)
            if not objectSuccess then return end

            local filterSuccess, result = pcall(function()
                return object:GetChatForUserAsync(player.UserId)
            end)
            if not filterSuccess then return end
            Messenger:FireClient(player, fromPlayer, result)
        end
    end
end)

You’re done!

Let’s put it to the test!

That’s my friend in another server, yup yup, that’s it!

Possible FAQ

How do I use Usernames for everyone instead of Display Names?

How would I color the usernames from other servers?

  • TextChatService supports Rich Text, which means we can color the text, in this case, usernames. Given the DisplayChannelMessage method, we do this instead:
local r, g, b = 123, 123, 123
currentChannel:DisplaySystemMessage(`<font color='rgb({r}, {g}, {b})'>{fromPlayer}:</font> {playerMessage}`)
Replace the RGB value with your desired colors. To get the colors different usernames like how Roblox does, use this code instead:
local NameColors = {
	BrickColor.new("Bright red").Color,
	BrickColor.new("Bright blue").Color,
	BrickColor.new("Earth green").Color,
	BrickColor.new("Bright violet").Color,
	BrickColor.new("Bright orange").Color,
	BrickColor.new("Bright yellow").Color,
	BrickColor.new("Light reddish violet").Color,
	BrickColor.new("Brick yellow").Color,
}

function GetPlayerChatColor(playerName: string)
    local value = 0
    for index = 1, #playerName do
        local calculatedValue = string.byte(
            string.sub(playerName, index, index)
        )
        local reverseIndex = #playerName - index + 1
        if #playerName % 2 == 1 then
            reverseIndex -= 1
        end

        if reverseIndex % 4 >= 2 then
            calculatedValue = -calculatedValue
        end

        value += calculatedValue
    end
    
    return NameColors[(value % #NameColors) + 1].R, NameColors[(value % #NameColors) + 1].G, NameColors[(value % #NameColors) + 1].B
end

local r, g, b = getPlayerChatColor(fromPlayer)
currentChannel:DisplaySystemMessage(`<font color='rgb({r}, {g}, {b})'>{fromPlayer}:</font> {playerMessage}`)

How would I add chat prefixes, such as tags, for players from other servers?

Limitations

Limitations are subjected to MessagingService limits. Refer to the documentation for it.
This chat system has no limits on how many players can be on it! To implement such limits, you’ll require some advanced scripting skills using MemoryStoreService.

Thank you!

If this tutorial helped you, do leave a like!
If something does not work, it means I may have written something wrong. Let me know and I’ll fix it!

18 Likes

While this isn’t entirely wrong, it is abusable by exploiters if they were to fire the RemoteEvent with whatever string they wish since there’s nothing on the server to verify that we are receiving actual filtered text.
I would add some filtering on the server anyway since it is still possible for unfiltered text to go through.

Another thing I thought about was the throttling that might happen if someone were to spam messages too fast since TextChatService.SendingMessage isn’t based on any rate limit. So I would force a rate limit on the messages sent so throttling doesn’t become too much of a problem.

Overall this is a really cool idea. It just needs some adjustments so it isn’t as abusable.

I wrote this without focus on security to keep things simple, but I’ll keep this in mind - thank you!

Please be aware that Roblox uses automated systems to detect filtering bypasses and will automatically suspend your game if a bypass is found.

Be aware that due to MessagingService limitations, you are limited to ~20 messages per minute across the entire game iirc.

1 Like

After testing, I figured out that the message within TextMessage (the instance) is not actually filtered - so we’re just sending unfiltered text. I’ve updated the post with an appropriate filtering method. Thanks for the heads-up!

3 Likes