Skip to main content

TutorialKit

Onboarding for Roblox, declared as data

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 CanvasGroup so 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

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

  1. Bump version in wally.toml (semver).
  2. Commit and tag: git tag v0.1.1 && git push origin v0.1.1.
  3. Publish: wally login once, then wally 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 sets 2.
  • MaxLines — how many lines it can walk through.
  • SupportsPortrait — whether Portrait means 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.

DirectionTriggerEffect
Client to serverStart, Advance, GoToPlayerStepAdvanced(player, step, previousStep)
Client to serverComplete, or advancing past the last stepTutorialCompleted(player)
Server to clientTutorialServer:SetStep(player, step)The tutorial jumps to that step
Server to clientTutorialServer: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

MemberNotes
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
PlayerStepAdvancedSignal<Player, number, number?>
TutorialCompletedSignal<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.

MemberNotes
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
StepRequestedSignal<number>: the server moved this client
CompleteRequestedSignal<()>: 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

ActionPurpose
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

MemberNotes
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
StepChangedSignal<number, number?>: new order and previous one
CompletedSignal<()>
Spotlight / Pointer / CameraShared 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.
  • Parent is applied last, after properties and children. Parenting first makes the engine recompute layout on each following assignment; Fusion skips Parent in 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)