Back to Projects

NPC Dialogue System

Released
Roblox
Luau
UI
Module

A Roblox NPC dialogue system with branching player responses, fully customizable appearance, and a clean client/server split. Drop it in, configure it, and it works.

Typewriter engine

Each line is revealed character by character using a configurable speed. The player can hit skip at any point to jump to the full line instantly. Movement and jumping are locked for the duration.

DialogueClient.lua
1local function typewriteText(text, speed, waitTime)
2	humanoid.WalkSpeed = 0
3	humanoid.JumpPower = 0
4	gui.Enabled = true
5	textBox.Visible = true
6	textLabel.Text = ""
7	skipRequested = false
8
9	local formatted = formatText(text)
10	if skipButton then skipButton.Visible = true end
11
12	for i = 1, #formatted do
13		if skipRequested then
14			textLabel.Text = formatted
15			break
16		end
17		textLabel.Text = textLabel.Text .. formatted:sub(i, i)
18		talkSound:Play()
19		task.wait(speed or DEFAULT_SPEED)
20	end
21
22	skipRequested = false
23	if skipButton then skipButton.Visible = false end
24
25	local t = 0
26	while t < waitTime do
27		if skipRequested then break end
28		task.wait(0.05)
29		t += 0.05
30	end
31end

Node runner

The node runner is the core of the system. It reads a node by name from the active config, plays every line in order, fires a server action if the node has one, then either shows the answer buttons or ends the dialogue automatically.

DialogueClient.lua
1local function runNode(nodeName)
2	local node = activeConfig[nodeName]
3	if not node then endDialogue() return end
4
5	dialogueState = nodeName
6	local cfg = activeConfig.config or {}
7
8	if nameLabel then nameLabel.Text = cfg.name or "???" end
9
10	local speed = math.clamp(
11		node.speed or cfg.speed or DEFAULT_SPEED,
12		MIN_SPEED,
13		MAX_SPEED
14	)
15
16	for i, line in ipairs(node.lines or {}) do
17		local waitTime = (i == #node.lines) and 0.6 or 1.2
18		typewriteText(line, speed, waitTime)
19	end
20
21	if node.action then
22		runNodeAction()
23		-- if the node also moves the player to a new location,
24		-- wait for them to walk away then fade out
25		if node.location and node.location ~= "" then
26			local root = chr:FindFirstChild("HumanoidRootPart")
27			if root then
28				local origin = root.Position
29				repeat task.wait() until (root.Position - origin).Magnitude > 5
30			end
31			showLocation(node.location)
32			endDialogue()
33			return
34		end
35	end
36
37	if node.answers then
38		showAnswers(node.answers)
39	else
40		endDialogue()
41	end
42end

Server-side action dispatch

Actions that need server authority — giving currency, teleporting, updating datastores — live in a mirrored server config. The client fires a RemoteEvent with the NPC name, node, and optional answer index. The server validates all inputs, applies a rate limit, then calls the matching function.

dAHandler (Server).lua
1actionEvent.OnServerEvent:Connect(function(plr, npcName, nodeName, answerIndex)
2	if type(npcName) ~= "string" then return end
3	if type(nodeName) ~= "string" then return end
4	if answerIndex ~= nil and type(answerIndex) ~= "number" then return end
5	if isRateLimited(plr) then return end
6
7	local config = ServerConfigs["Config" .. npcName]
8	if not config then return end
9
10	local node = config[nodeName]
11	if not node then return end
12
13	local action
14	if answerIndex then
15		action = node.answers and node.answers[answerIndex]
16	else
17		action = node.action
18	end
19
20	if type(action) ~= "function" then return end
21
22	local ok, err = pcall(action, plr)
23	if not ok then
24		warn("Action error for " .. npcName .. "/" .. nodeName .. ": " .. tostring(err))
25	end
26end)

Writing a config

Each NPC has a client config that defines nodes, lines, and answer branches, and an optional server config for any nodes that need to run code. Here's an excerpt from Alice's config — a branching conversation with four opening responses and a slow-reveal story node.

ConfigAlice.lua
1return {
2	config = {
3		name     = "Alice",
4		location = "Riverbank",
5		speed    = 0.1,
6		camera   = "Cam3",
7	},
8
9	start = {
10		lines = {
11			"Hello, {player}.",
12			"My name is Alice.",
13			"You arrived at the riverbank.",
14			"Do you want to stay for a moment?"
15		},
16		answers = {
17			{ text = "Yes",             next = "stay"        },
18			{ text = "No",              next = "leave"       },
19			{ text = "Who are you?",    next = "who_are_you" },
20			{ text = "Where is this?",  next = "where_am_i"  },
21		},
22	},
23
24	story = {
25		speed = 0.05, -- slower reveal for atmosphere
26		lines = {
27			"There was a traveler who lost his path near this river.",
28			"He asked the water for direction.",
29			"The water answered only with silence.",
30			"He followed the silence anyway.",
31		},
32		answers = {
33			{ text = "What happened next?", next = "story_end" },
34			{ text = "Stop",                next = "end"       },
35		},
36	},
37
38	leave = {
39		action   = true,
40		location = "Main Town",
41		lines = {
42			"You turn away from the riverbank.",
43			"The sound fades behind you.",
44		},
45	},
46}

Adding an NPC

Create a client config module under the LocalScript and a server config under dAHandler, then add the NPC name to the DialogueData table in the client. No changes to the core runner needed.