A lightweight, stage-based NPC dialog system for Roblox, written in Luau. Handles dialog flow, player state, and client/server callbacks — your front-end UI is fully decoupled and implemented by you.
Licensed under the MIT License.
- Stage-based dialog — supports
FirstTime,NotFirstTime,Completed, andLockedstages out of the box, with support for custom stages - Conditional access — lock dialog stages behind arbitrary conditions (level requirements, quest progress, whitelists, etc.)
- Client & server callbacks — fire logic on either context when a dialog is completed or cancelled
- Decoupled front-end — the service manages state and flow; you implement your own UI
- Dual-context safe — modules load correctly whether required from a Script or LocalScript
Set up the following hierarchy in your Roblox place:
ReplicatedStorage
└── Dialog ← ModuleScript (the aggregator)
├── Util ← ModuleScript (DialogUtil)
└── DialogTemplate ← ModuleScript (one per NPC)
ReplicatedStorage
└── Remotes
└── Events
└── Dialog ← RemoteEvent
ServerScriptService
└── Services ← Folder
Note: The paths to
DialogRemoteandDialogat the top ofDialogServiceare configurable. Change them to match your project's structure before use.
- Copy the modules into your place following the folder structure above.
- Create the RemoteEvent at
ReplicatedStorage.Remotes.Events.Dialog(or update the path inDialogServiceto match your existing remote structure). - Require DialogService from a server Script where you handle NPC interactions:
local DialogService = require(path.to.DialogService)
- Require the Dialog aggregator is handled automatically inside
DialogService— no additional setup needed.
Each NPC gets its own ModuleScript inside the Dialog folder, based on the DialogObject template.
For NPCs that always say the same thing regardless of player progress, use only the Completed stage:
return {
DisplayName = "Shopkeeper",
["Completed"] = {
["Welcome to my shop!"] = {"Browse", "Leave"}
},
["CompletedDialog"] = {
["Completed"] = {
Client = function()
--open shop UI
end,
Server = function(Player: Player)
--open shop UI, though I think you'd use the client callback for this and omit the server callback entirely.
end
}
}
}For NPCs tied to quest or story progression, use FirstTime, NotFirstTime, and Completed:
return {
DisplayName = "Guard",
["FirstTime"] = {
["I have a job for you."] = {"Tell me more.", "Not now."},
["Come back when you're ready."] = {"Will do.", "Goodbye."}
},
["NotFirstTime"] = {
["Have you finished the task?"] = {"Not yet.", "Goodbye."}
},
["Completed"] = {
["Well done. You've proven yourself."] = {"Thanks.", "Goodbye."}
},
["CompletedDialog"] = {
["FirstTime"] = {
Server = function(Player)
--assign quest to player
end
},
["Completed"] = {
Server = function(Player)
--award quest rewards
end
}
},
["CancelledDialog"] = {
["FirstTime"] = {
Client = function()
--player left early, clean up if needed
end
}
}
}Use Locked to conditionally gate a dialog behind any requirement. The Condition function receives the Player and must return a boolean — true to allow access, false to show the locked dialog instead:
["Locked"] = {
Condition = function(Player: Player)
--example: require level 10
return Player:GetAttribute("Level") >= 10
end,
["You're not strong enough yet."] = {"I'll be back.", "Goodbye."}
},The
Lockedstage is checked before other stages. IfConditionreturnsfalse, the locked dialog plays regardless of the player's current stage.
| Stage | Intended Use |
|---|---|
FirstTime |
Player has never interacted with this NPC / started this quest |
NotFirstTime |
Player has interacted before but not completed |
Completed |
Player has completed the associated quest or interaction |
Locked |
Player does not meet the condition to interact |
You can define custom stages beyond these four, but you will need to implement your own logic for determining when to use them.
Create a handler with DialogService.new(), then call :Start() with the appropriate stage:
local DialogService = require(path.to.DialogService)
-- When a player interacts with an NPC:
local handler = DialogService.new(Player, "NPCName")
handler:Start("FirstTime")If Stage is omitted, it defaults to "Completed".
Use :Listen() to subscribe to the result of a dialog. The callback receives the DialogState ("Completed" or "Cancelled") and an optional RunServerContext boolean:
handler:Listen(function(State, RunServerContext)
if State == "Completed" then
print("Player completed the dialog.")
elseif State == "Cancelled" then
print("Player left early.")
end
end)| Method | Behavior |
|---|---|
handler:Cancel() |
Ends the dialog early and removes the listener |
handler:Destroy() |
Same as Cancel, and clears the handler reference |
DialogService fires a RemoteEvent to the client to signal dialog start and cancellation. Your client-side UI script should listen to this remote and handle rendering:
-- LocalScript (your UI handler)
local DialogRemote = ReplicatedStorage.Remotes.Events.Dialog
DialogRemote.OnClientEvent:Connect(function(DialogName, Action, Stage)
if Action == "Start" then
--Fetch dialog data and render your UI
--DialogName: which NPC's dialog to show
--Stage: which stage to display ("FirstTime", "Completed", etc.)
elseif Action == "Cancel" then
-- Hide your dialog UI
end
end)When the player selects a response in your UI, fire the remote back to the server with the result:
--signal completion
DialogRemote:FireServer("Completed", true) --true = run server callback
--signal cancellation
DialogRemote:FireServer("Cancelled", false)DialogUtil is a client-only utility module intended for helper functions shared across dialog objects — for example, functions that open specific UI frames. It is empty by default. Add your own functions as needed:
-- In DialogUtil:
function Util.OpenShopUI()
--your UI logic
end
-- In a DialogObject's Client callback:
local Util = require(ReplicatedStorage.Dialog.Util)
Util.OpenShopUI()
DialogUtilreturns an empty table on the server to prevent errors when dialog objects are required in a server context.
Defined in DialogsContainer:
| Type | Description |
|---|---|
DialogState |
"Completed" or "Cancelled" |
DialogStage |
"FirstTime", "NotFirstTime", "Completed", or "Locked" |
DialogPoint |
{ [string]: {string} } — NPC line mapped to response options |
CompletionCallback |
{ Client: () -> ()?, Server: (Player) -> ()? } |
CompletionHandler |
{ [DialogStage]: CompletionCallback } |
DialogBase |
Full type for a dialog object module |
- One handler per player — creating multiple handlers for the same player without destroying the previous one will overwrite the subscribed listener.
- Attributes —
DialogServiceusesPlayer:SetAttribute("CurrentDialog")andPlayer:SetAttribute("DialogStage")to track state. Avoid using these attribute names for other purposes. - Custom stages — fully supported in the data structure, but routing logic (when to call
Start()with a custom stage) is your responsibility on the server.