Skip to main content

Custom actions and UI

Everything the built-in actions cannot express goes through Custom.new, and anything you need to draw can go through TutorialKit.UI.

Custom actions

Custom.new is a first-class action, not a workaround. It gets the same context and the same cleanup as everything built in.

Ending a step on a game event

The most common use by far:

Custom.new(function(context)
const connection = QuestService.Completed:Connect(function(questId)
if questId == "FirstDelivery" then
context:Advance()
end
end)

context.Janitor:add(connection, "Disconnect")
end)

A step on a timer

Custom.new(function(context)
const thread = task.delay(10, function()
context:Advance()
end)

context.Janitor:add(thread, true)
end)

Waiting for the player to walk somewhere

Custom.new(function(context)
const connection = RunService.Heartbeat:Connect(function()
const character = Players.LocalPlayer.Character
const root = character and character:FindFirstChild("HumanoidRootPart")
if root and (root.Position - targetPosition).Magnitude < 12 then
context:Advance()
end
end)

context.Janitor:add(connection, "Disconnect")
end)

The one rule

Everything you create goes on context.Janitor.

That Janitor belongs to the step, not to the action, and it is destroyed the moment the step ends. Registering there is why you never write cleanup code, and why a listener from step 2 cannot fire during step 6.

Do not yield in the action body

run is called synchronously while the step opens, and the other actions of that step have not all started yet. Start a thread instead, and put the thread on the Janitor so leaving the step cancels it.

Building UI without a framework

The kit does not draw dialogs, but it ships the thing you need to write one: a small declarative builder with none of the reactivity a real framework brings, so it never competes with the Fusion, React or Vide your game may already use.

The shape

const UI = TutorialKit.UI
const ui = UI.scoped(context.Janitor)

const card: Frame = ui("Frame", {
Name = "TutorialCard",
Size = UDim2.fromOffset(420, 160),
Position = UDim2.fromScale(0.5, 0.85),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundColor3 = Color3.fromRGB(18, 18, 22),

[UI.Children] = {
ui("UICorner", { CornerRadius = UDim.new(0, 10) }),
ui("UIPadding", { PaddingLeft = UDim.new(0, 16), PaddingTop = UDim.new(0, 16) }),

ui("TextLabel", {
Text = "Welcome, traveler!",
Size = UDim2.new(1, 0, 0, 40),
BackgroundTransparency = 1,
TextColor3 = Color3.new(1, 1, 1),
}),

ui("TextButton", {
Text = "Continue",
Size = UDim2.fromOffset(120, 36),
Position = UDim2.fromScale(1, 1),
AnchorPoint = Vector2.new(1, 1),
[UI.Event("Activated")] = function()
print("clicked")
end,
}),
},

Parent = screenGui,
})

Three things it does that Instance.new does not

Everything goes on the Janitor. Instances and connections alike. A renderer written this way cannot outlive its step, and you write no teardown.

Parent is applied last, after properties and children. Parenting first makes the engine recompute layout on every following assignment. Fusion skips Parent in its property loop for exactly this reason.

A wrong property errors at construction, naming the class and the property, instead of failing silently.

[TutorialKit.UI] TextLabel has no property "TextColour"

The three special keys

KeyDoes
UI.ChildrenThe list of children, parented before the frame itself is
UI.Event("Activated")Connects a handler, registered on the Janitor
UI.Changed("AbsoluteSize")Connects to GetPropertyChangedSignal

On the return type

ui(...) returns any, so annotate the variable:

const card: Frame = ui("Frame", { ... })

Everything downstream of that annotation is typed. Typing the property table per class needs generated types, which is why Fusion's New also gives up and returns a plain Instance. The property check above is what catches mistakes instead.

A dialog renderer in thirty lines

Putting both halves together, here is a working renderer with no dependency beyond the kit:

const Janitor = require(ReplicatedStorage.TutorialKit.Packages.janitor)
const UI = TutorialKit.UI

const Dialog = TutorialKit.createDialogs(function(
request: TutorialKit.DialogRequest<{}>
): TutorialKit.DialogHandle?
const janitor = Janitor.new()
const ui = UI.scoped(janitor)

const buttons = {}
for _, choice in request.Choices do
table.insert(buttons, ui("TextButton", {
Text = choice.Text,
Size = UDim2.fromOffset(140, 34),
[UI.Event("Activated")] = choice.Activate,
}))
end

ui("Frame", {
Size = UDim2.fromOffset(460, 170),
Position = UDim2.fromScale(0.5, 0.82),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundColor3 = Color3.fromRGB(18, 18, 22),

[UI.Children] = {
ui("UICorner", { CornerRadius = UDim.new(0, 10) }),
ui("TextLabel", {
Text = table.concat(request.Lines, "\n"),
Size = UDim2.new(1, -24, 1, -60),
Position = UDim2.fromOffset(12, 12),
BackgroundTransparency = 1,
TextColor3 = Color3.new(1, 1, 1),
TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Left,
}),
ui("Frame", {
Size = UDim2.new(1, -24, 0, 34),
Position = UDim2.new(0, 12, 1, -46),
BackgroundTransparency = 1,
[UI.Children] = buttons,
}),
},

Parent = playerGui.TutorialDialogs,
})

return { Close = function() janitor:destroy() end }
end)

Note the local Janitor. A renderer is not inside a step, so it makes its own and destroys it from Close.