How to make a Dynamic Custom Chat Bubble


HOW TO MAKE A DYNAMICAL CUSTOM CHAT BUBBLE

Youtube Video | Full Game File(to support me)




This tutorial will allow you to create a script that will make NPCs or any parts in Roblox to "speak" (display text in a chat bubble) or "think" (show a spinning loading icon) dynamically. It makes sure that only one speech or thought bubble appears at a time per part while also adjusting the chat bubble size dynamically based on the text inside. This means that even if the text is very long, the bubble will resize accordingly to fit the content properly.


Let´s begin!


Creating the Custom Chat bubble

First things first, we need to create the chat bubble design that we will be using. For this I am using BillboardGui.


To start, we add a Rig to our workspace to use as a reference to where the custom chat bubble will appear on. Remember this does not always have to be a Humanoid character, it can just be a [Basepart](https://create.roblox.com/docs/reference/engine/classes/BasePart).

First, add a rig (NPC) or any part into your game. This will be the object that will display chat bubbles.

To make the chat bubble look clean, we need to hide the NPC’s default name display.


Next, we’ll create the UI that will display speech and thought bubbles.

  • Go to the NPC´s head and select it.
  • Right click the Head, then click Insert Object > BillboardGui.
  • Rename the BillboardGui to DialogChat.
  • Set the LightInfluence property to 0 (so the UI isn’t affected by lighting).
  • Set the Size property to {5,0,1,0}.
  • Set the StudsOffsetWorldSpace property to {0,1.5,0} (positions the bubble slightly above the NPC’s head).


Add a Frame for the Chat Box

Add a Text Label for the Message

  • Inside Main, insert a TextLabel.
  • Rename the TextLabel to MessageText.
  • Set the BackgroundTransparency property to 1 (removes the background).
  • Set the Size property to {1,0,1,0} (makes it fill the entire frame).
  • Set the FontFace property to FredokaOne (you can use any font, but this looks nice).
  • Set the TextColor3 property to {255,255,255} (white text).
  • Set the TextScaled property to true (ensures the text resizes properly).

Add Padding to the Chat Box. This makes sure the text inside has some spacing and doesn’t touch the edges.

For testing purposes, we will do the following:

  • Set the Visible property of the MessageText to false (to hide it).
  • Set the Size property of the DialogChat(BillboardGui) to {2,0,1,0} (this will resize the BillboardGui to a thinking state size).

This will help us visualize how the BillboardGui will look when the NPC is thinking (when it is loading).

Add a Loading Icon for “Thinking” Mode. This will be used for the “thinking” effect, where the NPC shows a loading animation.

  • Inside Main, insert a new Frame.
  • Rename the Frame to loading.
  • Set the AnchorPoint property to {0.5,0.5} (keeps it centered).
  • Set the BackgroundTransparency property to 1 (makes it transparent/invisible).
  • Set the Position property to {0.5,0,0.5,0} (places it in the center of the chat box).
  • Set the Size property to {0.308, 0, 0.617, 0}.

  • Inside loading, insert an ImageLabel.
  • Set the AnchorPoint property to {0.5,0.5} (keeps it centered).
  • Set the BackgroundTransparency property to 1 (removes the background).
  • Set the Position property to {0.5,0,0.5,0} (centers it).
  • Set the Size property to {1,0,1,0} (fills the loading frame).
  • Set the Image property to (set this to a spinning loading icon from the Toolbox, in my case I used 17021132616).



Creating the Localscript


We will need to put the DialogChat(BillboardGui) somewhere and only occupy it when needed, in this case we will put it inside a Localscript. This does not do any magic but it serves as an easy access for when we are scripting and as well hide it.

  • In StarterPlayerScripts, insert a LocalScript.
  • Rename the Localscript to DialogChatHandler.
  • Drag the DialogChat(BillboardGui) inside the LocalScript.

We will start editing this script later.


Choosing the Typing sound


For the typing sound, choose one that seamlessly loops, meaning it should sound continuous without any noticeable delay at the beginning or end of the sound. So take your time to find one that suits your sound choice and gets its Sound Id.

  • Inside DialogChatHandler, insert a Sound.
  • Rename the Sound to typing.
  • Set the Looped property to true (so it plays continuously while the NPC is talking).
  • Set the RollOffMaxDistance property to 60 (so the sound isn’t heard from too far away).
  • Set the SoundId property to a typing sound effect (in this case, the one I used was 5416502002).
    image


Setting Up the Remote Events


These will allow the server to trigger speech and thought bubbles dynamically on the selected clients (clients are players).

We use RemoteEvents because some functions, like GetTextSize, only work on the client. Since we need GetTextSize to dynamically adjust the chat bubble’s size based on the text, we handle this logic on the client.

  • In ReplicatedStorage, create a Folder.
  • Rename the folder to DialogRemotes.
  • Inside DialogRemotes, create two RemoteEvents.
  • Rename one of the RemoteEvents to speak and the other to think.
    image



Now that everything is set up, your NPCs (or any parts) should be able to speak dynamically with resizable chat bubbles and “think” using a spinning loading icon once we finish the scripting part!


Scripting


This is the fun part where we begin to create the magic, and by magic I mean scripting, creating the full functionality of the system through code. Open the DialogChatHandler(Localscript) we created and start scripting!

  • image


First we are going to declare the services we will be using.

----------  SERVICES ---------- 
local TextS = game:GetService("TextService")
local TS = game:GetService("TweenService")
local RS = game:GetService("ReplicatedStorage")
  • TextService (TextS) - Used to calculate the size of the text in the chat bubble.
  • TweenService (TS) - Helps create smooth animations when the chat bubble appears and disappears.
  • ReplicatedStorage (RS) - Stores remote events that allow the server to tell the client when an NPC should speak or think.

Then we declare the variables we will be using.

---------- VARIABLES ---------- 
local remotes = RS:WaitForChild("DialogRemotes")
local SpeakingThreads = {}-- Stores all the ongoing speaking threads according to each part
  • remotes - A folder inside ReplicatedStorage containing events that trigger speaking or thinking.
  • SpeakingThreads - Stores information about active speech or thought bubbles for each part.

local MaxXOffset = 560 --Max X Offset Size of the billboard
local MaxXScale = 7 -- Max X Scale Size of the billboard
local MinXScale = 2 --Min Y Scale Size of the billboard

local MaxYOffset = 600 --Max Y Offset Size of the billboard
local MinYOffset = 24 --Min Y Offset Size of the billboard
  • These will let us know the minimum and maximum sizes that DialogChat(BillboardGui) can get to.

local DialogDuration = 10 
  • The number of seconds a speech bubble stays visible before closing.

We will now create the functions we will be using.

We will create a function that will reset the DialogChat(BillboardGui) just in case if we were for some reasons testing and sizing it manually for accomodations.

----------  FUNCTIONS ---------- 
local function reset_dialogBillboard(billboard) -- function that resets the billboard dialog, removes all text and sizes it to empty state
	billboard.Size = UDim2.fromScale(0,0)
	billboard.Main.loading.Visible = false
	billboard.Main.MessageText.Visible = false
	billboard.StudsOffsetWorldSpace = Vector3.new(0,0,0)
end
  • This function resets the billboard to a hidden state.
  • It makes sure the chat bubble starts empty and disappears when needed.

We will create a function that will cancel or delete a speaking thread, so that when we want to create a new thread for a part and if by any chance it already has one, it cancels and deletes all assets created for it like sounds etc. This functions like Garbage Collector.

local function cancel_speakThread(data) -- function that will end/cancel a speaking thread
	if data.Thread then
		task.cancel(data.Thread)
	end
	if data.Billboard then
		data.Billboard:Destroy()
	end
	if data.Sound then
		data.Sound:Destroy()
	end
	data = nil
end
  • If a part is already speaking or thinking, this function cancels the old thread and delete the billboard UI and sound if they still exist.
  • This prevents overlapping text bubbles when a new speech/thought starts.

Now its time to create the function that will make the NPC or part speak. For this we must assure the function stops any previous speaking thread of the part, create a new one for the part, and dynamically size the new chat bubble according to the text.

local function speak(part,text)-- function that will make the part speak
	--// if there is a speaking thread for this part, cancel it
	if SpeakingThreads[part] then
		cancel_speakThread(SpeakingThreads[part])
	end
	
	local data = {}
	data.Thread = task.spawn(function()-- creates the new thread which can be cancelled at any time
		-- cloning and setting up the new bilbboard dialog
		local billboard = script.DialogChat:Clone()
		billboard.Parent = part
		reset_dialogBillboard(billboard)
		billboard.Main.MessageText.Visible = true
		data.Billboard = billboard
		
		-- clones the sound and plays it
		local sound = script.typing:Clone()
		sound.Parent = part
		data.Sound = sound
		sound:Play()
		-- start looping through each letter in the text
		for i = 1,#text do
			local newtext = string.sub(text,1,i)
			local newsize = TextS:GetTextSize(newtext,MinYOffset,billboard.Main.MessageText.Font,Vector2.new(MaxXOffset,MaxYOffset))
			billboard.Main.MessageText.Text = newtext
			local x = math.clamp((newsize.X * MaxXScale)/MaxXOffset,MinXScale,MaxXScale)
			local y = math.clamp(newsize.Y/MinYOffset,1,1000)
			TS:Create(billboard,TweenInfo.new(0.2,Enum.EasingStyle.Back),{Size = UDim2.fromScale(x,y),StudsOffsetWorldSpace = Vector3.new(0,(y/2)+1,0)}):Play()
			task.wait(0.01)
		end
		--once it finishes, destroy the sound
		if sound then
			sound:Destroy()
		end
		task.wait(DialogDuration) -- wait for some time before ending the dialog
		if data.Billboard then-- a smooth closing animation
			TS:Create(billboard,TweenInfo.new(0.5,Enum.EasingStyle.Back,Enum.EasingDirection.In),{Size = UDim2.fromScale(0,0),StudsOffsetWorldSpace = Vector3.new(0,0,0)}):Play()
		end
		task.wait(0.5)
		-- delete the thread after the closing animation has finished
		data.Thread = nil
		cancel_speakThread(data)
	end)
	
	SpeakingThreads[part] = data -- stores it in the variable so we can access it later to cancel it
end
  • Stops any previous speech for the part.
  • Creates a new chat bubble and attaches it to the NPC or part.
  • Plays a typing sound while the text appears.
  • Types out each letter one by one to make it look like the NPC is speaking.
  • While each letter is typed out, adjust the chat bubble size based on text length.
  • After a few seconds, it disappears smoothly.

Now to create the function that will make the NPC or part think, basically showing a spinning loading icon.

local function think(part)
	--// if there is a speaking thread for this part, cancel it
	if SpeakingThreads[part] then
		cancel_speakThread(SpeakingThreads[part])
	end
	
	local data = {}
	data.Thread = task.spawn(function()-- creates the new thread which can be cancelled at any time
		-- cloning and setting up the new bilbboard dialog
		local billboard = script.DialogChat:Clone()
		billboard.Parent = part
		reset_dialogBillboard(billboard)
		billboard.Main.loading.Visible = true
		data.Billboard = billboard
		
		TS:Create(billboard,TweenInfo.new(0.2,Enum.EasingStyle.Back),{Size = UDim2.fromScale(MinXScale,1),StudsOffsetWorldSpace = Vector3.new(0,1.5,0)}):Play()
		while true do
			billboard.Main.loading.ImageLabel.Rotation += 5
			task.wait()
		end
	end)

	SpeakingThreads[part] = data -- stores it in the variable so we can access it later to cancel it
end
  • Stops any previous speech or thought.
  • Creates a new chat bubble with a only the loading frame visible.
  • Rotates the loading icon continuously until it is stopped by a new thread, making it look like the NPC is thinking.

To finalize, we will create listeners that will listen for when the speak or think RemoteEvents are fired.

---------- EVENTS ----------
remotes:WaitForChild("speak").OnClientEvent:Connect(speak)
remotes:WaitForChild("think").OnClientEvent:Connect(think)
  • When speak is fired, the NPC will speak out the provided text.
  • When think is fired, the NPC will start thinking.


At the end our final Code for the DialogChatHandler will be the following:
----------  SERVICES ---------- 
local TextS = game:GetService("TextService")
local TS = game:GetService("TweenService")
local RS = game:GetService("ReplicatedStorage")

---------- VARIABLES ---------- 
local remotes = RS:WaitForChild("DialogRemotes")
local SpeakingThreads = {}-- Stores all the ongoing speaking threads according to each part

local MaxXOffset = 560 --Max X Offset Size of the billboard
local MaxXScale = 7 -- Max X Scale Size of the billboard
local MinXScale = 2 --Min Y Scale Size of the billboard

local MaxYOffset = 600 --Max Y Offset Size of the billboard
local MinYOffset = 24 --Min Y Offset Size of the billboard

local DialogDuration = 10 

----------  FUNCTIONS ---------- 
local function reset_dialogBillboard(billboard) -- function that resets the billboard dialog, removes all text and sizes it to empty state
	billboard.Size = UDim2.fromScale(0,0)
	billboard.Main.loading.Visible = false
	billboard.Main.MessageText.Visible = false
	billboard.StudsOffsetWorldSpace = Vector3.new(0,0,0)
end
local function cancel_speakThread(data) -- function that will end/cancel a speaking thread
	if data.Thread then
		task.cancel(data.Thread)
	end
	if data.Billboard then
		data.Billboard:Destroy()
	end
	if data.Sound then
		data.Sound:Destroy()
	end
	data = nil
end

local function speak(part,text)-- function that will make the part speak
	--// if there is a speaking thread for this part, cancel it
	if SpeakingThreads[part] then
		cancel_speakThread(SpeakingThreads[part])
	end
	
	local data = {}
	data.Thread = task.spawn(function()-- creates the new thread which can be cancelled at any time
		-- cloning and setting up the new bilbboard dialog
		local billboard = script.DialogChat:Clone()
		billboard.Parent = part
		reset_dialogBillboard(billboard)
		billboard.Main.MessageText.Visible = true
		data.Billboard = billboard
		
		-- clones the sound and plays it
		local sound = script.typing:Clone()
		sound.Parent = part
		data.Sound = sound
		sound:Play()
		-- start looping through each letter in the text
		for i = 1,#text do
			local newtext = string.sub(text,1,i)
			local newsize = TextS:GetTextSize(newtext,MinYOffset,billboard.Main.MessageText.Font,Vector2.new(MaxXOffset,MaxYOffset))
			billboard.Main.MessageText.Text = newtext
			local x = math.clamp((newsize.X * MaxXScale)/MaxXOffset,MinXScale,MaxXScale)
			local y = math.clamp(newsize.Y/MinYOffset,1,1000)
			TS:Create(billboard,TweenInfo.new(0.2,Enum.EasingStyle.Back),{Size = UDim2.fromScale(x,y),StudsOffsetWorldSpace = Vector3.new(0,(y/2)+1,0)}):Play()
			task.wait(0.01)
		end
		--once it finishes, destroy the sound
		if sound then
			sound:Destroy()
		end
		task.wait(DialogDuration) -- wait for some time before ending the dialog
		if data.Billboard then-- a smooth closing animation
			TS:Create(billboard,TweenInfo.new(0.5,Enum.EasingStyle.Back,Enum.EasingDirection.In),{Size = UDim2.fromScale(0,0),StudsOffsetWorldSpace = Vector3.new(0,0,0)}):Play()
		end
		task.wait(0.5)
		-- delete the thread after the closing animation has finished
		data.Thread = nil
		cancel_speakThread(data)
	end)
	
	SpeakingThreads[part] = data -- stores it in the variable so we can access it later to cancel it
end

local function think(part)
	--// if there is a speaking thread for this part, cancel it
	if SpeakingThreads[part] then
		cancel_speakThread(SpeakingThreads[part])
	end
	
	local data = {}
	data.Thread = task.spawn(function()-- creates the new thread which can be cancelled at any time
		-- cloning and setting up the new bilbboard dialog
		local billboard = script.DialogChat:Clone()
		billboard.Parent = part
		reset_dialogBillboard(billboard)
		billboard.Main.loading.Visible = true
		data.Billboard = billboard
		
		TS:Create(billboard,TweenInfo.new(0.2,Enum.EasingStyle.Back),{Size = UDim2.fromScale(MinXScale,1),StudsOffsetWorldSpace = Vector3.new(0,1.5,0)}):Play()
		while true do
			billboard.Main.loading.ImageLabel.Rotation += 5
			task.wait()
		end
	end)

	SpeakingThreads[part] = data -- stores it in the variable so we can access it later to cancel it
end

---------- EVENTS ----------
remotes:WaitForChild("speak").OnClientEvent:Connect(speak)
remotes:WaitForChild("think").OnClientEvent:Connect(think)

Feel free to copy and paste with no problem :smiley:


The End

Well that will be, if there are any corrections or anything you will like to point out, do so down below. Remember if you do not want to follow the tutorial above, you can just get the file (.rbxm) on my Patreon:heart: as a way to support me for doing this tutorials, you can also find other scripting stuffs I have done there aswell. Thanks :smiley:

5 Likes