CheckpointKit
CheckpointKit is validated obby checkpoint progression. The defining decision: a Touched event is client-influencable physics — exploiters teleport onto pad 25 — so reaching a pad is a claim, not a fact. Every touch runs the fail-closed Intent.Checkpoint hook chain, where the kit validates the stage for sequence (no teleport-skips) and minimum time (no impossible splits) before any progress is recorded.
Progress persists through session.Profile when a DataDriver is attached; without one it lives in transient session data for the visit.
Mental model
Section titled “Mental model”Declaration is map-authored. Pads are BaseParts named by stage number — "1", "2", "3"… — inside a workspace folder (FolderName, default "Checkpoints"). At start() the kit connects Touched on every numerically-named part in the folder; anything else in the folder is ignored. No folder → a boot warning and no pads wired.
Every touch is validated. The Touched handler resolves the toucher to a session and fires Intent.Checkpoint with { Session, Player, Stage }. The kit’s own gate sits at priority 50 and rejects when:
- Out of sequence — the claimed stage isn’t exactly
BestStage + 1. This kills teleport-skips and makes backtracking touches (and re-touches of the current pad) free no-ops. - Impossibly fast — for stages past the first, less than
MinimumLegitSecondselapsed since the last accepted checkpoint (measured onworkspace:GetServerTimeNow(), the server clock). Stage 1 is exempt:CKReachedAtis stamped at session start, so a slow walk to the first pad never matters, and a fast one is legitimate.
Only on acceptance does the kit write BestStage, restamp CKReachedAt, and publish Obby.Checkpoint(player, stage). Rejected touches change nothing — they don’t reset the timer, so a wall-clipping attempt can’t be used to launder the next split.
Storage resolves per touch. Profile load is async; a data table pinned at join would point at stale session.Data once the profile attaches. So every touch re-resolves:
local Data = if session.Profile then session.Profile.Data else session.DataWhen Kernel.ProfileLoaded fires, any stage earned pre-profile in session.Data merges in — the higher of transient and persisted wins, so the load window can’t lose a pad and stale transient data can’t regress a save.
Server registration — one line, everything else is map authoring:
local ServerScriptService = game:GetService("ServerScriptService")local CheckpointKit = require(ServerScriptService.ChloeKernelServer.Kits.CheckpointKit)
return function(kernel: any) kernel:registerService(CheckpointKit.service({ MinimumLegitSeconds = 2, -- tightest honest split between two adjacent pads }))
kernel.Bus:subscribe("Obby.Checkpoint", function(_, player, stage) print(`{player.Name} reached stage {stage}`) end)endPrepend your own rules below the kit gate — lower priority numbers run first in the chain:
kernel.Hooks:on("Intent.Checkpoint", function(context) -- VIP-gated shortcut pad if context.Stage == 10 and not context.Session.Data.VipShortcut then return false endend, 10)Respawning at the checkpoint
Section titled “Respawning at the checkpoint”The kit records progress; it does not move characters. Wiring respawn is game code — and because a server-side pivot looks exactly like the teleport exploit the movement monitor exists to catch, publish AntiExploit.Forgive first so the next movement sample is pardoned:
kernel:onSession(function(session) session:bind(session.Player.CharacterAdded:Connect(function(character) task.defer(function() local Data = if session.Profile then session.Profile.Data else session.Data local Stage = Data.BestStage or 0 if Stage < 1 then return -- fresh runner: default spawn end local Pad = workspace.Checkpoints:FindFirstChild(tostring(Stage)) if Pad and Pad:IsA("BasePart") then kernel.Bus:publish("AntiExploit.Forgive", session.Player) character:PivotTo(Pad.CFrame + Vector3.new(0, 4, 0)) end end) end))end)Run timers and speedrun boards
Section titled “Run timers and speedrun boards”CKReachedAt is validation state (split timing between adjacent pads), not a run timer. Race the whole tower with a bus subscriber and a global leaderboard, exactly as the Obby starter template does:
local Leaderboards = require(game:GetService("ServerScriptService").ChloeKernelServer.Leaderboards)
local FinalStage = 25
local Boards = Leaderboards.attach(kernel, { Boards = { BestTime = { Ascending = true } }, -- lower is better})
local RunStart: { [Player]: number } = {}kernel:onSession(function(session: any) session:bind(function() RunStart[session.Player] = nil end)end)
kernel.Bus:subscribe("Obby.Checkpoint", function(_, player, stage) if stage == 1 then RunStart[player] = os.clock() -- the tower timer starts at pad 1 elseif stage == FinalStage and RunStart[player] then Boards:submit("BestTime", player, os.clock() - RunStart[player]) endend)Because Obby.Checkpoint only publishes for validated touches, a board built on it inherits the kit’s anti-cheat: a submitted time is a run whose every split passed sequence and minimum-time checks. The kit persists BestStage in the profile; best times live in the leaderboard, which handles its own persistence.
QuestKit objectives count the same topic — { Topic = "Obby.Checkpoint", Count = 10 } is a working daily with no extra wiring.
Resetting a run
Section titled “Resetting a run”There is no reset API — deliberately, because “restart my run” is a design decision (does it wipe the persisted best stage, or just this attempt?). The kit reads one field, so game code resets by writing it. As a client-triggered intent — note the explicit validator, because a fail-closed channel with a handler and no validator rejects everything:
local Net = kernel:net()Net:defineIntent("CK_ResetRun", {}, { RateLimit = 1 })
kernel.Hooks:on("Intent.CK_ResetRun", function() return true -- no state to check; the rate limit paces itend)
Net:onIntent("CK_ResetRun", function(session) local Data = if session.Profile then session.Profile.Data else session.Data Data.BestStage = 0 session.Data.CKReachedAt = workspace:GetServerTimeNow() local Humanoid = session.Player.Character and session.Player.Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid.Health = 0 -- respawn wiring sees BestStage 0 and leaves them at spawn endend)After the write, pads accept from stage 1 again — the sequence gate is just BestStage + 1. Restamping CKReachedAt matters: without it, stage 2 of the new run would be timed against the old run’s last checkpoint.
Configuration
Section titled “Configuration”CheckpointKit.service(config?) — KitConfig
Section titled “CheckpointKit.service(config?) — KitConfig”| Field | Type | Default | Description |
|---|---|---|---|
FolderName |
string? |
"Checkpoints" |
Folder under workspace holding the numbered pads. |
MinimumLegitSeconds |
number? |
3 |
Minimum server-clock seconds between consecutive accepted checkpoints (stage > 1). Tune to your map: the tightest split an honest runner can hit, minus margin. |
StageField |
string? |
"BestStage" |
Key in session.Profile.Data / session.Data holding the best stage (a number, 0 when fresh). |
The config table itself is optional — CheckpointKit.service() runs with all defaults.
API reference
Section titled “API reference”| Member | Description |
|---|---|
CheckpointKit.service(config: KitConfig?) |
Returns a service definition for kernel:registerService. |
service:init(kernel) |
Registers the priority-50 Intent.Checkpoint gate, seeds CKReachedAt on new sessions, subscribes the profile merge. Called by the kernel. |
service:start() |
Finds the folder and connects Touched on every numbered pad. Warns (and returns) when the folder is missing. Called by the kernel. |
service:stop() |
Disconnects the hook gate, session/profile subscriptions, and every pad connection. |
The kit exposes no progress methods — read StageField off the session’s data directly, or listen on the bus.
Hooks & bus topics
Section titled “Hooks & bus topics”| Hook point | Context | Mode | Notes |
|---|---|---|---|
Intent.Checkpoint |
{ Session, Player, Stage } |
fail-closed | Fired per pad touch. Kit gate at priority 50 (sequence + minimum time); prepend game rules at lower numbers. A crashing validator rejects — fail-closed like every intent chain. |
| Topic | Args | Direction | When |
|---|---|---|---|
Obby.Checkpoint |
player, stage |
published | A touch passed the full chain and progress was recorded. |
Kernel.ProfileLoaded |
session, profile |
consumed | Merges transient best stage into the profile (higher wins). |
AntiExploit.Forgive |
player |
you publish | Before any respawn/teleport pivot your game performs — see above. |