In short
Put your NPC model in a folder named NPCs in Workspace and write its conversation in a ModuleScript in ReplicatedStorage: each line has the NPC's text and a list of choices that lead to another line. A Script in ServerScriptService adds a ProximityPrompt ("Talk") to each NPC, remembers which line each player is on and sends it with a RemoteEvent. A LocalScript shows the text and one button per choice, and sends back only the number of the button pressed.
- Give rewards on the server. The built-in Dialog object's
DialogChoiceSelectedevent fires only on the client, so a server Script never hears it, and a heal or item given in a LocalScript changes only that player's own copy of the game. - Show a speech bubble with
TextChatService:DisplayBubble(npc.Head, text)from a LocalScript. The oldChatservice is deprecated in favour of TextChatService. - Lines you write yourself do not need filtering. Roblox's rule is to filter any displayed text you don't have explicit control over, such as what players type.
On this page
How NPC dialogue works
A talking NPC that works in a live game is split between the server and each player's device. The device draws the box and the buttons. The server, the only side you can trust, knows which line each player is reading and decides what every answer does. One turn of a conversation goes like this:
- The player walks up to the NPC and presses E on its Talk prompt. The prompt's
Triggeredevent fires on the server with that player. - The server script checks that the player is close enough, notes that they are on the line called
start, and sends that line's text and the labels of its choices to that player only. - The LocalScript types the line into the dialogue box, shows a speech bubble over the NPC, then adds one button per choice.
- The player clicks, taps or selects a choice. Only its number goes back to the server.
- The server looks that number up on the line it knows the player is reading, runs the choice's action if it has one (a heal, an item), and sends the next line or closes the box.
| Piece | Where it lives | What it does |
|---|---|---|
Guide rig | Workspace, in a folder named NPCs | The NPC. Its name picks its conversation and is shown on the prompt. |
DialogueData ModuleScript | ReplicatedStorage | Every line the NPC can say, the choices under each line and where each choice leads. |
DialogueService Script | ServerScriptService | Adds the Talk prompt, remembers which line each player is on, checks every click and runs the actions. |
DialogueEvent RemoteEvent | ReplicatedStorage (DialogueService creates it) | Carries each line to the player, and the number of the clicked choice back to the server. |
DialogueClient LocalScript | StarterPlayer, StarterPlayerScripts | Draws the box, types the line, shows the speech bubble and the choice buttons. |
Torch Tool (optional) | ServerStorage, in a folder named DialogueItems | The item one choice gives. Nothing in ServerStorage is sent to players. |
Workspace
NPCs Folder new
Guide Model new
Humanoid Humanoid
HumanoidRootPart Part
Head, UpperTorso, LowerTorso ... (the body parts)
ReplicatedStorage
DialogueData ModuleScript new
ServerScriptService
DialogueService Script new
ServerStorage
DialogueItems Folder new
Torch Tool new
Handle Part new
StarterPlayer
StarterPlayerScripts
DialogueClient LocalScript new
Step 1: Add an NPC
- On the Avatar or Home tab, click Character to open the Rig Generator. Choose R15, a body shape and a style. A rig named
Rigappears in the Workspace. An R6 rig works with the same scripts. - Rename it
Guide. The name matters: DialogueService looks up the conversation by the model's name, and the prompt shows it to players. - Expand the rig, select its HumanoidRootPart and tick Anchored in the Properties window. An anchored part never moves because of physics, so the NPC stands where you put it and players cannot push it around.
- Hover over Workspace in the Explorer, click the + button and insert a Folder named
NPCs. Drag Guide into it, and place the rig somewhere players can walk up to it. - Optional, for the item choice: hover over ServerStorage and insert a Folder named
DialogueItems. Put a Tool namedTorchinside it, with one unanchored Part namedHandle, which is the part a character holds. A PointLight inside the Handle makes it glow.
Names are case-sensitive, so type NPCs, Guide, DialogueItems and Torch exactly as shown. Any model with a Humanoid and a HumanoidRootPart can be an NPC, including one from the Toolbox, as long as it sits directly inside the NPCs folder.
Step 2: Write the conversation
Hover over ReplicatedStorage, insert a ModuleScript named DialogueData and paste this in. It holds no logic, only the words and how they link up, so this is the file you edit most.
-- ModuleScript in ReplicatedStorage named DialogueData.
-- Every conversation, keyed by the NPC's name in Workspace.NPCs. Each line has the
-- NPC's text and the player's choices. A choice can lead to another line (next),
-- end the talk (no next) and ask the server to do something (action). If the server
-- cannot do the action, it shows the failNext line instead of next.
export type Choice = {
text: string,
next: string?, -- the line to show after this choice; nil ends the conversation
action: string?, -- a server action from DialogueService's ACTIONS table
item: string?, -- for the GiveItem action: a Tool's name in ServerStorage.DialogueItems
failNext: string?, -- the line to show instead of next when the action could not be done
}
export type Line = {
text: string,
choices: { Choice },
}
local DialogueData: { [string]: { [string]: Line } } = {
Guide = {
start = {
text = "Welcome to the village! What can I do for you?",
choices = {
{ text = "Any tips?", next = "tips" },
{ text = "I'm hurt. Can you help?", next = "healed", action = "Heal", failNext = "healTooSoon" },
{ text = "Got anything for the cave?", next = "torch", action = "GiveItem", item = "Torch", failNext = "hasTorch" },
{ text = "Bye!" },
},
},
tips = {
text = "Stay off the red bricks, and come back if you get hurt.",
choices = {
{ text = "Anything else?", next = "start" },
{ text = "Thanks!" },
},
},
healed = {
text = "There you go, good as new.",
choices = {
{ text = "Thank you!" },
},
},
torch = {
text = "Take this torch. The cave is dark.",
choices = {
{ text = "Thanks!" },
},
},
healTooSoon = {
text = "I healed you a moment ago. Come back in a little while.",
choices = {
{ text = "OK" },
},
},
hasTorch = {
text = "You already have my torch. Keep it lit!",
choices = {
{ text = "Will do!" },
},
},
},
}
return DialogueData
The export type lines at the top describe what a line and a choice look like. They change nothing when the game runs; they describe your data so a type checker can check it.
How the pieces link up:
- Each NPC has a table named after its model in the NPCs folder, here
Guide. Every conversation starts at the line calledstart. textis what the NPC says.choicesare the player's buttons, in order. The box has room for four.nextnames the line to show after that choice. A choice with nonext, such as "Bye!", ends the conversation. AnextorfailNextthat names a line missing from the table ends it too, with a warning in Output.actionnames something the server does, from theACTIONStable in DialogueService:HealorGiveItemhere.itemis for GiveItem: the name of a Tool in ServerStorage, DialogueItems.failNextis the line shown instead ofnextwhen the action could not be done, so the NPC never claims a heal or a gift that did not happen.
| Line | The Guide says | Choices, and where they lead |
|---|---|---|
start | "Welcome to the village! What can I do for you?" | "Any tips?" leads to tips. "I'm hurt. Can you help?" heals, then healed (or healTooSoon). "Got anything for the cave?" gives the Torch, then torch (or hasTorch). "Bye!" ends. |
tips | "Stay off the red bricks, and come back if you get hurt." | "Anything else?" goes back to start. "Thanks!" ends. |
healed | "There you go, good as new." | "Thank you!" ends. |
torch | "Take this torch. The cave is dark." | "Thanks!" ends. |
healTooSoon | "I healed you a moment ago. Come back in a little while." | "OK" ends. |
hasTorch | "You already have my torch. Keep it lit!" | "Will do!" ends. |
Only the server reads this module. The LocalScript never requires it: the server sends each line when the player reaches it. A module in ReplicatedStorage is still copied to every player's device, though, so if a conversation holds spoilers (the answer to a puzzle, say), move DialogueData to ServerStorage. Then, in DialogueService's require line, change ReplicatedStorage to ServerStorage, so the whole line reads local DialogueData = require(ServerStorage:WaitForChild("DialogueData")) :: { [string]: { [string]: Line } }.
Step 3: Add the server script
Hover over ServerScriptService, insert a Script named DialogueService and paste the code below. It is the only place a conversation moves forward and the only place a reward is given.
-- Script in ServerScriptService named DialogueService.
-- Adds a Talk prompt to every NPC in Workspace.NPCs and runs each conversation.
-- The player's device only shows the text and sends back the number of the choice
-- they clicked. The server knows which line each player is on, checks the choice
-- and the distance, and is the only side that heals or gives items.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
-- The same shapes as in DialogueData, so Studio's type checker knows them here too
type Choice = { text: string, next: string?, action: string?, item: string?, failNext: string? }
type Line = { text: string, choices: { Choice } }
local DialogueData = require(ReplicatedStorage:WaitForChild("DialogueData")) :: { [string]: { [string]: Line } }
local TALK_DISTANCE = 12 -- studs: the prompt's range, and how close a player must stay
local MIN_SECONDS_BETWEEN_CHOICES = 0.3
local HEAL_COOLDOWN_SECONDS = 30
local npcFolder = workspace:WaitForChild("NPCs")
local itemFolder = ServerStorage:FindFirstChild("DialogueItems")
-- This script makes DialogueEvent itself, so do not add one by hand
local dialogueEvent = Instance.new("RemoteEvent")
dialogueEvent.Name = "DialogueEvent"
dialogueEvent.Parent = ReplicatedStorage
type Conversation = { npc: Model, npcName: string, lineId: string, lastChoice: number }
local conversations: { [Player]: Conversation } = {}
local lastHeal: { [Player]: number } = {}
local function rootOf(model: Model?): BasePart?
local root = model and model:FindFirstChild("HumanoidRootPart")
return if root and root:IsA("BasePart") then root else nil
end
local function isClose(player: Player, npc: Model): boolean
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
local playerRoot = rootOf(character)
local npcRoot = rootOf(npc)
if not (humanoid and humanoid.Health > 0 and playerRoot and npcRoot) then
return false -- no character, a dead one, or the NPC is gone
end
-- a little extra room, because the player's position reaches the server slightly late
return (playerRoot.Position - npcRoot.Position).Magnitude <= TALK_DISTANCE + 4
end
-- What the server does when a choice has an action. Each returns true if it did it.
local ACTIONS: { [string]: (player: Player, choice: Choice) -> boolean } = {
Heal = function(player)
local now = os.clock()
if now - (lastHeal[player] or -math.huge) < HEAL_COOLDOWN_SECONDS then
return false -- healed too recently
end
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not (humanoid and humanoid.Health > 0) then
return false
end
lastHeal[player] = now
humanoid.Health = humanoid.MaxHealth
return true
end,
GiveItem = function(player, choice)
local itemName = choice.item
local template = itemFolder and itemName and itemFolder:FindFirstChild(itemName)
if not (itemName and template and template:IsA("Tool")) then
warn(`DialogueService: no Tool named {itemName} in ServerStorage.DialogueItems`)
return false
end
local backpack = player:FindFirstChildOfClass("Backpack")
local character = player.Character
if not backpack or backpack:FindFirstChild(itemName) or (character and character:FindFirstChild(itemName)) then
return false -- no backpack yet, or they already have one
end
local item = template:Clone() :: Tool
item.CanBeDropped = false -- so nobody can drop it and ask for another
item.Parent = backpack
return true
end,
}
local function showLine(player: Player, conversation: Conversation)
local line = DialogueData[conversation.npcName][conversation.lineId]
local choiceTexts = {}
for _, choice in line.choices do
table.insert(choiceTexts, choice.text)
end
dialogueEvent:FireClient(player, "show", conversation.npc, line.text, choiceTexts)
end
local function endConversation(player: Player)
if conversations[player] then
conversations[player] = nil
dialogueEvent:FireClient(player, "close")
end
end
local function startConversation(player: Player, npc: Model)
local npcName = npc.Name
local lines = DialogueData[npcName]
if conversations[player] or not (lines and lines.start) or not isClose(player, npc) then
return -- already talking, no "start" line for this NPC, dead or too far away
end
local conversation = { npc = npc, npcName = npcName, lineId = "start", lastChoice = 0 }
conversations[player] = conversation
showLine(player, conversation)
end
-- The player clicked choice number choiceIndex (0 means they closed the box)
dialogueEvent.OnServerEvent:Connect(function(player: Player, choiceIndex: unknown)
local conversation = conversations[player]
if not conversation then
return
end
if typeof(choiceIndex) ~= "number" or choiceIndex ~= math.floor(choiceIndex) then
return
end
if choiceIndex == 0 or not isClose(player, conversation.npc) then
endConversation(player)
return
end
local now = os.clock()
if now - conversation.lastChoice < MIN_SECONDS_BETWEEN_CHOICES then
-- Too fast. Send the same line again, because the box has already locked its
-- buttons after the click and would otherwise wait for an answer forever.
showLine(player, conversation)
return
end
conversation.lastChoice = now
local line = DialogueData[conversation.npcName][conversation.lineId]
local choice = line.choices[choiceIndex]
if not choice then
return -- there is no such choice on the line this player is reading
end
local nextLine = choice.next
if choice.action then
local action = ACTIONS[choice.action]
local done = false
if action then
done = action(player, choice)
else
warn(`DialogueService: no action named {choice.action}`)
end
if not done and choice.failNext then
nextLine = choice.failNext -- for example "I healed you a moment ago"
end
end
if nextLine and not DialogueData[conversation.npcName][nextLine] then
warn(`DialogueService: no line named {nextLine} for {conversation.npcName} in DialogueData`)
nextLine = nil
end
if nextLine then
conversation.lineId = nextLine
showLine(player, conversation)
else
endConversation(player)
end
end)
-- A Talk prompt on each NPC
local function setUpNpc(npc: Instance)
local root = if npc:IsA("Model") then rootOf(npc) else nil
if not (npc:IsA("Model") and root) then
return
end
if not (DialogueData[npc.Name] and DialogueData[npc.Name].start) then
warn(`DialogueService: no conversation with a start line for {npc.Name} in DialogueData`)
end
local leftover = root:FindFirstChild("TalkPrompt")
if leftover then
leftover:Destroy() -- a copy of an NPC brings the old prompt, which nothing listens to
end
local prompt = Instance.new("ProximityPrompt")
prompt.Name = "TalkPrompt"
prompt.ActionText = "Talk"
prompt.ObjectText = npc.Name
prompt.MaxActivationDistance = TALK_DISTANCE
prompt.RequiresLineOfSight = false
prompt.Parent = root
prompt.Triggered:Connect(function(player: Player)
startConversation(player, npc)
end)
end
for _, npc in npcFolder:GetChildren() do
setUpNpc(npc)
end
npcFolder.ChildAdded:Connect(setUpNpc)
-- Close the box for anyone who walks away or dies mid-conversation
task.spawn(function()
while true do
task.wait(0.5)
for player, conversation in conversations do
if not isClose(player, conversation.npc) then
endConversation(player)
end
end
end
end)
Players.PlayerRemoving:Connect(function(player: Player)
conversations[player] = nil
lastHeal[player] = nil
end)
DialogueService creates the DialogueEvent RemoteEvent itself when the game starts, so there is nothing to insert in ReplicatedStorage by hand. If you already made a RemoteEvent with that name, delete it: the LocalScript could find yours, which nothing on the server listens to.
| Setting | Value here | What it changes |
|---|---|---|
TALK_DISTANCE | 12 | How close, in studs, a player must be for the Talk prompt to appear. The server lets them drift 4 studs further before it closes the box, because their position reaches the server slightly late. |
MIN_SECONDS_BETWEEN_CHOICES | 0.3 | The shortest time between two clicks the server accepts. A faster click gets the same line sent again instead of an answer. |
HEAL_COOLDOWN_SECONDS | 30 | How long each player waits between heals from the Heal action. |
The server keeps one entry per player in conversations: which NPC they are talking to, which line they are reading and when their last click was accepted. When the Talk prompt fires, startConversation creates that entry only if the player is not already talking, the NPC has a start line and the player is alive and within range. After that, every click goes through these checks:
| Check | Code | If it fails |
|---|---|---|
| The player is in a conversation | conversations[player] | The click is ignored. |
| The choice is a whole number | typeof and math.floor | Ignored. Strings, tables, fractions and NaN all stop here. |
| 0 means the player pressed X | choiceIndex == 0 | The conversation ends. |
| Alive and still within 16 studs (12 plus 4) | isClose | The conversation ends and the box closes. |
| At least 0.3 seconds since the last accepted click | conversation.lastChoice | The same line is sent again, so the box gets fresh buttons instead of waiting forever. |
| The choice exists on the line this player is reading | line.choices[choiceIndex] | Ignored. |
| The action worked | each action returns true or false | The failNext line is shown instead of next. |
The prompt goes on the NPC's HumanoidRootPart, because a ProximityPrompt works when it is inside a part (or an attachment, or a model with a PrimaryPart) in the Workspace. Its RequiresLineOfSight is on by default, which hides the prompt whenever something blocks the view from the camera to that part. Roblox leaves the prompt's own part out of that check, but not the rest of the body, and the HumanoidRootPart sits inside the torso, so the script turns line of sight off. The prompt is named TalkPrompt, and the script deletes any TalkPrompt already on the part before adding its own: a copy of an NPC made while the game runs arrives with the original's prompt, and nothing listens to a copied one. If DialogueData has no conversation with a start line for the NPC's name, Output shows a warning naming the NPC.
A loop checks every open conversation twice a second and closes it when the player is more than 16 studs away or their character has died, so a player who dies mid-conversation does not respawn with the box still open. PlayerRemoving clears both tables when someone leaves.
Step 4: Add the dialogue box
Open StarterPlayer, hover over StarterPlayerScripts, insert a LocalScript named DialogueClient and paste this in. It builds the dialogue box in code, so there is no GUI to draw by hand.
-- LocalScript in StarterPlayer > StarterPlayerScripts named DialogueClient.
-- Draws the conversation the server sends: the NPC's name, its line typed out letter
-- by letter, a speech bubble over its head and one button per choice. Clicking a
-- choice only sends its number; the server decides what happens next.
local GuiService = game:GetService("GuiService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TextChatService = game:GetService("TextChatService")
local UserInputService = game:GetService("UserInputService")
local dialogueEvent = ReplicatedStorage:WaitForChild("DialogueEvent") :: RemoteEvent
local player = Players.LocalPlayer
local LETTERS_PER_SECOND = 45
local REGULAR = Font.fromEnum(Enum.Font.BuilderSans)
local BOLD = Font.fromEnum(Enum.Font.BuilderSansBold)
-- The dialogue box, built in code
local gui = Instance.new("ScreenGui")
gui.Name = "Dialogue"
gui.ResetOnSpawn = false
gui.Enabled = false
local box = Instance.new("Frame")
box.AnchorPoint = Vector2.new(0.5, 1)
box.Position = UDim2.fromScale(0.5, 0.96)
box.Size = UDim2.fromScale(0.6, 0.34)
box.BackgroundColor3 = Color3.fromRGB(24, 24, 32)
box.BackgroundTransparency = 0.1
box.Parent = gui
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 12)
corner.Parent = box
local sizeLimit = Instance.new("UISizeConstraint")
sizeLimit.MaxSize = Vector2.new(720, 300)
sizeLimit.Parent = box
local nameLabel = Instance.new("TextLabel")
nameLabel.Position = UDim2.fromScale(0.04, 0.04)
nameLabel.Size = UDim2.fromScale(0.8, 0.14)
nameLabel.BackgroundTransparency = 1
nameLabel.TextXAlignment = Enum.TextXAlignment.Left
nameLabel.TextColor3 = Color3.fromRGB(190, 160, 255)
nameLabel.TextScaled = true
nameLabel.FontFace = BOLD
nameLabel.Parent = box
local lineLabel = nameLabel:Clone()
lineLabel.Position = UDim2.fromScale(0.04, 0.2)
lineLabel.Size = UDim2.fromScale(0.92, 0.3)
lineLabel.TextColor3 = Color3.new(1, 1, 1)
lineLabel.TextScaled = true -- a long line shrinks to fit on a small phone screen
lineLabel.TextWrapped = true
lineLabel.TextYAlignment = Enum.TextYAlignment.Top
lineLabel.FontFace = REGULAR
lineLabel.Parent = box
local lineTextLimit = Instance.new("UITextSizeConstraint")
lineTextLimit.MaxTextSize = 20 -- and never grows past 20 pixels on a big one
lineTextLimit.Parent = lineLabel
local closeButton = Instance.new("TextButton")
closeButton.AnchorPoint = Vector2.new(1, 0)
closeButton.Position = UDim2.new(1, -8, 0, 8)
closeButton.Size = UDim2.fromOffset(32, 32)
closeButton.BackgroundTransparency = 1
closeButton.Text = "X"
closeButton.TextColor3 = Color3.new(1, 1, 1)
closeButton.TextSize = 20
closeButton.FontFace = BOLD
closeButton.Parent = box
local choiceList = Instance.new("Frame")
choiceList.Position = UDim2.fromScale(0.04, 0.52)
choiceList.Size = UDim2.fromScale(0.92, 0.44)
choiceList.BackgroundTransparency = 1
choiceList.Parent = box
local layout = Instance.new("UIGridLayout")
layout.CellSize = UDim2.new(0.5, -6, 0.5, -6)
layout.CellPadding = UDim2.fromOffset(6, 6)
layout.SortOrder = Enum.SortOrder.LayoutOrder
layout.Parent = choiceList
gui.Parent = player:WaitForChild("PlayerGui")
local talkingTo: Model? = nil
local lineNumber = 0 -- goes up with every new line, so an old typing loop stops
local function setPromptVisible(npc: Model?, visible: boolean)
local root = npc and npc:FindFirstChild("HumanoidRootPart")
local prompt = root and root:FindFirstChild("TalkPrompt")
if prompt and prompt:IsA("ProximityPrompt") then
prompt.Enabled = visible -- changed on this device only
end
end
local function clearChoices()
for _, child in choiceList:GetChildren() do
if child:IsA("TextButton") then
child:Destroy()
end
end
end
local function close()
lineNumber += 1
clearChoices()
gui.Enabled = false
setPromptVisible(talkingTo, true)
talkingTo = nil
GuiService.SelectedObject = nil
end
local function show(npc: Model, text: string, choices: { string })
lineNumber += 1
local thisLine = lineNumber
if talkingTo ~= npc then
setPromptVisible(talkingTo, true)
talkingTo = npc
setPromptVisible(npc, false)
end
gui.Enabled = true
nameLabel.Text = npc.Name
clearChoices()
-- A speech bubble over the NPC (DisplayBubble only works in a LocalScript)
local head = npc:FindFirstChild("Head")
if head then
TextChatService:DisplayBubble(head, text)
end
-- Type the line out, letter by letter. The count comes from the time passed,
-- so the speed is the same at any frame rate.
lineLabel.Text = text
lineLabel.MaxVisibleGraphemes = 0
local letters = 0
for _ in utf8.graphemes(text) do
letters += 1
end
local startTime = os.clock()
local shown = 0
while shown < letters do
task.wait()
if lineNumber ~= thisLine then
return -- a newer line or a close replaced this one
end
shown = math.min(letters, math.floor((os.clock() - startTime) * LETTERS_PER_SECOND))
lineLabel.MaxVisibleGraphemes = shown
end
lineLabel.MaxVisibleGraphemes = -1
-- One button per choice; the server only ever receives the button's number
for index, choiceText in choices do
local button = Instance.new("TextButton")
button.LayoutOrder = index
button.BackgroundColor3 = Color3.fromRGB(124, 77, 255)
button.TextColor3 = Color3.new(1, 1, 1)
button.TextScaled = true
button.FontFace = BOLD
button.Text = choiceText
local buttonCorner = Instance.new("UICorner")
buttonCorner.CornerRadius = UDim.new(0, 8)
buttonCorner.Parent = button
button.Parent = choiceList
button.Activated:Connect(function()
if lineNumber == thisLine then
lineNumber += 1 -- one click per line
dialogueEvent:FireServer(index)
end
end)
if index == 1 and UserInputService.PreferredInput == Enum.PreferredInput.Gamepad then
GuiService.SelectedObject = button -- so a controller can pick a choice
end
end
end
closeButton.Activated:Connect(function()
dialogueEvent:FireServer(0)
close()
end)
dialogueEvent.OnClientEvent:Connect(function(kind: string, npc: Model?, text: string?, choices: { string }?)
if kind == "show" and npc and text and choices then
show(npc, text, choices)
elseif kind == "close" then
close()
end
end)
The colours, sizes and LETTERS_PER_SECOND are yours to change. Keep the last two handlers as they are: they are the only way the box talks to the server.
What each part of DialogueClient does:
- The box. A ScreenGui with a Frame anchored at the bottom centre of the screen, 60% of its width and 34% of its height, capped at 720 by 300 pixels by a
UISizeConstraintso it does not stretch across a wide monitor. The NPC's line usesTextScaled, which shrinks the text to fit inside its label on a small phone screen, and aUITextSizeConstraintstops it growing past 20 pixels on a big one. AUIGridLayoutlays the choices out two by two.ResetOnSpawnis false because this LocalScript runs once per session: a box deleted on respawn would never come back. - The typewriter. The whole line goes into the TextLabel at once, and
MaxVisibleGraphemesdecides how many characters show, the same property Roblox's own typewriter example uses withutf8.graphemes. Roblox lays the text out as if every character were visible, so words do not jump to a new line or change size as they appear. The script works out how many to show from the time that has passed, 45 a second, so the speed is the same at any frame rate, then sets the property to -1, which shows everything. - The buttons. They appear once the line has finished typing. Each one sends only its number, once: the
lineNumbercheck stops a double click from sending twice, and the box then waits for the server's next line. - The speech bubble.
TextChatService:DisplayBubble(head, text)puts the line in a chat bubble over the NPC's Head. Roblox allows it only in client scripts, and bubble chat is on by default. Because this player's device draws it, the bubble is theirs alone. - The prompt. While a player is talking, their copy of the Talk prompt (found by its name, TalkPrompt) is turned off, so it does not sit on top of the conversation. Other players still see theirs.
- Every device.
Activatedfires for a mouse click, a tap on a phone and the A button on a controller. A controller can only press a button that is selected, so whenPreferredInputsays the player is using a gamepad, the script selects the first choice withGuiService.SelectedObject. - The X button. It sends 0, which the server takes as "end the conversation", and closes the box straight away.
Add your own actions
An action is a function in the ACTIONS table near the top of DialogueService. It receives the player and the choice, and returns true when it did its job. To add one, say a quest that an NPC hands out:
- Add an entry to
ACTIONSnamedStartQuest. Check first, then change: ifplayer:GetAttribute("Quest")is already set, returnfalse; otherwise callplayer:SetAttribute("Quest", "Cave")and returntrue. - In DialogueData, give a choice
action = "StartQuest", anextline for when it works and afailNextline for when it does not, such as "You are already on a quest." - Press Play and pick the choice twice. If the name in DialogueData does not match the key in
ACTIONS(capitals count), Output showsDialogueService: no action named StartQuestand the conversation moves to thefailNextline.
Rules for writing actions:
- Do the check and the change inside the action, on the server. The only thing the player's device sends is a choice number.
- Read what the action needs from the choice (like
item) or from the server's own state, never from the remote. - Limit anything valuable inside the action itself, the way Heal keeps its own 30 second cooldown in
lastHeal. The 0.3 second click limit only slows clicking down; it does not stop a player from walking through the same choice again and again. - To open another window, such as a shop, an action can fire a RemoteEvent to that player and return
true. Whatever happens in that window still needs its own server checks, as in A shopkeeper NPC.
What other NPC dialogue tutorials get wrong
If you are comparing this guide with a video or a forum post, these are the differences that matter, with the reason each pattern is a problem.
| The tutorial | The problem | This guide |
|---|---|---|
Uses the Dialog object, with a server Script listening to DialogChoiceSelected to give a reward | Roblox's reference says that event is client-side only and will not fire on the server, so the reward never arrives. | The choice reaches the server as a number, and the server runs the reward. |
| Gives the reward from a LocalScript | A change a LocalScript makes stays on that one device (see what replicates), and a cheater can run the same code whenever they like. | Every action lives in DialogueService. |
Makes the NPC speak with game:GetService("Chat"):Chat(head, text) | The Chat service is deprecated in favour of TextChatService. | TextChatService:DisplayBubble in the LocalScript. |
| Sends the reward name or the next line's id from the client | A cheater can send any item name, or jump straight to the line that gives the reward. | The client sends only the choice number. The server looks it up on the line it knows the player is reading. |
| Checks nothing once the prompt is triggered | Roblox's security guide lists ProximityPrompts next to remotes as something clients can abuse, and asks whether the player is close enough before acting. | The distance is checked when the conversation starts, on every click and twice a second while it is open. |
Types the text with wait() in a loop | The global wait is deprecated in favour of task.wait. | Works out the letters to show from os.clock(), waiting with task.wait(). |
What a player can and cannot fake
Assume a cheater controls everything on their own device. They can read your LocalScripts and anything in ReplicatedStorage, fire DialogueEvent with any arguments as often as they like, and trigger the Talk prompt from code, which Roblox's security guide warns about. DialogueService answers with server checks of the kind covered in validating RemoteEvent arguments. Here is what that gets them.
| A cheater tries to | Result | Why |
|---|---|---|
| Trigger the Talk prompt from across the map | Nothing happens | startConversation checks that they are within 16 studs (12 plus 4). |
| Send choice 99, a string, a fraction or NaN | Ignored | It is not a whole number, or there is no such choice on the line they are reading. |
| Send a reward name or a line id | Never read | The server reads only the choice number. Rewards come from the item and action fields on the server's copy of the line. |
| Click as fast as the network allows | One click per 0.3 seconds per conversation | Faster clicks get their own line sent back. Ending and restarting the talk skips that wait, which is why Heal and GiveItem keep their own limits. |
| Ask for the torch without talking | Impossible | Items come only from a choice on the line the server knows they are on. |
| Collect a pile of torches | One at a time | The server checks their Backpack and character for a Torch first, and the torch it gives cannot be dropped, so it cannot be farmed by dropping it and asking again. |
| Spam the heal line | One heal every 30 seconds | The cooldown is kept per player on the server, and the NPC says so with the healTooSoon line. |
| Walk away and keep the box open | Closed within half a second | The server checks every open conversation twice a second. |
| Read the whole conversation, including lines they have not reached | Possible | DialogueData is in ReplicatedStorage, which is copied to every device. Move it to ServerStorage if that matters (see Step 2). |
One limit deserves a plain answer: a cheater who walks up to the Guide and picks the choices gets exactly what an honest player gets. The server controls how often and from where, not who. Anything scarce, such as a reward each player may claim once, needs a check the server owns, like an attribute set when it is claimed or a flag saved with a data store.
Step 5: Test it
- Press Play (F5) and walk to the Guide. Within 12 studs, a Talk prompt with the name Guide appears. Press E.
- The box shows "Guide", types the welcome line and then shows four buttons. The same line appears in a speech bubble over the NPC's head.
- Pick "Any tips?", then "Anything else?": you are back on the first line. Pick "Bye!": the box closes and the Talk prompt comes back.
- Hurt yourself first: click the Client/Server toggle to switch to Server, select your character's Humanoid in the Workspace, set Health to 50 in the Properties window and switch back to Client. Player characters regain 1% of their MaxHealth every second, so talk to the Guide straight away and pick "I'm hurt. Can you help?": your health jumps to full. Talk again within 30 seconds and pick it again: no heal, and the Guide says it healed you a moment ago.
- Pick "Got anything for the cave?": the Torch appears in your hotbar. Equip it and press Backspace: it stays in your hand. Ask again: no second torch, and the Guide says you already have it.
- Start talking, then walk away. The box closes about as soon as you are 16 studs from the Guide.
- Choose Server & Clients in the test mode dropdown, set 2 clients and press Play. Both players can talk to the Guide at the same time, each on their own line.
- Check a controller with the Controller Emulator (Controller, on the Emulation tab) during a playtest. Use the emulated pad to walk up and press the prompt's button (ButtonX by default): when the choices appear, the first one is selected, and A picks it.
- Check a phone screen with Studio's Device Simulator (a beta at the time of writing: turn on New Device Simulator under File, Beta Features, and restart Studio). Choose a phone, click Rotate until it is in landscape, press Play, walk to the Guide and tap the Talk prompt. Pick "Any tips?": the whole line shows above the choice buttons, in smaller text if it needs to, without running into them.
Keep the Output window open (Window menu, then Output) the whole time. These scripts print nothing when they work, so any red error or a warning starting with DialogueService: is a real problem. See Common errors.
Extending it
More NPCs and longer conversations
Add another rig to the NPCs folder with its own name, and a table with the same name in DialogueData that has a start line. DialogueService adds the Talk prompt to every model in the folder that has a HumanoidRootPart, including ones added while the game runs; a copy of an NPC gets a fresh prompt in place of the one it was copied with. Two NPCs with the same name use the same table. Lines can link in any order, and a choice can lead back to an earlier line, as "Anything else?" does. Keep each line to four choices, because the box lays them out two by two.
Lines that depend on progress
To greet returning players differently, choose the first line from something the server owns. In startConversation, pick it with local firstLine = if player:GetAttribute("MetGuide") then "welcomeBack" else "start", check that the line exists the way the script checks lines.start, and use firstLine instead of "start" when the conversation is created. Set the attribute in an action, and add a welcomeBack line to the Guide's table. Attributes last one session; to remember the player across visits, save the flag with a data store as in the DataStore tutorial.
A shopkeeper NPC
Build the shop from the shop GUI guide, then add an OpenShop action that fires a RemoteEvent to that player and returns true, and have the shop's LocalScript open its window when that event arrives. Purchases keep going through the shop's own server checks. If buying should only work near the NPC, check the distance inside the purchase handler too, because a client can call the shop's remote from anywhere.
NPCs that walk
To make the Guide wander or follow players, use the NPC pathfinding guide and untick Anchored on the HumanoidRootPart so it can move. The Talk prompt sits on that part, so it moves with the NPC, and the distance checks use the same part. Pause the walking while a conversation is open, or the box closes as soon as the NPC walks more than 16 studs from the player.
Keys, quests and other rewards
GiveItem hands out any Tool in DialogueItems, so an NPC can give the key that a locked door checks for, as in the door guide. For items that do something when used, such as food that heals, see the inventory guide.
Sounds and the camera
For a voice clip or a blip on each line, put a Sound in the NPC's Head and play it from show in DialogueClient. To point the camera at the NPC while it talks, the cutscene guide shows how a LocalScript takes over the camera and gives it back.
Common errors and fixes
Open the Output window (Window menu, then Output) before you press Play. Most dialogue problems print a warning there or come from a name that does not match.
Infinite yield possible on 'ReplicatedStorage:WaitForChild("DialogueEvent")'
DialogueClient is waiting for the remote that DialogueService creates, so DialogueService has not got that far. Check that it is a Script, not a LocalScript, directly inside ServerScriptService, and that it is not disabled. DialogueService first waits for DialogueData in ReplicatedStorage and the NPCs folder in the Workspace, so a missing or misspelt one stops it too; Output then shows an infinite yield warning from DialogueService naming it.
No Talk prompt appears over my NPC
DialogueService only adds prompts to models directly inside Workspace, NPCs that have a part named HumanoidRootPart. Check both, and walk within 12 studs. A rig from the Toolbox may be missing that part, or it may be a different kind of object wrapped in a folder.
The prompt appears, but pressing E does nothing
The server found no conversation for that NPC, and when the game starts Output shows DialogueService: no conversation with a start line for Guide in DialogueData, with your NPC's name. The table in DialogueData must have exactly the model's name, capitals included, and a line called start.
A choice closes the box instead of going to the next line
Output shows DialogueService: no line named tip for Guide in DialogueData, with your names: the choice's next (or failNext) names a line that is not in that NPC's table. Line names are case-sensitive, so check the spelling against the table, here tips.
The box works, but there is no speech bubble
The bubble needs a part named Head directly inside the NPC model, and bubble chat turned on: in the Explorer, select BubbleChatConfiguration under TextChatService and check that Enabled is ticked. It is on by default.
Output says DialogueService: no Tool named Torch in ServerStorage.DialogueItems
The server could not find the item. Check that the folder in ServerStorage is named DialogueItems and that it holds a Tool (not a Model or a Part) named exactly like the choice's item. Until you fix it, the Guide answers with the hasTorch line, because the action failed.
Output says DialogueService: no action named ...
A choice's action does not match any key in the ACTIONS table. The names are case-sensitive: heal is not Heal.
Build it with RoCode
If you would rather describe the NPC than wire it up by hand, RoCode can write these scripts into the place you have open. It is an independent AI agent for Roblox Studio, not made by Roblox: you type in a web chat, and its Studio plugin creates and edits the scripts in your Explorer.
Make the Guide model in Workspace.NPCs talk: a Talk prompt, a dialogue box with typed-out lines and choice buttons, a speech bubble, a heal choice with a 30 second cooldown and a choice that gives the Torch tool from ServerStorage once. Keep the conversation in a ModuleScript and give every reward on the server.
- Can look through the model first, using Get Properties to read key values such as Anchored on its parts and WalkSpeed on its Humanoid. Type / in the chat to attach the model's exact path
- Writes the DialogueData ModuleScript, the DialogueService Script and the DialogueClient LocalScript with Create Script
- Is told to compile-check each script it writes with Check Script, which compiles it in Studio without running it
- Sends its changes in batches, and each batch is an undo point in Studio's history
With plugin 3.9 or newer RoCode can run a short playtest and read the errors, but it does not move your character or take screenshots, so walk up to the NPC and talk to it yourself. If something misbehaves, tell RoCode what you saw: Check Output can read the Server and Client lines from your last playtest. A talking NPC is one system among many; making a Roblox game with AI, one system per prompt shows how they fit together. How RoCode connects to Studio.
Questions
How do I make an NPC talk in Roblox Studio?
Add a ProximityPrompt to the NPC and, when a player triggers it, show the NPC's line in a dialogue box, a speech bubble or both. The three scripts in this guide do both and add choices. For a bubble only, call TextChatService:DisplayBubble(npc.Head, "Hello!") from a LocalScript when the prompt is triggered, as in the NPC example on Roblox's bubble chat page.
Should I use the Dialog object or a custom dialogue GUI?
The Dialog object needs no code and shows a clickable speech bubble, which is enough for flavour text. Its choice event only fires on the player's device, so it cannot safely give rewards, and by default only one player can talk to it at a time. A custom box like the one in this guide lets the server run every reward and gives each player their own conversation.
How do I give the player an item from an NPC?
Keep the Tool in ServerStorage and have a server Script clone it into the player's Backpack when they pick that choice, as the GiveItem action does. Check that they do not already have one, and turn off the copy's CanBeDropped so it cannot be dropped and asked for again. Never let the player's device say which item to give.
How do I make a dialogue box with a typewriter effect?
Put the whole line in a TextLabel, raise its MaxVisibleGraphemes from 0 a few characters at a time, and set it to -1 at the end to show everything. Roblox's UI animation page uses the same property with utf8.graphemes. DialogueClient works out how many characters to show from the time that has passed, 45 a second, so the speed does not depend on the frame rate.
Can two players talk to the same NPC at once?
With the scripts in this guide, yes: the server keeps one conversation per player, and each player sees only their own box. The built-in Dialog object is different: with its default BehaviorType, SinglePlayer, one player talks at a time and the others see that player's choices.
Do I need to filter NPC dialogue text?
Not lines you wrote yourself. Roblox makes you responsible for filtering displayed text you don't have explicit control over, such as what players type, words generated from random characters, or text fetched from an outside website. If an NPC repeats something a player typed, filter that part.
Sources and further reading
- Roblox Creator Docs: Proximity prompts. creating prompts, MaxActivationDistance and RequiresLineOfSight
- Roblox Engine API: ProximityPrompt. where a prompt works, its default text and keys, Triggered and the line-of-sight check
- Roblox Creator Docs: Securing the client-server boundary. ProximityPrompts as an attack surface, and distance and permission checks
- Roblox Engine API: Dialog. BehaviorType and the client-only DialogChoiceSelected event
- Roblox Engine API: TextChatService:DisplayBubble. speech bubbles for NPCs from client scripts
- Roblox Creator Docs: Customize bubble chat. NPC bubbles and BubbleChatConfiguration
- Roblox Creator Docs: Text filtering. which displayed text you must filter
- Roblox Creator Docs: UI animation and tweens. the typewriter effect with MaxVisibleGraphemes
- Roblox Engine API: Tool.CanBeDropped. what Backspace does to a tool
- Roblox Engine API: UserInputService.PreferredInput. detecting a controller player
- Roblox Creator Docs: Rig Generator. inserting an R15 or R6 rig
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 15 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.