IntermediateNPCDialogueProximityPromptBillboardGuiClient

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.

3 files122 lines of Luau
The Prompt

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.

File 1 of 3ModuleScript
ReplicatedStorage/Dialogues.lualuau
--!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.

File 2 of 3Script
ServerScriptService/NPCDialogueService.lualuau
--!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.

File 3 of 3LocalScript
StarterPlayerScripts/DialogueClient.client.lualuau
--!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

ProximityPrompt for interactionBillboardGui anchored to a PartPer-NPC dialogue stored in attributes or a config moduleTypewriter effect using string.sub and task.waitTriggered events with debouncing per player

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.

Build it free