Skip to main content

Dialog

This item only works when running on the client. Client

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 {
Textstring--

The button label

StyleChoiceStyle?--

Visual hint. Defaults to "Primary"

Selected((contextActionContext) → ())?--

Runs when this button is picked

Advanceboolean?--

Move to the next step after Selected

GoTonumber?--

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

Speakerstring?--

Who is talking

Portraitstring?--

Image for the speaker

Choices{DialogChoice}?--

Buttons. Defaults to none

ExtraExtra?--

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 {
Textstring--

The button label

StyleChoiceStyle--

Always present here, defaulted to "Primary"

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 {
Stepnumber--

Order of the step asking for this dialog

Lines{string}--

The text to show

Speakerstring?--

Who is talking

Portraitstring?--

Image for the speaker

Choices{ResolvedChoice}--

Buttons, already bound. Empty when the spec had none

ExtraExtra?--

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

type DialogRenderer = (requestDialogRequest<Extra>) → DialogHandle?

Your function that turns a request into visible UI. Passed once to Dialog.createDialogs.

RendererInfo

interface RendererInfo {
MaxChoicesnumber?--

How many buttons the renderer draws

MaxLinesnumber?--

How many lines it can walk through

SupportsPortraitboolean?--

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

interface Dialogs {
new(specDialogSpec<Extra>) → Action--

Declares one dialog action

}

What Dialog.createDialogs hands back: a Dialog constructor bound to your renderer and typed to your Extra.

Functions

createDialogs

constructor
Dialog.createDialogs(
rendererDialogRenderer<Extra>,--

Draws a request with your own UI

infoRendererInfo?--

What your renderer can draw. Optional, but recommended

) → Dialogs<Extra>--

A Dialog constructor bound to that renderer

Binds 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

TypeDescription
"createDialogs needs a renderer function"renderer was not a function

new

Dialog.new(
specDialogSpec<Extra>--

The dialog to show

) → Action--

Put it in a Step

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

TypeDescription
"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
Show raw api
{
    "functions": [
        {
            "name": "createDialogs",
            "desc": "Binds a renderer once, at boot, and hands back a typed `Dialog` constructor.\n\n### Step 1 — describe what your dialog system needs\n\nAlmost every dialog system needs something the kit cannot know about: a template name, a text\neffect, a voice clip. Declare it as a plain type:\n\n```lua\ntype GuideDialog = {\n\tTemplate: string,\n\tTextEffect: (\"Typewriter\" | \"Glitch\" | \"FadeIn\")?,\n}\n```\n\n### Step 2 — write the renderer\n\nAnnotating `request` is the important part: that is what fixes `Extra` to your type.\n\n```lua\nconst Dialog = TutorialKit.createDialogs(function(\n\trequest: TutorialKit.DialogRequest<GuideDialog>\n): TutorialKit.DialogHandle?\n\tconst accept = request.Choices[1]\n\n\tconst session = DialogModule:Open({\n\t\tTemplate = if request.Extra then request.Extra.Template else \"Default\",\n\t\tNpcName = request.Speaker,\n\t\tLines = request.Lines,\n\t\tAcceptText = if accept then accept.Text else nil,\n\t})\n\tif not session then\n\t\treturn nil\n\tend\n\n\tif accept then\n\t\tsession.Accept:Connect(accept.Activate)\n\tend\n\n\treturn { Close = function() session:Destroy() end }\nend, { MaxChoices = 2 })\n```\n\n### Step 3 — write steps against it\n\n```lua\nDialog.new({\n\tSpeaker = \"Guide\",\n\tLines = { \"Welcome, traveler!\" },\n\tChoices = { { Text = \"Continue\", Advance = true } },\n\tExtra = { Template = \"Wooden\", TextEffect = \"Typewriter\" },\n})\n```\n\n`TextEffect = \"Typewritter\"` is now a type error where you wrote the step, not an effect your\nrenderer quietly ignores.\n\n:::info Why a factory and not a global registry\nA registry would be one mutable slot shared by every caller, so `Extra` would have to be `any`\nand all of the typing above would disappear. Binding at construction is what makes the generic\npossible.\n:::",
            "params": [
                {
                    "name": "renderer",
                    "desc": "Draws a request with your own UI",
                    "lua_type": "DialogRenderer<Extra>"
                },
                {
                    "name": "info",
                    "desc": "What your renderer can draw. Optional, but recommended",
                    "lua_type": "RendererInfo?"
                }
            ],
            "returns": [
                {
                    "desc": "A Dialog constructor bound to that renderer",
                    "lua_type": "Dialogs<Extra>"
                }
            ],
            "function_type": "static",
            "tags": [
                "constructor"
            ],
            "errors": [
                {
                    "lua_type": "\"createDialogs needs a renderer function\"",
                    "desc": "renderer was not a function"
                }
            ],
            "source": {
                "line": 132,
                "path": "src/Dialog/Factory.luau"
            }
        },
        {
            "name": "new",
            "desc": "Declares a dialog action.\n\nThis is not the module-level `Dialog` you require; it is the one [Dialog.createDialogs]\nhanded you, already bound to your renderer and typed to your `Extra`.\n\nValidation happens here, at build time, so the errors above surface at boot with the\noffending line quoted rather than mid-session.\n\t",
            "params": [
                {
                    "name": "spec",
                    "desc": "The dialog to show",
                    "lua_type": "DialogSpec<Extra>"
                }
            ],
            "returns": [
                {
                    "desc": "Put it in a Step",
                    "lua_type": "Action"
                }
            ],
            "function_type": "static",
            "errors": [
                {
                    "lua_type": "\"dialog needs at least one line\"",
                    "desc": "Lines was empty"
                },
                {
                    "lua_type": "\"dialog choice sets both Advance and GoTo\"",
                    "desc": "They contradict each other"
                },
                {
                    "lua_type": "\"dialog declares N choices but the renderer draws at most M\"",
                    "desc": "Exceeds RendererInfo"
                }
            ],
            "source": {
                "line": 152,
                "path": "src/Dialog/Factory.luau"
            }
        }
    ],
    "properties": [],
    "types": [
        {
            "name": "ChoiceStyle",
            "desc": "A hint about how prominent a button should look. The kit does not act on it; it passes it\nthrough to your renderer, which decides what the three names mean visually.",
            "lua_type": "\"Primary\" | \"Secondary\" | \"Danger\"",
            "source": {
                "line": 36,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "DialogChoice",
            "desc": "One button in a dialog.\n\n`Selected` runs first, then either `Advance` or `GoTo`. Setting both is a build error, since\nthey contradict each other.\n\n```lua\nChoices = {\n\t{ Text = \"Let's go\", Advance = true },\n\t{ Text = \"I know this already\", GoTo = 20, Style = \"Secondary\" },\n}\n```\n\nA dialog with no choices at all is valid: it shows text and waits for some other action in the\nstep to end it.",
            "fields": [
                {
                    "name": "Text",
                    "lua_type": "string",
                    "desc": "The button label"
                },
                {
                    "name": "Style",
                    "lua_type": "ChoiceStyle?",
                    "desc": "Visual hint. Defaults to \"Primary\""
                },
                {
                    "name": "Selected",
                    "lua_type": "((context: ActionContext) -> ())?",
                    "desc": "Runs when this button is picked"
                },
                {
                    "name": "Advance",
                    "lua_type": "boolean?",
                    "desc": "Move to the next step after Selected"
                },
                {
                    "name": "GoTo",
                    "lua_type": "number?",
                    "desc": "Jump to a specific step after Selected"
                }
            ],
            "source": {
                "line": 62,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "DialogSpec",
            "desc": "What you write in a step, and the argument to `Dialog.new`.\n\n`Lines` is always a list, even for a single line, so a renderer never has to handle two shapes.\n\n```lua\nDialog.new({\n\tSpeaker = \"Guide\",\n\tLines = { \"Welcome, traveler!\", \"Let me show you around.\" },\n\tChoices = { { Text = \"Continue\", Advance = true } },\n\tExtra = { Template = \"Wooden\" },\n})\n```",
            "fields": [
                {
                    "name": "Lines",
                    "lua_type": "{string}",
                    "desc": "The text, one entry per line"
                },
                {
                    "name": "Speaker",
                    "lua_type": "string?",
                    "desc": "Who is talking"
                },
                {
                    "name": "Portrait",
                    "lua_type": "string?",
                    "desc": "Image for the speaker"
                },
                {
                    "name": "Choices",
                    "lua_type": "{DialogChoice}?",
                    "desc": "Buttons. Defaults to none"
                },
                {
                    "name": "Extra",
                    "lua_type": "Extra?",
                    "desc": "Your own payload, typed by createDialogs"
                }
            ],
            "source": {
                "line": 92,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "ResolvedChoice",
            "desc": "A choice as the renderer sees it: no context, no runtime, just a label and something to call.\n\nOnly the first `Activate` of a dialog does anything. Advancing tears the step down and closes\nthe dialog anyway, but a second click landing in the same frame would otherwise skip a step, so\nthe kit closes that gap for you.",
            "fields": [
                {
                    "name": "Text",
                    "lua_type": "string",
                    "desc": "The button label"
                },
                {
                    "name": "Style",
                    "lua_type": "ChoiceStyle",
                    "desc": "Always present here, defaulted to \"Primary\""
                },
                {
                    "name": "Activate",
                    "lua_type": "() -> ()",
                    "desc": "Call this when the player picks the button"
                }
            ],
            "source": {
                "line": 113,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "DialogRequest",
            "desc": "What your renderer receives.\n\nIt is the spec, normalized: `Choices` is never nil, every `Style` is filled in, and every\ncallback is already wired to the running step.",
            "fields": [
                {
                    "name": "Step",
                    "lua_type": "number",
                    "desc": "Order of the step asking for this dialog"
                },
                {
                    "name": "Lines",
                    "lua_type": "{string}",
                    "desc": "The text to show"
                },
                {
                    "name": "Speaker",
                    "lua_type": "string?",
                    "desc": "Who is talking"
                },
                {
                    "name": "Portrait",
                    "lua_type": "string?",
                    "desc": "Image for the speaker"
                },
                {
                    "name": "Choices",
                    "lua_type": "{ResolvedChoice}",
                    "desc": "Buttons, already bound. Empty when the spec had none"
                },
                {
                    "name": "Extra",
                    "lua_type": "Extra?",
                    "desc": "Your payload, exactly as written in the spec"
                }
            ],
            "source": {
                "line": 134,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "DialogHandle",
            "desc": "The one thing your renderer owes back.\n\nThe kit calls `Close` when the step ends, so a dialog can never outlive the step that opened it.\nReturn nil instead of a handle if the dialog could not be opened at all.",
            "fields": [
                {
                    "name": "Close",
                    "lua_type": "() -> ()",
                    "desc": "Tear the dialog down"
                }
            ],
            "source": {
                "line": 153,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "DialogRenderer",
            "desc": "Your function that turns a request into visible UI. Passed once to [Dialog.createDialogs].",
            "lua_type": "(request: DialogRequest<Extra>) -> DialogHandle?",
            "source": {
                "line": 163,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "RendererInfo",
            "desc": "What your renderer can actually draw, declared once as the second argument to\n[Dialog.createDialogs].\n\nSpecs are checked against it while the tutorial is being built, so a dialog asking for more than\nthe renderer supports fails at boot with a message naming the dialog. Without this, the same\nmistake would silently drop a button in front of a player at step 7.\n\nLeave a field out to mean \"no limit\".\n\n```lua\n-- A template with a Yes and a No button, and no portrait art.\n{ MaxChoices = 2, SupportsPortrait = false }\n```",
            "fields": [
                {
                    "name": "MaxChoices",
                    "lua_type": "number?",
                    "desc": "How many buttons the renderer draws"
                },
                {
                    "name": "MaxLines",
                    "lua_type": "number?",
                    "desc": "How many lines it can walk through"
                },
                {
                    "name": "SupportsPortrait",
                    "lua_type": "boolean?",
                    "desc": "Whether Portrait means anything to it"
                }
            ],
            "source": {
                "line": 186,
                "path": "src/Dialog/Types.luau"
            }
        },
        {
            "name": "Dialogs",
            "desc": "What [Dialog.createDialogs] hands back: a `Dialog` constructor bound to your renderer and typed\nto your `Extra`.",
            "fields": [
                {
                    "name": "new",
                    "lua_type": "(spec: DialogSpec<Extra>) -> Action",
                    "desc": "Declares one dialog action"
                }
            ],
            "source": {
                "line": 200,
                "path": "src/Dialog/Types.luau"
            }
        }
    ],
    "name": "Dialog",
    "desc": "Dialogs are the one thing the kit refuses to draw.\n\nGames differ too much on typography, portraits and text effects for a built-in to be worth\nusing, and a game that already has a dialog system should not end up running two. So you bind\nyour renderer once with [Dialog.createDialogs] and get back a constructor bound to it.\n\nThe contract has three shapes, each with a different audience:\n\n| Shape | Who fills it in |\n| --- | --- |\n| [DialogSpec] | You, when writing a step |\n| [DialogRequest] | The kit, when handing the step to your renderer |\n| [DialogHandle] | Your renderer, so the kit can close the dialog later |\n\nYour renderer never sees an [ActionContext]. `Selected`, `Advance` and `GoTo` are collapsed into\na single `Activate` before the request is handed over, so a renderer only ever knows a label and\nsomething to call. That keeps it reusable and untangled from the runtime.",
    "realm": [
        "Client"
    ],
    "source": {
        "line": 24,
        "path": "src/Dialog/Types.luau"
    }
}