Dialogs
Dialogs are the one thing the kit refuses to draw. This page explains why, then wires one up in four steps.
Why there is no built-in dialog
Two reasons, and both are about your game rather than about the kit.
Games differ enormously on what a dialog is: portraits or not, typewriter or instant, one line or a walk-through with a Skip button, a speech bubble in the world or a panel at the bottom. A built-in would satisfy none of them and would look foreign next to the rest of your UI.
And if your game already has a dialog system, a built-in means running two. The kit would have to ship a UI framework to draw it, and the community consensus is not to mix frameworks in one project. A game on React-Lua should not end up loading Fusion because it wanted onboarding.
So the kit gives you the contract and gets out of the way.
The four steps
Step 1 — decide what your dialog system needs
The kit knows about text, a speaker, a portrait and buttons. Everything beyond that is yours: a template name, a text effect, a voice clip, a sound id. Declare it as a plain type.
type GuideDialog = {
Template: string,
TextEffect: ("Typewriter" | "Glitch" | "FadeIn")?,
}
This type is called Extra throughout the API. If your dialog system needs nothing extra, you can use {} and ignore the field.
Step 2 — write the renderer
A renderer is one function. It receives a request and returns a handle.
const Dialog = TutorialKit.createDialogs(function(
request: TutorialKit.DialogRequest<GuideDialog>
): TutorialKit.DialogHandle?
const extra = request.Extra
const accept = request.Choices[1]
const session = DialogModule:Open({
Template = if extra then extra.Template else "Default",
NpcName = request.Speaker,
Lines = request.Lines,
AcceptText = if accept then accept.Text else nil,
TextEffect = if extra then extra.TextEffect 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 })
Three things to notice.
Annotating request is not optional. That annotation is what fixes Extra to GuideDialog. Leave it off and the type is never bound, which is exactly the mistake that makes the whole feature look pointless.
The renderer never sees the tutorial. There is no context, no step object, no way to advance. Each button arrives as a Text and an Activate, and calling Activate is all the renderer ever does. That is what keeps a renderer reusable and impossible to misuse.
Close is your teardown. The kit calls it when the step ends, so a dialog can never outlive its step. Return nil instead of a handle if the dialog could not open at all.
Step 3 — declare what you can draw
That { MaxChoices = 2 } at the end is the second argument, and it is worth understanding.
{
MaxChoices = 2, -- the template has a Yes and a No, nothing else
MaxLines = 4, -- it can walk through four lines
SupportsPortrait = false, -- it draws no portrait
}
Specs are checked against this while the tutorial is being built. A step that asks for three buttons from a two-button renderer fails at boot, with the offending line quoted:
[TutorialKit] dialog "Which path will you take?" declares 3 choices but the renderer draws at most 2
Without it, that same mistake would silently drop the third button in front of a player at step 7, in a build you already shipped. Every field is optional and leaving one out means "no limit", but filling them in is the cheapest bug prevention in the library.
Step 4 — write steps against it
Dialog.new({
Speaker = "Guide",
Lines = { "Welcome, traveler!", "Let me show you around." },
Choices = { { Text = "Continue", Advance = true } },
Extra = { Template = "Wooden", TextEffect = "Typewriter" },
})
TextEffect = "Typewritter" is now a type error, where you wrote the step. Not a warning in the output, not an effect your renderer quietly ignores.
Choices in depth
A choice is a button. Three fields decide what happens when it is pressed, and they run in this order:
{
Text = "Take the shortcut",
Style = "Secondary",
Selected = function(context) -- 1. your callback
Analytics:Log("skipped_intro")
end,
GoTo = 40, -- 2. then jump (or Advance = true to go next)
}
Setting both Advance and GoTo is a build error, because they contradict each other.
Branching
GoTo is how a tutorial forks:
Dialog.new({
Lines = { "Have you played a game like this before?" },
Choices = {
{ Text = "First time", Advance = true },
{ Text = "I know the basics", GoTo = 40, Style = "Secondary" },
},
})
No choices at all
Perfectly valid. The dialog shows and waits for some other action in the step to end it:
Step.new(20, {
Dialog.new({ Lines = { "Press the glowing button." } }),
FocusUI.to(shopButton, { Advance = true }),
})
Double clicks
Only the first choice picked in 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. You do not need a debounce in your renderer.
What the renderer receives
Your function is handed a normalized version of the spec, never the spec itself:
| Field | Notes |
|---|---|
Step | Order number of the step asking |
Lines | Always a list, even for one line |
Speaker | May be nil |
Portrait | May be nil |
Choices | Always a list, empty when the spec had none |
Extra | Exactly what the spec wrote |
Choices being never-nil and every Style being filled in is deliberate: a renderer should not need a single nil check on the shape of the request.
A full reference renderer
The kit ships a reference renderer with templates, text effects and multi-line walk-through in examples/DialogModuleRenderer.luau.
If you have no dialog system yet
You still need a renderer, but you can build one with TutorialKit.UI in about thirty lines and no new dependency.