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.
Mental model
Section titled “Mental model”Nodes and validation
Section titled “Nodes and validation”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.
The visible list
Section titled “The visible list”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
Choicesare the visible choices’Textkeys, in order. DLG_Choose(index)andchoose(session, index)look upvisible[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.
Busy while handlers yield
Section titled “Busy while handlers yield”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 publishingDialogue.Nodeand pushing the sync. - A pick marks it busy, re-checks
Where, publishesDialogue.Choice, runsRun, then clears the flag. - While busy,
chooseandadvancereturnfalse, "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.
Wire and the intent gates
Section titled “Wire and the intent gates”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.
Leaving mid-conversation
Section titled “Leaving mid-conversation”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:
--!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 }, },}--!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 idlocal 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() endend
return DialogueService--!strict-- DialogueController: renders the node the server sends and answers with an index.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local DialogueController = { Name = "DialogueController", Node = nil :: any, -- the payload being shown, nil when closed}
function DialogueController.init(self: any, kernel: any) self.Kernel = kernel self.KitNet = NetClient.new() self.Choose = self.KitNet:intent("DLG_Choose", { "NumberU8" }) self.Advance = self.KitNet:intent("DLG_Advance", {})end
function DialogueController.start(self: any) self.Connection = self.KitNet:onState("DLG_Sync", { "Any" }, function(payload) self.Node = if next(payload) then payload else nil -- {} means closed self.Kernel.Bus:publish("Hud.Dialogue", self.Node) -- the screen localizes Line, Speaker and Choices end)end
-- index is a position in Node.Choices, the visible listfunction DialogueController.pick(self: any, index: number) self.Choose.fire(index)end
function DialogueController.continue(self: any) if self.Node and #self.Node.Choices == 0 then self.Advance.fire() endend
function DialogueController.stop(self: any) if self.Connection then self.Connection:Disconnect() endend
return DialogueControllerDialogue.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). |
Configuration
Section titled “Configuration”DialogueKit.attach(kernel, options)
Section titled “DialogueKit.attach(kernel, options)”| 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. |
Dialogue
Section titled “Dialogue”| 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. |
Choice
Section titled “Choice”| 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. |
API reference
Section titled “API reference”| 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. |
Bus topics
Section titled “Bus topics”| 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. |