Skip to content

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.

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:

  1. 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.
  2. Impossibly fast — for stages past the first, less than MinimumLegitSeconds elapsed since the last accepted checkpoint (measured on workspace:GetServerTimeNow(), the server clock). Stage 1 is exempt: CKReachedAt is 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.Data

When 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)
end

Prepend 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
end
end, 10)

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)

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])
end
end)

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.

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 it
end)
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
end
end)

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.

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.

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.

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.