Dialog
Dialogs are the one thing the kit refuses to draw.
Games differ too much on typography, portraits and text effects for a built-in to be worth using, and a game that already has a dialog system should not end up running two. So you bind your renderer once with Dialog.createDialogs and get back a constructor bound to it.
The contract has three shapes, each with a different audience:
| Shape | Who fills it in |
|---|---|
| DialogSpec | You, when writing a step |
| DialogRequest | The kit, when handing the step to your renderer |
| DialogHandle | Your renderer, so the kit can close the dialog later |
Your renderer never sees an ActionContext. Selected, Advance and GoTo are collapsed into
a single Activate before the request is handed over, so a renderer only ever knows a label and
something to call. That keeps it reusable and untangled from the runtime.
Types
ChoiceStyle
type ChoiceStyle = "Primary" | "Secondary" | "Danger"A hint about how prominent a button should look. The kit does not act on it; it passes it through to your renderer, which decides what the three names mean visually.
DialogChoice
interface DialogChoice {Text: string--
The button label
Advance: boolean?--
Move to the next step after Selected
GoTo: number?--
Jump to a specific step after Selected
}One button in a dialog.
Selected runs first, then either Advance or GoTo. Setting both is a build error, since
they contradict each other.
Choices = {
{ Text = "Let's go", Advance = true },
{ Text = "I know this already", GoTo = 20, Style = "Secondary" },
}
A dialog with no choices at all is valid: it shows text and waits for some other action in the step to end it.
DialogSpec
interface DialogSpec {Lines: {string}--
The text, one entry per line
Speaker: string?--
Who is talking
Portrait: string?--
Image for the speaker
Extra: Extra?--
Your own payload, typed by createDialogs
}What you write in a step, and the argument to Dialog.new.
Lines is always a list, even for a single line, so a renderer never has to handle two shapes.
Dialog.new({
Speaker = "Guide",
Lines = { "Welcome, traveler!", "Let me show you around." },
Choices = { { Text = "Continue", Advance = true } },
Extra = { Template = "Wooden" },
})
ResolvedChoice
interface ResolvedChoice {Text: string--
The button label
Activate: () → ()--
Call this when the player picks the button
}A choice as the renderer sees it: no context, no runtime, just a label and something to call.
Only the first Activate of a dialog does anything. Advancing tears the step down and closes
the dialog anyway, but a second click landing in the same frame would otherwise skip a step, so
the kit closes that gap for you.
DialogRequest
interface DialogRequest {Step: number--
Order of the step asking for this dialog
Lines: {string}--
The text to show
Speaker: string?--
Who is talking
Portrait: string?--
Image for the speaker
Extra: Extra?--
Your payload, exactly as written in the spec
}What your renderer receives.
It is the spec, normalized: Choices is never nil, every Style is filled in, and every
callback is already wired to the running step.
DialogHandle
interface DialogHandle {Close: () → ()--
Tear the dialog down
}The one thing your renderer owes back.
The kit calls Close when the step ends, so a dialog can never outlive the step that opened it.
Return nil instead of a handle if the dialog could not be opened at all.
DialogRenderer
Your function that turns a request into visible UI. Passed once to Dialog.createDialogs.
RendererInfo
interface RendererInfo {MaxChoices: number?--
How many buttons the renderer draws
MaxLines: number?--
How many lines it can walk through
SupportsPortrait: boolean?--
Whether Portrait means anything to it
}What your renderer can actually draw, declared once as the second argument to Dialog.createDialogs.
Specs are checked against it while the tutorial is being built, so a dialog asking for more than the renderer supports fails at boot with a message naming the dialog. Without this, the same mistake would silently drop a button in front of a player at step 7.
Leave a field out to mean "no limit".
-- A template with a Yes and a No button, and no portrait art.
{ MaxChoices = 2, SupportsPortrait = false }
Dialogs
What Dialog.createDialogs hands back: a Dialog constructor bound to your renderer and typed
to your Extra.
Functions
createDialogs
constructorBinds a renderer once, at boot, and hands back a typed Dialog constructor.
Step 1 — describe what your dialog system needs
Almost every dialog system needs something the kit cannot know about: a template name, a text effect, a voice clip. Declare it as a plain type:
type GuideDialog = {
Template: string,
TextEffect: ("Typewriter" | "Glitch" | "FadeIn")?,
}
Step 2 — write the renderer
Annotating request is the important part: that is what fixes Extra to your type.
const Dialog = TutorialKit.createDialogs(function(
request: TutorialKit.DialogRequest<GuideDialog>
): TutorialKit.DialogHandle?
const accept = request.Choices[1]
const session = DialogModule:Open({
Template = if request.Extra then request.Extra.Template else "Default",
NpcName = request.Speaker,
Lines = request.Lines,
AcceptText = if accept then accept.Text else nil,
})
if not session then
return nil
end
if accept then
session.Accept:Connect(accept.Activate)
end
return { Close = function() session:Destroy() end }
end, { MaxChoices = 2 })
Step 3 — write steps against it
Dialog.new({
Speaker = "Guide",
Lines = { "Welcome, traveler!" },
Choices = { { Text = "Continue", Advance = true } },
Extra = { Template = "Wooden", TextEffect = "Typewriter" },
})
TextEffect = "Typewritter" is now a type error where you wrote the step, not an effect your
renderer quietly ignores.
Why a factory and not a global registry
A registry would be one mutable slot shared by every caller, so Extra would have to be any
and all of the typing above would disappear. Binding at construction is what makes the generic
possible.
Errors
| Type | Description |
|---|---|
| "createDialogs needs a renderer function" | renderer was not a function |
new
Declares a dialog action.
This is not the module-level Dialog you require; it is the one Dialog.createDialogs
handed you, already bound to your renderer and typed to your Extra.
Validation happens here, at build time, so the errors above surface at boot with the offending line quoted rather than mid-session.
Errors
| Type | Description |
|---|---|
| "dialog needs at least one line" | Lines was empty |
| "dialog choice sets both Advance and GoTo" | They contradict each other |
| "dialog declares N choices but the renderer draws at most M" | Exceeds RendererInfo |