Skip to content

DialogueKit

Runs conversation trees on the server with per-player gated choices and server-side consequences; the client renders the node it is sent and answers with an index.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Kits.DialogueKit
Since
v0.7.0
Source
Kits/DialogueKit.luau

DialogueKit runs conversation trees on the server. A dialogue is a start node and a map of nodes; a node is a line, optional choices and what happens next. The client never holds the tree. It renders the node the server just pushed and answers with an index into the choices it was shown. A choice hidden by a Where gate is not in that list at all, and the gate is checked again when the choice is picked. Consequences run in server code. Conversations usually start from an InteractionKit prompt.

Node = { Line: string, Speaker: string?, Next: string?, Choices: { Choice }?, OnEnter: ((session) -> ())? }
Choice = { Text: string, Next: string?, Where: ((session) -> boolean)?, Run: ((session) -> ())? }

Line, Speaker and Choice.Text are Text keys. Send them as they are and localize on the client.

A node with visible choices waits for a pick. A node without them waits for advance(), which moves to its Next or, with no Next, ends the dialogue with reason "Completed". A picked choice works the same way: its Next moves on, and no Next ends the dialogue.

attach() validates every dialogue: Start must name a node, every node needs a Line, every choice needs Text, and every node Next and choice Next must name a node in the same dialogue. A dangling reference raises at attach, so a typo in a node id fails at boot instead of leaving a dead end in a live conversation.

Each time a node is entered, the kit runs every choice’s Where(session) under pcall (a missing Where passes; an error or a falsy result hides the choice) and stores the surviving choices as the session’s visible list. That list is what the client receives and what indices mean:

  • The synced Choices are the visible choices’ Text keys, in order.
  • DLG_Choose(index) and choose(session, index) look up visible[index].

If a node has three choices and the middle one’s Where fails, the client sees two, and “option 2” is the tree’s third choice. Nothing the client sends can reach the hidden one.

choose then runs the picked choice’s Where again. State can change between the sync and the pick (reputation drops, a timed condition lapses), and a failed re-check returns false, "Vetoed" without running anything.

Where, Run and OnEnter may yield (a DataStore read, a Policy check). While any of them runs, the session’s dialogue is Busy:

  • Entering a node marks it busy, computes the visible list, runs OnEnter, then clears the flag before publishing Dialogue.Node and pushing the sync.
  • A pick marks it busy, re-checks Where, publishes Dialogue.Choice, runs Run, then clears the flag.
  • While busy, choose and advance return false, "Busy", and both intent validators reject.

A repeated pick sent while Run yields cannot run the same choice twice. Before 0.9.0 it could.

DLG_Sync carries one node as an Any payload: { Dialogue, Node, Line, Speaker?, Choices }. It is pushed on every node entry, and pushed as {} (empty, not nil) when the dialogue closes, which is the client’s cue to tear down the UI.

Both intents are fail-closed and checked against the session’s live node at the moment they arrive, never against anything the client claims:

Intent Schema Rate limit Kit validator (priority 50)
DLG_Choose { "NumberU8" } (visible index) 10/s The session is in a dialogue, it is not busy, and the index exists in the live node’s visible list.
DLG_Advance {} 10/s The session is in a dialogue, it is not busy, and the live node has no visible choices.

A client that missed a sync and sends an index meant for an earlier node is checked against the node the server is actually on. DLG_Advance cannot skip a pending choice.

There is no DLG_Begin. Starting a conversation is a server call from game code, usually when a prompt is triggered.

attach() registers a session binding through kernel:onSession that removes the session’s dialogue when the player leaves, whether or not intents are wired. No DLG_Sync close and no Dialogue.Ended are sent on that path. To react to a conversation cut short by a leave, watch Kernel.SessionEnd.

DialogueKit is an attach kit. Attach it from the owning service’s init:

