DialogueKit
DialogueKit runs conversation trees entirely on the server. A dialogue is a start node id and a map of nodes; a node is a line, optional choices, and what happens next. The defining design decision is that the client never holds the tree — it renders whatever node the server just pushed and answers with an index, so there is nothing to reverse-engineer and nothing to spoof: a choice hidden by a Where gate isn’t merely hidden from view, it doesn’t exist in the list the client’s index refers to, and the gate is checked again at the moment of picking, not just when the node was drawn.
Mental model
Section titled “Mental model”Node shape and validation
Section titled “Node shape 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 keys, not display strings — send them over the wire as-is and localize on the client through Text’s get/format, the same “ship keys, resolve at render” convention used everywhere else in the framework.
A node with Choices waits for a pick. A node with no Choices (or an empty one) waits for advance() either way — the difference is what advance() does once it’s called: a Next moves to that node, and no Next ends the dialogue with reason "Completed". A choice works the same way on pick: its own Next moves on, and no Next ends the dialogue instead.
attach() validates every dialogue up front: every Start must name a real node, every node’s Next and every choice’s Next must name a real node in the same dialogue. A dangling reference throws immediately, at attach() — a typo’d node id is a boot-time crash, not a silent dead end mid-conversation three weeks after ship.
The visible list, and why indices are dangerous to get wrong
Section titled “The visible list, and why indices are dangerous to get wrong”Every time a node is entered, visibleChoices filters node.Choices through each choice’s Where(session) (missing Where always passes) and stores the surviving array as state.Visible. That filtered array — not node.Choices — is what gets sent to the client and what choice indices mean from that point on:
- The synced
Choiceslist isstate.Visible’sTextfields, in order. DLG_Choose(index)looks upstate.Visible[index]— a position in the filtered list, not the tree’s original list.
So if a node defines three choices and the middle one’s Where fails for this session, state.Visible holds only the 1st and 3rd — the client renders two options, and picking “option 2” resolves to the tree’s third choice. The hidden choice isn’t choice-that-does-nothing; it isn’t in the list at all, and nothing the client sends can reach it by index.
That handles what’s shown. Picking still re-checks the gate a second time, independently: choose() re-invokes Choice.Where(session) on the specific choice resolved by the pick, at the moment of the pick — not by trusting that it must still be true because it passed when the node was synced. State can change in the gap between a node reaching the client and the client’s pick arriving (another event flips session.Data.Reputation, a timed condition lapses); choose() catches that and returns false, "Vetoed" instead of running a consequence the current state would no longer allow. “Hidden choices can’t be picked” is enforced twice, at two different times, for two different reasons: once to decide what’s even offered, and again to make sure it’s still true when it’s used.
Wire and the intent gates
Section titled “Wire and the intent gates”Net:defineState("DLG_Sync", { "Any" })
Net:defineIntent("DLG_Choose", { "NumberU8" }, { RateLimit = 10 })Kernel.Hooks:on("Intent.DLG_Choose", function(context) local State = Active[context.Session] return State ~= nil and State.Visible[context.Args[1]] ~= nilend, 50)
Net:defineIntent("DLG_Advance", {}, { RateLimit = 10 })Kernel.Hooks:on("Intent.DLG_Advance", function(context) local State = Active[context.Session] return State ~= nil and #State.Visible == 0end, 50)local ReplicatedStorage = game:GetService("ReplicatedStorage")local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local Net = NetClient.new()local Choose = Net:intent("DLG_Choose", { "NumberU8" })local Advance = Net:intent("DLG_Advance", {})
Net:onState("DLG_Sync", { "Any" }, function(payload) if not next(payload) then DialogueUi.close() return end DialogueUi.show(Text.get(LocalPlayer, payload.Speaker), Text.get(LocalPlayer, payload.Line)) DialogueUi.setChoices(payload.Choices, function(index) Choose.fire(index) end) if #payload.Choices == 0 then DialogueUi.onContinue(function() Advance.fire() end) endend)DLG_Sync carries the whole node as one adaptively-encoded Any payload — { Dialogue, Node, Line, Speaker?, Choices } — pushed on begin() and 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 gated against the session’s live node, read fresh from Active[session] at the moment the intent fires — not from anything the client claims. This is what stops a stale client from mattering: if the player isn’t in Active at all (never began, or the dialogue already ended), or the index doesn’t resolve in the current node’s Visible list, the intent is rejected before choose()/advance() ever runs. A client that missed a sync (lag, a dropped render) and fires an index that meant something on a previous node can’t have it reinterpreted against whatever node the server has actually moved to — the live node is the only thing indices are ever checked against. DLG_Advance additionally requires the live node to have zero visible choices, so a client can’t skip past a pending choice by advancing instead.
begin() itself is not wired as an intent — there’s no DLG_Begin channel. Starting a conversation is a server-side call you make from your own game code, typically from an InteractionKit prompt’s OnInteract:
kernel:registerService(InteractionKit.service({ Interactions = { TalkToSmith = { Tag = "Smith", ActionText = "Talk", MaxDistance = 8, OnInteract = function(session) Dialogues:begin(session, "Blacksmith") end, }, },}))Leaving mid-conversation
Section titled “Leaving mid-conversation”attach() registers a session binding (kernel:onSession(session:bind(...))) that clears Active[session] directly on leave — the same session-cleanup shape CompanionKit and PartyKit use for their own per-player state, rather than a PlayerRemoving handler bolted on separately. No DLG_Sync close push and no Dialogue.Ended fire on this path (there’s no departed player to sync to, and nothing left to tell); the entry is just gone. If you need to know a dialogue was cut short by a leave specifically, watch Kernel.SessionEnd yourself.
Dialogue.CanBegin gates every begin() call with context { Session, Dialogue }, and is defined fail-open — a throwing handler here lets the conversation start rather than blocking it. That’s consistent with every other “may this proceed” gate in the framework that guards a server-initiated action rather than raw client input (Companion.CanSummon, Craft.CanCraft, Party.CanInvite, Inventory.CanEquip/CanUse are all FailOpen for the same reason): begin() is called from your own trusted code, not from a wire intent, so there’s no exploit surface to fail closed against — only the risk that a broken custom validator would softlock every dialogue in the game if it were fail-closed instead. The wire-facing Intent.DLG_Choose/Intent.DLG_Advance gates, by contrast, are ordinary fail-closed intent chains, because those do run against client-controlled bytes.
| Hook point | Context | Mode | Notes |
|---|---|---|---|
Dialogue.CanBegin |
{ Session, Dialogue } |
fail-open | Vetoes begin(). Return false to refuse; an erroring handler still lets it through. |
Intent.DLG_Choose |
{ Session, Args = { index } } |
fail-closed | Kit gate at priority 50: session must be in a dialogue and index must resolve in the live node’s visible list. |
Intent.DLG_Advance |
{ Session, Args = {} } |
fail-closed | Kit gate at priority 50: session must be in a dialogue with zero visible choices. |
Configuration
Section titled “Configuration”DialogueKit.attach(kernel, options) — Options
Section titled “DialogueKit.attach(kernel, options) — Options”| Field | Type | Default | Description |
|---|---|---|---|
Dialogues |
{ [string]: Dialogue } |
required | Dialogue id → definition. Validated in full at attach(). |
Intents |
boolean? |
true |
Wire DLG_Choose/DLG_Advance (and DLG_Sync). Set false to drive the tree entirely from server code (choose/advance called directly) with no client channel at all. |
Dialogue
Section titled “Dialogue”| Field | Type | Default | Description |
|---|---|---|---|
Start |
string |
required | The node begin() enters first. Must name a real node. |
Nodes |
{ [string]: Node } |
required | The tree. |
| Field | Type | Default | Description |
|---|---|---|---|
Line |
string |
required | Text key for the line, localized client-side. |
Speaker |
string? |
— | Text key for the speaker name. |
Next |
string? |
— | Node advance() moves to when this node has no choices. Must name a real node. |
Choices |
{ Choice }? |
— | Presence of this field means the player picks instead of advancing. |
OnEnter |
((session: any) -> ())? |
— | Runs synchronously (under pcall) the instant the node is entered, before the sync push. A throwing OnEnter only warns. |
Choice
Section titled “Choice”| Field | Type | Default | Description |
|---|---|---|---|
Text |
string |
required | Text key for the option label. |
Next |
string? |
— | Node to enter after this choice resolves. Omit to end the dialogue on pick. Must name a real node. |
Where |
((session: any) -> boolean)? |
— | Per-player visibility gate, checked when the node is entered (decides what’s synced) and again when the choice is picked (decides whether it still runs). |
Run |
((session: any) -> ())? |
— | Server-side consequence, runs under pcall on pick. A throwing Run only warns — it doesn’t stop Next from being entered. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
DialogueKit.attach(kernel, options: Options) |
Validates every dialogue, wires the leave-cleanup session binding, and wires the intents unless Intents = false. Returns the kit instance. |
instance:begin(session, dialogueId: string) -> (boolean, string?) |
Starts a dialogue at its Start node. Reasons on failure: UnknownDialogue, Busy (already in one), Vetoed (Dialogue.CanBegin refused). |
instance:choose(session, index: number) -> (boolean, string?) |
Picks by index into the live node’s visible list. Reasons: NotInDialogue, BadChoice (no such visible index), Vetoed (the choice’s Where failed on re-check). |
instance:advance(session) -> (boolean, string?) |
Continues a choiceless node via its Next, or ends the dialogue if it has none. Reasons: NotInDialogue, ChoicesPending. |
instance:stop(session) |
Force-ends the session’s active dialogue; publishes Dialogue.Ended with reason "Stopped". |
instance:active(session) -> (dialogueId: string?, nodeId: string?) |
The session’s current dialogue and node, or nil, nil if not in one. |
instance:destroy() |
Clears all active dialogue state. |
Bus topics
Section titled “Bus topics”Published:
| Topic | Args | When |
|---|---|---|
Dialogue.Started |
session, dialogueId |
begin() succeeds, before the start node’s sync. |
Dialogue.Node |
session, dialogueId, nodeId |
Every node entry, including the start node. |
Dialogue.Choice |
session, dialogueId, nodeId, index |
A pick passes its Where re-check, before Run executes. |
Dialogue.Ended |
session, dialogueId, reason |
The dialogue closes. reason is "Completed" (ran off the end of the tree via advance() or a terminal choice) or "Stopped" (stop() was called). Does not fire when a session leaves mid-dialogue. |
Consumed: none.