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.
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
| Key | Does |
|---|---|
UI.Children | The 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.