Skip to main content

Your first real tutorial

We are going to build a four-step onboarding flow, one step at a time, and explain every decision. By the end you will have used all three guidance tools and both ways of ending a step.

The flow we want:

  1. Fly the camera over the spawn area so the player sees where they are.
  2. Point them at the quest giver across the map.
  3. Highlight the shop button once they get back.
  4. Say goodbye.

Step 0 — the skeleton

Every tutorial has the same three lines around it.

const TutorialKit = require(ReplicatedStorage.TutorialKit)
const Tutorial, Step = TutorialKit.Tutorial, TutorialKit.Step

const tutorial = Tutorial.build({
-- steps go here
})

tutorial:Start()

Tutorial.build needs at least one step, so this does not run yet. Let us fill it in.

Step 1 — a camera flight

const Cinematic = TutorialKit.Cinematic

Step.new(1, {
Cinematic.focus(workspace.Landmarks.SpawnStatue, {
Hold = 3,
Distance = 45,
Height = 15,
Advance = true,
}),
})

Reading it in order:

  • Step.new(1, ...) — the 1 is the order, not the position in the list. You could write this step last in the file and it would still run first.
  • Cinematic.focus takes the part to look at.
  • Hold = 3 lingers there for three seconds.
  • Distance and Height frame the shot: 45 studs back, 15 studs up.
  • Advance = true moves to step 2 when the flight ends.

That last one matters more than it looks. Camera state is captured when the flight starts and restored when it ends whether it finished or was interrupted, and Advance fires in both cases. A player who somehow leaves mid-flight still lands on step 2 rather than getting stuck.

Step 2 — pointing across the map

const PointWorld = TutorialKit.PointWorld
const Custom = TutorialKit.Custom

Step.new(2, {
PointWorld.to(workspace.NPCs.QuestGiver.HumanoidRootPart, {
Outline = workspace.NPCs.QuestGiver,
}),

Custom.new(function(context)
const connection = QuestService.Started:Connect(function()
context:Advance()
end)

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

Two actions in one step. They start at the same moment and neither blocks the other: the beam appears immediately, and the listener is armed immediately.

Notice the split of responsibilities. PointWorld shows and never ends the step. Something has to decide when the step is over, and here that is the Custom action listening for the quest to start.

The line that people forget is this one:

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

Without it the listener outlives the step. The player finishes the tutorial, starts a second quest an hour later, and the handler fires again on a tutorial that no longer exists. Registering it on the step's Janitor means leaving the step disconnects it, and you never write cleanup code yourself.

tip

Anything you create inside a Custom action, connections, instances, threads, belongs on context.Janitor. That is the whole contract.

Step 3 — highlighting a button, with a condition

const FocusUI = TutorialKit.FocusUI

Step.new(3, {
FocusUI.to(shopButton, {
Padding = Vector2.new(8, 8),
Clicked = function(context)
if PlayerData:Get("Coins") >= 100 then
context:Advance()
else
NotificationModule:ShowError("Come back with 100 coins")
end
end,
}),
})

This is the long form of Advance = true. Use it when ending the step depends on something the kit cannot know.

Clicked receives the same context every action gets, so it can advance, jump elsewhere with context:GoTo(7), or end the whole tutorial with context:Complete().

Padding gives the highlight 8 pixels of breathing room on each axis. Without it the hole hugs the button exactly, which looks tight on buttons that already have their own stroke.

Step 4 — the goodbye

Step.new(4, {
Dialog.new({
Speaker = "Guide",
Lines = { "That's everything.", "Good luck out there!" },
Choices = { { Text = "Thanks!", Advance = true } },
}),
})

Dialog is the one action you cannot use straight out of the box, because the kit does not draw dialogs. See Dialogs for the ten lines that wire it up.

Advancing past the last step completes the tutorial. That fires Completed and clears everything.

Putting it together

const tutorial = Tutorial.build({
Step.new(1, {
Cinematic.focus(workspace.Landmarks.SpawnStatue, {
Hold = 3, Distance = 45, Height = 15, Advance = true,
}),
}),

Step.new(2, {
PointWorld.to(workspace.NPCs.QuestGiver.HumanoidRootPart, {
Outline = workspace.NPCs.QuestGiver,
}),
Custom.new(function(context)
context.Janitor:add(QuestService.Started:Connect(function()
context:Advance()
end), "Disconnect")
end),
}),

Step.new(3, {
FocusUI.to(shopButton, { Padding = Vector2.new(8, 8), Advance = true }),
}),

Step.new(4, {
Dialog.new({
Speaker = "Guide",
Lines = { "That's everything.", "Good luck out there!" },
Choices = { { Text = "Thanks!", Advance = true } },
}),
}),
})

tutorial.Completed:Connect(function()
print("onboarding done")
end)

tutorial:Start()

Two habits worth picking up now

Number your steps in tens

Step.new(10, ...)
Step.new(20, ...)
Step.new(30, ...)

Inserting a step between the first two later costs nothing. With 1, 2, 3 you renumber everything below it, and any GoTo pointing into that range silently means something else.

Style once, not per step

Tutorial.build(steps, {
Spotlight = {
DimTransparency = 0.55,
StrokeColor = Color3.fromRGB(255, 214, 102),
},
Camera = { FocusDuration = 2 },
})

The spotlight, pointer and camera are shared by every step, so their look is configured once on the tutorial rather than repeated on each action.

Next