NPC Dialogue System
ReleasedA 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.
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
31endNode 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.
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
42endServer-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.
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.
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.