Tutorial · Game systems

How to make NPC dialogue in Roblox Studio

NPC dialogue in Roblox is three pieces: a prompt to start talking, a box that shows what the NPC says with buttons for the player's answers, and a script that decides what each answer leads to. This guide builds all three: you write the conversation as a simple table, players press E to talk, and anything an answer gives (a heal, an item) is handed out by the server, never by the player's device.

Updated 15 October 202622 min readBeginnerLuau

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 DialogChoiceSelected event 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 old Chat service 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
  1. How it works
  2. 1. Add an NPC
  3. 2. Conversation
  4. 3. Server script
  5. 4. Dialogue box
  6. Your own actions
  7. Other tutorials
  8. Exploit limits
  9. 5. Test it
  10. Extending it
  11. Common errors
  12. With RoCode
  13. Questions
  14. Sources

How NPC dialogue works

IllustrationEach line types out in the box while the whole line shows in a speech bubble, the choices appear once the line has finished typing, and the server hands over the Torch when you pick "Got anything for the cave?".

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:

  1. The player walks up to the NPC and presses E on its Talk prompt. The prompt's Triggered event fires on the server with that player.
  2. 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.
  3. The LocalScript types the line into the dialogue box, shows a speech bubble over the NPC, then adds one button per choice.
  4. The player clicks, taps or selects a choice. Only its number goes back to the server.
  5. 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.
The pieces of a talking NPC
PieceWhere it livesWhat it does
Guide rigWorkspace, in a folder named NPCsThe NPC. Its name picks its conversation and is shown on the prompt.
DialogueData ModuleScriptReplicatedStorageEvery line the NPC can say, the choices under each line and where each choice leads.
DialogueService ScriptServerScriptServiceAdds the Talk prompt, remembers which line each player is on, checks every click and runs the actions.
DialogueEvent RemoteEventReplicatedStorage (DialogueService creates it)Carries each line to the player, and the number of the clicked choice back to the server.
DialogueClient LocalScriptStarterPlayer, StarterPlayerScriptsDraws the box, types the line, shows the speech bubble and the choice buttons.
Torch Tool (optional)ServerStorage, in a folder named DialogueItemsThe item one choice gives. Nothing in ServerStorage is sent to players.
Explorer when the NPC is finished. When the game runs, DialogueService adds a RemoteEvent named DialogueEvent to ReplicatedStorage and a Talk ProximityPrompt named TalkPrompt to each NPC's HumanoidRootPart.
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

  1. On the Avatar or Home tab, click Character to open the Rig Generator. Choose R15, a body shape and a style. A rig named Rig appears in the Workspace. An R6 rig works with the same scripts.
  2. Rename it Guide. The name matters: DialogueService looks up the conversation by the model's name, and the prompt shows it to players.
  3. 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.
  4. 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.
  5. Optional, for the item choice: hover over ServerStorage and insert a Folder named DialogueItems. Put a Tool named Torch inside it, with one unanchored Part named Handle, 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
-- 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 called start.
  • text is what the NPC says. choices are the player's buttons, in order. The box has room for four.
  • next names the line to show after that choice. A choice with no next, such as "Bye!", ends the conversation. A next or failNext that names a line missing from the table ends it too, with a warning in Output.
  • action names something the server does, from the ACTIONS table in DialogueService: Heal or GiveItem here.
  • item is for GiveItem: the name of a Tool in ServerStorage, DialogueItems.
  • failNext is the line shown instead of next when the action could not be done, so the NPC never claims a heal or a gift that did not happen.
The Guide's conversation, line by line
LineThe Guide saysChoices, 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
-- 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.

The settings at the top of DialogueService
SettingValue hereWhat it changes
TALK_DISTANCE12How 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_CHOICES0.3The shortest time between two clicks the server accepts. A faster click gets the same line sent again instead of an answer.
HEAL_COOLDOWN_SECONDS30How 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:

What DialogueService checks on every click
CheckCodeIf it fails
The player is in a conversationconversations[player]The click is ignored.
The choice is a whole numbertypeof and math.floorIgnored. Strings, tables, fractions and NaN all stop here.
0 means the player pressed XchoiceIndex == 0The conversation ends.
Alive and still within 16 studs (12 plus 4)isCloseThe conversation ends and the box closes.
At least 0.3 seconds since the last accepted clickconversation.lastChoiceThe same line is sent again, so the box gets fresh buttons instead of waiting forever.
The choice exists on the line this player is readingline.choices[choiceIndex]Ignored.
The action workedeach action returns true or falseThe 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
-- 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 UISizeConstraint so it does not stretch across a wide monitor. The NPC's line uses TextScaled, which shrinks the text to fit inside its label on a small phone screen, and a UITextSizeConstraint stops it growing past 20 pixels on a big one. A UIGridLayout lays the choices out two by two. ResetOnSpawn is 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 MaxVisibleGraphemes decides how many characters show, the same property Roblox's own typewriter example uses with utf8.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 lineNumber check 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. Activated fires 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 when PreferredInput says the player is using a gamepad, the script selects the first choice with GuiService.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:

  1. Add an entry to ACTIONS named StartQuest. Check first, then change: if player:GetAttribute("Quest") is already set, return false; otherwise call player:SetAttribute("Quest", "Cave") and return true.
  2. In DialogueData, give a choice action = "StartQuest", a next line for when it works and a failNext line for when it does not, such as "You are already on a quest."
  3. Press Play and pick the choice twice. If the name in DialogueData does not match the key in ACTIONS (capitals count), Output shows DialogueService: no action named StartQuest and the conversation moves to the failNext line.

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.

Common patterns in NPC dialogue tutorials, and what this guide does instead
The tutorialThe problemThis guide
Uses the Dialog object, with a server Script listening to DialogChoiceSelected to give a rewardRoblox'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 LocalScriptA 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 clientA 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 triggeredRoblox'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 loopThe 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.

What a cheater can try, and what DialogueService does about it
A cheater tries toResultWhy
Trigger the Talk prompt from across the mapNothing happensstartConversation checks that they are within 16 studs (12 plus 4).
Send choice 99, a string, a fraction or NaNIgnoredIt is not a whole number, or there is no such choice on the line they are reading.
Send a reward name or a line idNever readThe 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 allowsOne click per 0.3 seconds per conversationFaster 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 talkingImpossibleItems come only from a choice on the line the server knows they are on.
Collect a pile of torchesOne at a timeThe 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 lineOne heal every 30 secondsThe cooldown is kept per player on the server, and the NPC says so with the healTooSoon line.
Walk away and keep the box openClosed within half a secondThe server checks every open conversation twice a second.
Read the whole conversation, including lines they have not reachedPossibleDialogueData 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

  1. Press Play (F5) and walk to the Guide. Within 12 studs, a Talk prompt with the name Guide appears. Press E.
  2. 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.
  3. Pick "Any tips?", then "Anything else?": you are back on the first line. Pick "Bye!": the box closes and the Talk prompt comes back.
  4. 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.
  5. 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.
  6. Start talking, then walk away. The box closes about as soon as you are 16 studs from the Guide.
  7. 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.
  8. 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.
  9. 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.

You type

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.

RoCode does
  • 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

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.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.