NPC Dialogue with Typewriter Effect
A polished NPC dialogue system. ProximityPrompt opens a billboard, the dialogue typewriters in character-by-character, and players can advance through a multi-line conversation tree.
“I want NPCs in my game that players can talk to. When you walk up to one, you press E to start a conversation. The dialogue should appear with a typewriter effect, and you can press E to advance to the next line. Each NPC has its own dialogue.”
Paste this — or any variation — into StudByStud and you’ll get the code below in seconds.
How it works
- Each NPC is a Model with a HumanoidRootPart (or any anchored Part). Tag the model 'DialogueNPC' so the system finds it.
- On the server, the NPC Service adds a ProximityPrompt to each tagged NPC and sends the player the dialogue lines when they trigger the prompt.
- The dialogue text comes from a config ModuleScript keyed by NPC name — easy for designers to edit without touching script logic.
- On the client, the dialogue UI shows a BillboardGui above the NPC and types each line out one character at a time using string.sub.
- Players advance the conversation by re-pressing the prompt key. The client tells the server when the conversation ends so other systems (quests, achievements) can react.
The generated code
3 files. Each one is labeled with its Roblox Studio path.
--!strict
-- Dialogues.lua (ModuleScript in ReplicatedStorage)
export type DialogueLine = {
speaker: string,
text: string,
}
export type Dialogue = { DialogueLine }
local Dialogues: { [string]: Dialogue } = {
Blacksmith = {
{ speaker = "Blacksmith", text = "Welcome, adventurer. The forge is hot today." },
{ speaker = "Blacksmith", text = "Bring me 5 iron ore and I'll craft you a sword." },
{ speaker = "Blacksmith", text = "...don't keep me waiting." },
},
OldMan = {
{ speaker = "Old Man", text = "I haven't seen this place in fifty years." },
{ speaker = "Old Man", text = "The path to the mountain is closed. Be careful out there." },
},
}
return Dialogues
The dialogue config. Add new NPCs here without touching script logic. Designers can own this file.
--!strict
-- NPCDialogueService.lua
local CollectionService = game:GetService("CollectionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Dialogues = require(ReplicatedStorage:WaitForChild("Dialogues"))
local NPC_TAG = "DialogueNPC"
local DialogueRemote = Instance.new("RemoteEvent")
DialogueRemote.Name = "DialogueRemote"
DialogueRemote.Parent = ReplicatedStorage
local function setupNPC(npc: Instance)
if not npc:IsA("Model") then return end
local root = npc:FindFirstChild("HumanoidRootPart") or npc.PrimaryPart
if not root or not root:IsA("BasePart") then return end
local prompt = Instance.new("ProximityPrompt")
prompt.ActionText = "Talk"
prompt.ObjectText = npc.Name
prompt.HoldDuration = 0
prompt.MaxActivationDistance = 8
prompt.RequiresLineOfSight = false
prompt.Parent = root
prompt.Triggered:Connect(function(player)
local dialogue = Dialogues[npc.Name]
if not dialogue then
warn(`No dialogue for NPC: {npc.Name}`)
return
end
DialogueRemote:FireClient(player, npc, dialogue)
end)
end
for _, npc in CollectionService:GetTagged(NPC_TAG) do
task.spawn(setupNPC, npc)
end
CollectionService:GetInstanceAddedSignal(NPC_TAG):Connect(setupNPC)
Adds a ProximityPrompt to every tagged NPC and sends dialogue lines to the triggering player on demand.
--!strict
-- DialogueClient.client.lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
local DialogueRemote = ReplicatedStorage:WaitForChild("DialogueRemote")
type DialogueLine = { speaker: string, text: string }
local TYPE_SPEED = 0.02 -- seconds per character
local activeBillboard: BillboardGui? = nil
local advanceRequested = false
local function buildBillboard(npc: Model): BillboardGui
local adornee = npc:FindFirstChild("Head") or npc:FindFirstChildWhichIsA("BasePart")
local bb = Instance.new("BillboardGui")
bb.Adornee = adornee
bb.Size = UDim2.fromOffset(320, 110)
bb.StudsOffset = Vector3.new(0, 4, 0)
bb.AlwaysOnTop = true
local bg = Instance.new("Frame")
bg.Size = UDim2.fromScale(1, 1)
bg.BackgroundColor3 = Color3.fromRGB(20, 18, 15)
bg.BackgroundTransparency = 0.15
bg.BorderSizePixel = 0
bg.Parent = bb
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 12)
corner.Parent = bg
local speakerLabel = Instance.new("TextLabel")
speakerLabel.Name = "Speaker"
speakerLabel.Size = UDim2.new(1, -16, 0, 22)
speakerLabel.Position = UDim2.fromOffset(8, 6)
speakerLabel.BackgroundTransparency = 1
speakerLabel.Font = Enum.Font.GothamBold
speakerLabel.TextSize = 16
speakerLabel.TextColor3 = Color3.fromRGB(250, 92, 16)
speakerLabel.TextXAlignment = Enum.TextXAlignment.Left
speakerLabel.Parent = bg
local textLabel = Instance.new("TextLabel")
textLabel.Name = "Text"
textLabel.Size = UDim2.new(1, -16, 1, -34)
textLabel.Position = UDim2.fromOffset(8, 30)
textLabel.BackgroundTransparency = 1
textLabel.Font = Enum.Font.Gotham
textLabel.TextSize = 14
textLabel.TextColor3 = Color3.fromRGB(234, 229, 211)
textLabel.TextWrapped = true
textLabel.TextXAlignment = Enum.TextXAlignment.Left
textLabel.TextYAlignment = Enum.TextYAlignment.Top
textLabel.Parent = bg
return bb
end
UserInputService.InputBegan:Connect(function(input, processed)
if processed then return end
if input.KeyCode == Enum.KeyCode.Space or input.KeyCode == Enum.KeyCode.E then
advanceRequested = true
end
end)
local function typewrite(label: TextLabel, line: string)
advanceRequested = false
for i = 1, #line do
if advanceRequested then
-- Player wants to skip the typewriter — show full text immediately.
label.Text = line
return
end
label.Text = string.sub(line, 1, i)
task.wait(TYPE_SPEED)
end
end
local function waitForAdvance()
advanceRequested = false
while not advanceRequested do
task.wait()
end
end
DialogueRemote.OnClientEvent:Connect(function(npc: Model, dialogue: { DialogueLine })
if activeBillboard then return end -- already in a conversation
local bb = buildBillboard(npc)
bb.Parent = npc
activeBillboard = bb
local frame = bb:FindFirstChildWhichIsA("Frame") :: Frame
local speaker = frame:FindFirstChild("Speaker") :: TextLabel
local text = frame:FindFirstChild("Text") :: TextLabel
for _, line in dialogue do
speaker.Text = line.speaker
typewrite(text, line.text)
waitForAdvance()
end
bb:Destroy()
activeBillboard = nil
end)
Renders the dialogue billboard, typewriters each line, and lets the player advance with Space or E.
What you’ll learn
Want this built for your game?
Sign up, paste the prompt above, and StudByStud will generate it — and sync it straight into Roblox Studio. Free tier includes 1M Flash tokens per month.