src/Server/Game/Config/Dialogues.luau
--!strict
-- Dialogues: every conversation tree, keyed by id. Lines are Text keys.
return {
Blacksmith = {
Start = "Greet",
Nodes = {
Greet = {
Line = "Smith.Greet",
Speaker = "Smith.Name",
Choices = {
{ Text = "Smith.AskWares", Next = "Wares" },
{
Text = "Smith.AskSecret",
Next = "Secret",
Where = function(session)
local Profile = session.Profile
return Profile ~= nil and (Profile.Data.Reputation or 0) >= 10
end,
},
{ Text = "Smith.Leave" }, -- no Next: ends the dialogue
},
},
Wares = { Line = "Smith.Wares", Next = "Greet" },
Secret = { Line = "Smith.Secret" }, -- no choices, no Next: advance() ends it
},
},
}
src/Server/Game/Services/DialogueService.luau
--!strict
-- DialogueService: conversations, started from InteractionKit prompts.
local ServerScriptService = game:GetService("ServerScriptService")
local DialogueKit = require(ServerScriptService.ChloeKernelServer.Kits.DialogueKit)
local Dialogues = require(script.Parent.Parent.Config.Dialogues)
local DialogueService = {
Name = "DialogueService",
Dependencies = { "InteractionKit" },
}
-- Interaction id -> dialogue id
local Talks: { [string]: string } = {
TalkToSmith = "Blacksmith",
}
function DialogueService.init(self: any, kernel: any)
self.Kernel = kernel
self.Dialogues = DialogueKit.attach(kernel, { Dialogues = Dialogues })
end
function DialogueService.start(self: any)
-- Interact.Triggered publishes only after InteractionKit validated distance and sight
self.Connection = self.Kernel.Bus:subscribe("Interact.Triggered", function(_topic, session, interactionId)
local DialogueId = Talks[interactionId]
if DialogueId then
self.Dialogues:begin(session, DialogueId)
end
end)
-- No dialogue while in combat
self.Unhook = self.Kernel.Hooks:on("Dialogue.CanBegin", function(context: any): boolean?
if context.Session.Data.InCombat then
return false
end
return nil
end)
end
function DialogueService.stop(self: any)
if self.Connection then
self.Connection:disconnect()
end
if self.Unhook then
self.Unhook()
end
if self.Dialogues then
self.Dialogues:destroy()
end
end
return DialogueService

Dialogue.CanBegin gates every begin() with { Session, Dialogue } and is fail-open: a handler that errors is skipped. begin() is called by trusted server code, so there is no client input to fail closed against, and a broken custom validator should not block every conversation in the game. The intent chains are ordinary fail-closed chains, because they run against client bytes.

Hook point Context Mode Notes
Dialogue.CanBegin { Session, Dialogue } fail-open Any false refuses begin() with Vetoed.
Intent.DLG_Choose { Session, Args = { index } } fail-closed Kit validator at priority 50 (see above).
Intent.DLG_Advance { Session, Args = {} } fail-closed Kit validator at priority 50 (see above).
Field Type Default Description
Dialogues { [string]: Dialogue } required Dialogue id to definition. Validated in full at attach.
Intents boolean? true Define DLG_Sync, DLG_Choose and DLG_Advance. With false there is no client channel and no sync; drive the tree from server code.
Field Type Description
Start string The node begin() enters first. Must name a node.
Nodes { [string]: Node } The tree.
Field Type Default Description
Line string required Text key for the line.
Speaker string? nil Text key for the speaker’s name.
Next string? nil Node advance() moves to when there are no visible choices. Must name a node.
Choices { Choice }? nil Choices, filtered per player by Where.
OnEnter ((session) -> ())? nil Runs under pcall when the node is entered, before the sync. May yield (the dialogue is busy meanwhile). An error only warns.
Field Type Default Description
Text string required Text key for the option.
Next string? nil Node to enter after the pick. Omit to end the dialogue. Must name a node.
Where ((session) -> boolean)? nil Visibility gate, run on node entry and again at the pick. May yield.
Run ((session) -> ())? nil Server-side consequence, run under pcall on pick. May yield. An error only warns.
Member Description
DialogueKit.attach(kernel, options) -> kit Validates every dialogue, defines the Dialogue.CanBegin hook point, registers the leave cleanup and wires the intents unless Intents = false.
kit:begin(session, dialogueId) -> (boolean, string?) Starts a dialogue at its Start node. Reasons: UnknownDialogue, Busy (already in a dialogue), Vetoed.
kit:choose(session, index) -> (boolean, string?) Picks by index into the live node’s visible list. Reasons: NotInDialogue, Busy, BadChoice, Vetoed (the Where re-check failed).
kit:advance(session) -> (boolean, string?) Continues a node without visible choices through its Next, or ends the dialogue. Reasons: NotInDialogue, Busy, ChoicesPending.
kit:stop(session) Ends the session’s dialogue, pushes the empty sync and publishes Dialogue.Ended with "Stopped".
kit:active(session) -> (string?, string?) The session’s dialogue id and node id, or nil, nil.
kit:destroy() Unhooks the intent validators (the handlers go inert), disconnects the session hook and clears every active dialogue without syncing.
Topic Payload When
Dialogue.Started session, dialogueId begin() succeeded, before the start node is entered.
Dialogue.Node session, dialogueId, nodeId Every node entry, after OnEnter and before the sync.
Dialogue.Choice session, dialogueId, nodeId, index A pick passed its Where re-check, before Run.
Dialogue.Ended session, dialogueId, reason The dialogue closed: "Completed" (the tree ran out) or "Stopped" (stop()). Not published when the player leaves.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.