{"id":"kartzrbx/tutorial-kit","name":"tutorial-kit","scope":"kartzrbx","platform":"roblox","description":"Self-contained onboarding module for Roblox: declarative steps, UI spotlight, world pointers and camera tours.","version":"0.1.2","latest":"0.1.2","versions":["0.1.2"],"license":"MIT","licenseRating":"safe","licenseCaveats":["The package archive does not include its license text; the license is declared in its manifest metadata."],"licenseVerified":false,"dependencies":{"arxkdev/ezvisualz":{"version":"^1.2.0","alias":"ezvisualz"},"1foreverhd/janitor":{"version":"^1.18.15","alias":"janitor"}},"integrity":"21736c5e4d66cf68d0bc7ea8b02709ccc82ce2137e5e9949787286c520d83b1b","likes":0,"downloads":0,"install":"forest install kartzrbx/tutorial-kit","url":"https://forest.dev/p/roblox/kartzrbx/tutorial-kit","files":"https://api.forest.dev/ai/package/roblox/kartzrbx/tutorial-kit/files","readme":"# TutorialKit\n\n[Documentation and API reference](https://kartzrbx.github.io/TutorialKit/)\n\n<!--moonwave-hide-before-this-line-->\n\nSelf-contained onboarding module for Roblox. A tutorial is declared as data: ordered steps, each holding actions that start together.\n\n```luau\nconst tutorial = Tutorial.build({\n\tStep.new(1, {\n\t\tDialog.new({\n\t\t\tSpeaker = \"Guide\",\n\t\t\tLines = { \"Welcome!\" },\n\t\t\tChoices = { { Text = \"Continue\" } },\n\t\t}),\n\t\tFocusUI.to(shopButton, {\n\t\t\tClicked = function()\n\t\t\t\tprint(\"player opened the shop\")\n\t\t\tend,\n\t\t\tAdvance = true,\n\t\t}),\n\t}),\n\n\tStep.new(2, {\n\t\tPointWorld.to(fountainPart),\n\t\tCinematic.focus(fountainPart, { Hold = 3, Advance = true }),\n\t}),\n})\n\ntutorial:Start()\n```\n\nThe 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.\n\nThe facade is client only. Progress can be replicated so the server can save it — see [Server events](#server-events).\n\n## Why it exists\n\nEvery onboarding flow rebuilds the same pieces, and each one has a trap that surfaces late:\n\n- **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.\n- **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.\n- **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.\n\n## Install\n\n### Wally (recommended)\n\nAdd to your game's `wally.toml`:\n\n```toml\nTutorialKit = \"kartzrbx/tutorial-kit@0.1.0\"\n```\n\n```sh\nwally install\n```\n\nMap the shim in `default.project.json`:\n\n```json\n\"TutorialKit\": {\n  \"$path\": \"Packages/TutorialKit.lua\"\n}\n```\n\n```luau\nconst TutorialKit = require(ReplicatedStorage.Packages.TutorialKit)\n```\n\nPackage page: [wally.run/package/kartzrbx/tutorial-kit](https://wally.run/package/kartzrbx/tutorial-kit)\n\nFull step-by-step: [Getting started](https://kartzrbx.github.io/TutorialKit/docs/getting-started) on the docs site.\n\n### Git submodule\n\nIf you prefer vendoring the source:\n\n```json\n\"TutorialKit\": {\n  \"$path\": \"lib/tutorial-kit/src\",\n  \"Packages\": { \"$path\": \"lib/tutorial-kit/packages.project.json\" }\n}\n```\n\n```luau\nconst TutorialKit = require(ReplicatedStorage.TutorialKit)\n```\n\n## Working on the kit itself\n\nFrom a clone of this repository:\n\n```sh\nrokit install   # wally + rojo\nrojo serve\n```\n\n`wally install` inside this repo refreshes the vendored copies under `Packages/`; the kit runs without it because they are committed.\n\nTo preview the documentation site:\n\n```sh\nnpm i -g moonwave\nmoonwave dev\n```\n\n### Publishing a new Wally version\n\n1. Bump `version` in `wally.toml` (semver).\n2. Commit and tag: `git tag v0.1.1 && git push origin v0.1.1`.\n3. Publish: `wally login` once, then `wally publish`.\n\nOr 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`).\n\n## Dialog\n\nThe 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.\n\nInstead you bind your renderer once and get back a `Dialog` constructor:\n\n```luau\ntype GuideDialog = { Template: string }\n\nconst Dialog = TutorialKit.createDialogs(function(\n\trequest: TutorialKit.DialogRequest<GuideDialog>\n): TutorialKit.DialogHandle?\n\tconst accept = request.Choices[1]\n\n\tconst session = DialogModule:Open({\n\t\tTemplate = if request.Extra then request.Extra.Template else \"Default\",\n\t\tNpcName = request.Speaker,\n\t\tLines = request.Lines,\n\t\tAcceptText = if accept then accept.Text else nil,\n\t})\n\tif not session then\n\t\treturn nil\n\tend\n\n\tif accept then\n\t\tsession.Accept:Connect(accept.Activate)\n\tend\n\n\treturn {\n\t\tClose = function()\n\t\t\tsession:Destroy()\n\t\tend,\n\t}\nend)\n```\n\nThe kit calls `Close` when the step ends, so a dialog never outlives its step.\n\nAnnotating `request` is what pins `Extra`, and it is the whole reason this is a factory instead of a registry — see [Typing your own fields](#typing-your-own-fields).\n\nNote 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.\n\nA full renderer against a real game dialog system is in [examples/DialogModuleRenderer.luau](examples/DialogModuleRenderer.luau).\n\n### Typing your own fields\n\nAlmost 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:\n\n```luau\ntype GuideDialog = {\n\tTemplate: string,\n\tTextEffect: (\"Typewriter\" | \"Glitch\" | \"FadeIn\")?,\n}\n\nconst Dialog = TutorialKit.createDialogs(function(request: DialogRequest<GuideDialog>)\n\t-- request.Extra is a GuideDialog here\nend, { MaxChoices = 2 })\n```\n\n```luau\nDialog.new({\n\tSpeaker = \"Guide\",\n\tLines = { \"Hello, traveler!\", \"Let me show you around.\" },\n\tChoices = { { Text = \"Continue\", Advance = true } },\n\tExtra = { Template = \"Wooden\", TextEffect = \"Typewriter\" },\n})\n```\n\n`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`.\n\n### Telling the kit what you can draw\n\nThe second argument to `createDialogs` describes the renderer's limits:\n\n- `MaxChoices` — how many buttons it draws. A template with Yes and No sets `2`.\n- `MaxLines` — how many lines it can walk through.\n- `SupportsPortrait` — whether `Portrait` means anything.\n\nSpecs 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.\n\n### Spec reference\n\n`Dialog.new` takes `Lines` (required, always a list), `Speaker?`, `Portrait?`, `Choices?` and `Extra?`.\n\nEach choice takes `Text`, `Style?` (`\"Primary\"`, `\"Secondary\"` or `\"Danger\"`), `Selected?`, and either `Advance?` or `GoTo?` — setting both is a build error.\n\nA dialog with no choices is valid: it shows text and waits for some other action in the step to advance.\n\n## How a step runs\n\nActions of a step all start together: the dialog appears while the spotlight highlights the button. Nothing blocks anything.\n\nThe step ends when an action asks it to, through the context every action receives:\n\n```luau\nFocusUI.to(button, {\n\tClicked = function(context)\n\t\tif playerHasEnoughCoins() then\n\t\t\tcontext:Advance()\n\t\tend\n\tend,\n})\n```\n\n`Advance = true` is the shorthand for the common case.\n\n## Server events\n\nProgress 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.\n\nSet `Replicate = true` on the tutorial and start the server half at boot:\n\n```luau\n-- Server\nconst TutorialServer = require(Packages.TutorialKit.Server)\n\nTutorialServer:Start()\n\nTutorialServer.PlayerStepAdvanced:Connect(function(player, step, previousStep)\n\tDataService:Set(player, \"TutorialStep\", step)\nend)\n\nTutorialServer.TutorialCompleted:Connect(function(player)\n\tDataService:Set(player, \"TutorialDone\", true)\nend)\n```\n\n```luau\n-- Client\nconst tutorial = Tutorial.build(steps, { Replicate = true })\ntutorial:Start()\n```\n\nRequire `Server` directly, never through the facade: the facade pulls the spotlight, the pointer and `ezvisualz`, none of which belong on a server.\n\n### Direction of each message\n\nThe client reports **what it did**; it never says what should happen next. The server decides, and the client follows.\n\n| Direction | Trigger | Effect |\n| --- | --- | --- |\n| Client to server | `Start`, `Advance`, `GoTo` | `PlayerStepAdvanced(player, step, previousStep)` |\n| Client to server | `Complete`, or advancing past the last step | `TutorialCompleted(player)` |\n| Server to client | `TutorialServer:SetStep(player, step)` | The tutorial jumps to that step |\n| Server to client | `TutorialServer:Complete(player)` | The tutorial ends |\n\nServer-driven transitions are not reported back, so a `SetStep` does not bounce to the server as a fresh `PlayerStepAdvanced`.\n\nEverything arriving from a remote is parsed and dropped when malformed, so neither side ever sees a bad packet.\n\n### Resuming a returning player\n\n```luau\nPlayers.PlayerAdded:Connect(function(player)\n\tconst saved = DataService:Get(player, \"TutorialStep\")\n\tif saved then\n\t\tTutorialServer:SetStep(player, saved)\n\tend\nend)\n```\n\n### Server API\n\n| Member | Notes |\n| --- | --- |\n| `Start()` | Creates the bridge and listens. Call once at boot, before any client can report |\n| `Stop()` | Disconnects and clears tracked progress |\n| `SetStep(player, step)` | Puts a client on a step |\n| `Complete(player)` | Ends a client's tutorial |\n| `GetStep(player)` | Last step the player reported |\n| `PlayerStepAdvanced` | `Signal<Player, number, number?>` |\n| `TutorialCompleted` | `Signal<Player>` |\n\n### Client API\n\n`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.\n\n| Member | Notes |\n| --- | --- |\n| `Start()` / `Stop()` | Connects the bridge. Idempotent; the first call yields while the remote replicates |\n| `ReportStep(step, previousStep?)` | Reports a step reached |\n| `ReportCompleted()` | Reports the tutorial finished |\n| `StepRequested` | `Signal<number>`: the server moved this client |\n| `CompleteRequested` | `Signal<()>`: the server ended this client's tutorial |\n\n### Without replication\n\nIf you would rather own the transport, leave `Replicate` off and mirror the server yourself:\n\n```luau\nRemotes.TutorialStep.OnClientEvent:Connect(function(step: number)\n\ttutorial:GoTo(step)\nend)\n```\n\n`GoTo` is idempotent, so replicating the same step twice does not replay it.\n\n## Actions\n\n| Action | Purpose |\n| --- | --- |\n| `Dialog.new(spec)` | Shows a dialog through your renderer. Comes from `createDialogs`, not from the facade |\n| `FocusUI.to(gui, options?)` | Dims the screen around a GuiObject, optionally reacting to clicks |\n| `PointWorld.to(part, options?)` | Beam and floating icon toward something in the world |\n| `Cinematic.focus(part, options?)` | Flies the camera to one point of interest and back |\n| `Cinematic.tour(stops, options?)` | Flies the camera over several points in order |\n| `Custom.new(fn)` | Escape hatch, receives the step context |\n\n`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.\n\n`PointWorld.to` takes `From?` (beam origin, defaults to the local `HumanoidRootPart`), `Outline?` (model or part to highlight), and `Beam?` / `Icon?` to suppress either piece.\n\n`Cinematic.*` takes `Hold?`, `Distance?`, `Height?`, `Finished?`, `Advance?`.\n\n## ActionContext\n\nPassed to every action and every callback: `Step`, `Janitor`, `Spotlight`, `Pointer`, `Camera`, `Advance`, `Complete`, `GoTo`.\n\nRegister anything you create on `Janitor`. It is destroyed when the step ends.\n\n## Tutorial\n\n| Member | Notes |\n| --- | --- |\n| `Tutorial.build(steps, config?)` | Steps may be listed in any order; the kit sorts them |\n| `Start(step?)` | Enters the first step, or the given order number |\n| `GoTo(step)` | Jumps to a step; returns `false` for an unknown order |\n| `Advance()` | Next step, or completes on the last one |\n| `Complete()` | Ends the flow and fires `Completed` |\n| `GetStep()` / `IsActive()` | Current state |\n| `StepChanged` | `Signal<number, number?>`: new order and previous one |\n| `Completed` | `Signal<()>` |\n| `Spotlight` / `Pointer` / `Camera` | Shared tools, also on every context |\n| `Destroy()` | Tears down everything, including the overlay |\n\n`build` accepts an optional second argument: `{ Spotlight = ..., Pointer = ..., Camera = ... }` to style the tools, and `Replicate = true` to report progress to the server.\n\n### Spotlight config\n\n`Name`, `DisplayOrder`, `DimColor`, `DimTransparency`, `Padding`, `CornerRadius`, `StrokeColor`, `StrokeThickness`, `StrokeTransparency`, `TweenTime`, `Shine`, `ShineColor`, `ShineSpeed`, `PointerImage`, `PointerSize`, `PointerGap`, `PointerBobDistance`, `PointerBobCycle`.\n\nThe 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.\n\n### WorldPointer config\n\n`Name`, `BeamTemplate`, `BeamColor`, `BeamWidth`, `Icon`, `IconSize`, `IconHeightOffset`, `OutlineColor`, `OutlineFillTransparency`.\n\n### CameraTour config\n\n`Distance`, `Height`, `Hold`, `FocusDuration`, `EasingStyle`.\n\n## Building UI without a framework\n\nWriting 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.\n\n`TutorialKit.UI` is the middle ground. It has no reactivity and no components, just enough structure to describe a tree in one expression:\n\n```luau\nconst UI = TutorialKit.UI\nconst ui = UI.scoped(context.Janitor)\n\nconst card: Frame = ui(\"Frame\", {\n\tName = \"TutorialCard\",\n\tSize = UDim2.fromOffset(420, 160),\n\tBackgroundColor3 = Color3.fromRGB(18, 18, 22),\n\n\t[UI.Children] = {\n\t\tui(\"UICorner\", { CornerRadius = UDim.new(0, 10) }),\n\t\tui(\"TextButton\", {\n\t\t\tText = \"Continue\",\n\t\t\t[UI.Event(\"Activated\")] = function()\n\t\t\t\tprint(\"clicked\")\n\t\t\tend,\n\t\t}),\n\t},\n\n\tParent = screenGui,\n})\n```\n\nThree things it does that a bare `Instance.new` does not:\n\n- Every instance and every connection goes on the Janitor you scoped it to, so a renderer written this way cannot outlive its step.\n- `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.\n- A property the class does not have raises an error naming the class and the property, at construction, instead of failing silently.\n\nKeys: `UI.Children`, `UI.Event(name)` and `UI.Changed(propertyName)`, the last one wrapping `GetPropertyChangedSignal`.\n\n`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.\n\n## Layout\n\n```\nsrc/\n  init.luau        client facade\n  Server.luau      server entry, required directly\n  Types.luau       ActionContext, Action, Step\n  UI.luau          declarative builder\n\n  Core/            Tutorial runtime and Step\n  Actions/         FocusUI, PointWorld, Cinematic, Custom, Dialog\n  Dialog/          the renderer contract and createDialogs\n  Guidance/        Spotlight, WorldPointer, CameraTour\n  Net/             transport and the client bridge\n  Internal/        Signal and package resolution\n```\n\n`Actions/Dialog.luau` only builds the action; `Dialog/Factory.luau` binds the renderer. That is why a dialog act","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/kartzrbx/roblox/tutorial-kit/0.1.2/readme"}