A tutorial is data
Ordered steps holding actions that start together. Entering a step gives its actions a shared Janitor, and leaving it destroys that Janitor, so no step ever cleans up after the one before it.
Draws with your UI, not ours
The kit never draws a dialog. You bind your own renderer once and get back a Dialog constructor typed to whatever your dialog system needs, checked while the tutorial is built.
Self-contained
Ships its own janitor and ezvisualz under Packages, so it never touches the host game's dependency tree, and carries its own RemoteEvent so it needs no slot in your network layer.
Self-contained onboarding module for Roblox. A tutorial is declared as data: ordered steps, each holding actions that start together.
const tutorial = Tutorial.build({
Step.new(1, {
Dialog.new({
Speaker = "Guide",
Lines = { "Welcome!" },
Choices = { { Text = "Continue" } },
}),
FocusUI.to(shopButton, {
Clicked = function()
print("player opened the shop")
end,
Advance = true,
}),
}),
Step.new(2, {
PointWorld.to(fountainPart),
Cinematic.focus(fountainPart, { Hold = 3, Advance = true }),
}),
})
tutorial:Start()
The kit ships its own janitor and ezvisualz under Packages, mounted as a child of the module. It never touches the host game's dependency tree.
The facade is client only. Progress can be replicated so the server can save it — see Server events.
Why it exists
Every onboarding flow rebuilds the same pieces, and each one has a trap that surfaces late:
- Dimming the screen around a button. The obvious build is four semi-transparent frames around a hole. Their alpha stacks where they touch, and that renders as a seam line along the hole. TutorialKit puts the panels inside a
CanvasGroupso the engine flattens them before applying transparency. - Cleaning up between steps. A step that forgets to disconnect something leaks into the next one. Here, leaving a step destroys its Janitor and clears all three guidance tools at once.
- Restoring the camera. A tour interrupted halfway strands the player in a scriptable camera. Camera state is captured when a tour starts and restored whether it finished or was stopped.
Install
Wally (recommended)
Add to your game's wally.toml:
TutorialKit = "kartzrbx/tutorial-kit@0.1.0"
wally install
Map the shim in default.project.json:
"TutorialKit": {
"$path": "Packages/TutorialKit.lua"
}
const TutorialKit = require(ReplicatedStorage.Packages.TutorialKit)
Package page: wally.run/package/kartzrbx/tutorial-kit
Full step-by-step: Getting started on the docs site.
Git submodule
If you prefer vendoring the source:
"TutorialKit": {
"$path": "lib/tutorial-kit/src",
"Packages": { "$path": "lib/tutorial-kit/packages.project.json" }
}
const TutorialKit = require(ReplicatedStorage.TutorialKit)
Working on the kit itself
From a clone of this repository:
rokit install # wally + rojo
rojo serve
wally install inside this repo refreshes the vendored copies under Packages/; the kit runs without it because they are committed.
To preview the documentation site:
npm i -g moonwave
moonwave dev
Publishing a new Wally version
- Bump
versioninwally.toml(semver). - Commit and tag:
git tag v0.1.1 && git push origin v0.1.1. - Publish:
wally loginonce, thenwally publish.
Or push a GitHub Release — the publish-wally workflow publishes automatically when WALLY_AUTH is set in repository secrets (contents of ~/.wally/auth.toml after wally login).
Dialog
The kit does not draw dialogs. 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.
Instead you bind your renderer once and get back a Dialog constructor:
type GuideDialog = { Template: string }
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)
The kit calls Close when the step ends, so a dialog never outlives its step.
Annotating request is what pins Extra, and it is the whole reason this is a factory instead of a registry — see Typing your own fields.
Note what the renderer does not receive: there is no ActionContext, no runtime, no notion of steps. 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. Only the first choice picked counts, which closes the gap where a double click could skip a step.
A full renderer against a real game dialog system is in examples/DialogModuleRenderer.luau.
Typing your own fields
Almost every dialog system needs something the kit cannot know about: a template name, a text effect, a voice clip. That is what the Extra type parameter is for. Declare it once on the renderer and it stays typed at the other end, where the steps are written:
type GuideDialog = {
Template: string,
TextEffect: ("Typewriter" | "Glitch" | "FadeIn")?,
}
const Dialog = TutorialKit.createDialogs(function(request: DialogRequest<GuideDialog>)
-- request.Extra is a GuideDialog here
end, { MaxChoices = 2 })
Dialog.new({
Speaker = "Guide",
Lines = { "Hello, 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 an effect your renderer quietly ignores. A global registry cannot do this: with one mutable slot shared by every caller, Extra would have to be any.
Telling the kit what you can draw
The second argument to createDialogs describes the renderer's limits:
MaxChoices— how many buttons it draws. A template with Yes and No sets2.MaxLines— how many lines it can walk through.SupportsPortrait— whetherPortraitmeans anything.
Specs are checked against this while the tutorial is being built. A dialog asking for three buttons from a two-button renderer fails at boot with the offending line quoted, instead of dropping a button in front of a player at step 7. Leave a field out to mean no limit.
Spec reference
Dialog.new takes Lines (required, always a list), Speaker?, Portrait?, Choices? and Extra?.
Each choice takes Text, Style? ("Primary", "Secondary" or "Danger"), Selected?, and either Advance? or GoTo? — setting both is a build error.
A dialog with no choices is valid: it shows text and waits for some other action in the step to advance.
How a step runs
Actions of a step all start together: the dialog appears while the spotlight highlights the button. Nothing blocks anything.
The step ends when an action asks it to, through the context every action receives:
FocusUI.to(button, {
Clicked = function(context)
if playerHasEnoughCoins() then
context:Advance()
end
end,
})
Advance = true is the shorthand for the common case.
Server events
Progress usually has to be saved, and saving belongs on the server. The kit carries its own RemoteEvent, so you do not have to give it a slot in your network layer.
Set Replicate = true on the tutorial and start the server half at boot:
-- Server
const TutorialServer = require(Packages.TutorialKit.Server)
TutorialServer:Start()
TutorialServer.PlayerStepAdvanced:Connect(function(player, step, previousStep)
DataService:Set(player, "TutorialStep", step)
end)
TutorialServer.TutorialCompleted:Connect(function(player)
DataService:Set(player, "TutorialDone", true)
end)
-- Client
const tutorial = Tutorial.build(steps, { Replicate = true })
tutorial:Start()
Require Server directly, never through the facade: the facade pulls the spotlight, the pointer and ezvisualz, none of which belong on a server.
Direction of each message
The client reports what it did; it never says what should happen next. The server decides, and the client follows.
| Direction | Trigger | Effect |
|---|---|---|
| Client to server | Start, Advance, GoTo | PlayerStepAdvanced(player, step, previousStep) |
| Client to server | Complete, or advancing past the last step | TutorialCompleted(player) |
| Server to client | TutorialServer:SetStep(player, step) | The tutorial jumps to that step |
| Server to client | TutorialServer:Complete(player) | The tutorial ends |
Server-driven transitions are not reported back, so a SetStep does not bounce to the server as a fresh PlayerStepAdvanced.
Everything arriving from a remote is parsed and dropped when malformed, so neither side ever sees a bad packet.
Resuming a returning player
Players.PlayerAdded:Connect(function(player)
const saved = DataService:Get(player, "TutorialStep")
if saved then
TutorialServer:SetStep(player, saved)
end
end)
Server API
| Member | Notes |
|---|---|
Start() | Creates the bridge and listens. Call once at boot, before any client can report |
Stop() | Disconnects and clears tracked progress |
SetStep(player, step) | Puts a client on a step |
Complete(player) | Ends a client's tutorial |
GetStep(player) | Last step the player reported |
PlayerStepAdvanced | Signal<Player, number, number?> |
TutorialCompleted | Signal<Player> |
Client API
TutorialKit.Client is driven by the runtime when Replicate = true, so most games never touch it. It is there for progress the kit cannot observe, or for reacting to the server without a Tutorial instance.
| Member | Notes |
|---|---|
Start() / Stop() | Connects the bridge. Idempotent; the first call yields while the remote replicates |
ReportStep(step, previousStep?) | Reports a step reached |
ReportCompleted() | Reports the tutorial finished |
StepRequested | Signal<number>: the server moved this client |
CompleteRequested | Signal<()>: the server ended this client's tutorial |
Without replication
If you would rather own the transport, leave Replicate off and mirror the server yourself:
Remotes.TutorialStep.OnClientEvent:Connect(function(step: number)
tutorial:GoTo(step)
end)
GoTo is idempotent, so replicating the same step twice does not replay it.
Actions
| Action | Purpose |
|---|---|
Dialog.new(spec) | Shows a dialog through your renderer. Comes from createDialogs, not from the facade |
FocusUI.to(gui, options?) | Dims the screen around a GuiObject, optionally reacting to clicks |
PointWorld.to(part, options?) | Beam and floating icon toward something in the world |
Cinematic.focus(part, options?) | Flies the camera to one point of interest and back |
Cinematic.tour(stops, options?) | Flies the camera over several points in order |
Custom.new(fn) | Escape hatch, receives the step context |
FocusUI.to takes Padding?, Clicked?, Advance?. It connects to the target's Activated when it is a button, to the first descendant button otherwise, and falls back to raw input on the frame.
PointWorld.to takes From? (beam origin, defaults to the local HumanoidRootPart), Outline? (model or part to highlight), and Beam? / Icon? to suppress either piece.
Cinematic.* takes Hold?, Distance?, Height?, Finished?, Advance?.
ActionContext
Passed to every action and every callback: Step, Janitor, Spotlight, Pointer, Camera, Advance, Complete, GoTo.
Register anything you create on Janitor. It is destroyed when the step ends.
Tutorial
| Member | Notes |
|---|---|
Tutorial.build(steps, config?) | Steps may be listed in any order; the kit sorts them |
Start(step?) | Enters the first step, or the given order number |
GoTo(step) | Jumps to a step; returns false for an unknown order |
Advance() | Next step, or completes on the last one |
Complete() | Ends the flow and fires Completed |
GetStep() / IsActive() | Current state |
StepChanged | Signal<number, number?>: new order and previous one |
Completed | Signal<()> |
Spotlight / Pointer / Camera | Shared tools, also on every context |
Destroy() | Tears down everything, including the overlay |
build accepts an optional second argument: { Spotlight = ..., Pointer = ..., Camera = ... } to style the tools, and Replicate = true to report progress to the server.
Spotlight config
Name, DisplayOrder, DimColor, DimTransparency, Padding, CornerRadius, StrokeColor, StrokeThickness, StrokeTransparency, TweenTime, Shine, ShineColor, ShineSpeed, PointerImage, PointerSize, PointerGap, PointerBobDistance, PointerBobCycle.
The highlight stroke carries an animated ezvisualz gradient. Set Shine = false to turn it off. Spotlight:GetOverlay() returns the root frame, which is where you parent a caption so it renders above the dim.
WorldPointer config
Name, BeamTemplate, BeamColor, BeamWidth, Icon, IconSize, IconHeightOffset, OutlineColor, OutlineFillTransparency.
CameraTour config
Distance, Height, Hold, FocusDuration, EasingStyle.
Building UI without a framework
Writing a renderer means building an instance tree, and pulling Fusion, React or Vide just for that is a bad trade: the community consensus is not to run two UI frameworks in one project, so a kit that picks one locks out every game that picked another.
TutorialKit.UI is the middle ground. It has no reactivity and no components, just enough structure to describe a tree in one expression:
const UI = TutorialKit.UI
const ui = UI.scoped(context.Janitor)
const card: Frame = ui("Frame", {
Name = "TutorialCard",
Size = UDim2.fromOffset(420, 160),
BackgroundColor3 = Color3.fromRGB(18, 18, 22),
[UI.Children] = {
ui("UICorner", { CornerRadius = UDim.new(0, 10) }),
ui("TextButton", {
Text = "Continue",
[UI.Event("Activated")] = function()
print("clicked")
end,
}),
},
Parent = screenGui,
})
Three things it does that a bare Instance.new does not:
- Every instance and every connection goes on the Janitor you scoped it to, so a renderer written this way cannot outlive its step.
Parentis applied last, after properties and children. Parenting first makes the engine recompute layout on each following assignment; Fusion skipsParentin its property loop for the same reason.- A property the class does not have raises an error naming the class and the property, at construction, instead of failing silently.
Keys: UI.Children, UI.Event(name) and UI.Changed(propertyName), the last one wrapping GetPropertyChangedSignal.
ui(...) returns any, so annotate the variable with the class you asked for and everything downstream of it 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.
Layout
src/
init.luau client facade
Server.luau server entry, required directly
Types.luau ActionContext, Action, Step
UI.luau declarative builder
Core/ Tutorial runtime and Step
Actions/ FocusUI, PointWorld, Cinematic, Custom, Dialog
Dialog/ the renderer contract and createDialogs
Guidance/ Spotlight, WorldPointer, CameraTour
Net/ transport and the client bridge
Internal/ Signal and package resolution
Actions/Dialog.luau only builds the action; Dialog/Factory.luau binds the renderer. That is why a dialog action cannot exist without a renderer to draw it.
Testing the spotlight
The overlay is the piece most likely to regress. With a spotlight visible, run this in the Studio command bar:
local overlay = game.Players.LocalPlayer.PlayerGui.TutorialSpotlight.Overlay
local dim = overlay.Dim
assert(dim:IsA("CanvasGroup"), "Dim must be a CanvasGroup or the panels will seam")
for _, name in { "DimTop", "DimBottom", "DimLeft", "DimRight" } do
assert(dim[name].BackgroundTransparency == 0, name .. " must stay opaque")
end
print("spotlight ok", dim.GroupTransparency)