CheckpointKit
Validates obby checkpoint progression on every pad touch for sequence, split time and physical reach, and keeps the best stage in the profile.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Kits .CheckpointKit - Since
- v0.1.0
- Source
- Kits/CheckpointKit.luau
CheckpointKit records obby progress from pad touches. A Touched event comes from physics the client can influence
(an exploiter can teleport onto pad 25), so a touch is a claim. Every touch runs the Intent.Checkpoint
hook chain, where the kit checks sequence, minimum split time and whether the character’s root is
close enough to the pad, before anything is recorded.
Progress lives in session.Profile.Data when a DataDriver is attached, and in session.Data
for the visit otherwise.
Mental model
Section titled “Mental model”Pads are authored in the map. They are BaseParts named by stage number ("1", "2", "3") placed directly in a
Workspace folder (FolderName, default "Checkpoints"). At start() the kit reads the folder once and connects
Touched on every numbered part in it. Other children are ignored. A missing folder logs a warning and wires nothing. A
pad authored with CanTouch off cannot carry a Touched connection, so the kit warns with its full name and skips it;
the other pads still work. Pads added to the folder after start() are not wired.
Touched fires once per body part, so one step onto a pad produces several events. The kit keeps the last attempt time
per player and pad and ignores further touches from that player on that pad for TouchDebounce seconds (default 0.5).
The window starts on every attempt, accepted or not.
Each attempt resolves the toucher to a session and fires Intent.Checkpoint with
{ Session, Player, Stage, Pad }. The kit’s own handler runs at priority 50 and rejects when:
- The stage is out of sequence: it is not exactly
BestStage + 1. This blocks skips and makes backtracking and touches on the current pad free no-ops. - The split is too fast: for stages after the first, less than
MinimumLegitSecondspassed since the last accepted checkpoint, measured withworkspace:GetServerTimeNow(). Stage 1 is exempt. The kit stamps the start time when the session starts, so any walk to the first pad is fine. - The touch is out of reach: the character has no
HumanoidRootPart(orPrimaryPart), or the root is farther from the pad’s physical centre (Pad.ExtentsCFrame) than(Pad.ExtentsSize.Magnitude / 2 + 6) * TouchSlackstuds. The 6 studs cover any body part’s distance from the root on a scaled avatar, andTouchSlackdefaults to 1.5. This check runs only when the context carries aPad.
Only when the chain passes does the kit write the stage, restamp the split time and publish
Obby.Checkpoint(player, stage). A rejected touch changes nothing and does not reset the split timer.
The store is resolved on every touch:
local Data = if session.Profile then session.Profile.Data else session.DataWhen Kernel.ProfileLoaded fires, a stage earned in session.Data before the load is copied into the profile if it is
higher than the saved one. The load window cannot lose a pad, and a stale transient value cannot lower a save.
CheckpointKit is a service kit. Add it to the server Registry.luau:
--!strict-- Checkpoints: CheckpointKit tuning for this map.
return { MinimumLegitSeconds = 2, -- the tightest honest split between two adjacent pads}--!strict-- Every server service, one require per module in Services. ServiceManager-- orders boot by each service's Dependencies.
local ServerScriptService = game:GetService("ServerScriptService")
local CheckpointKit = require(ServerScriptService.ChloeKernelServer.Kits.CheckpointKit)
return { require(script.Parent.Services.ComponentService), CheckpointKit.service(require(script.Parent.Config.Checkpoints)), require(script.Parent.Services.ObbyService),} :: { any }Game rules go in extra handlers on Intent.Checkpoint. Lower priority numbers run first, so priority 10 runs before the
kit’s 50:
-- In ObbyService.start(): a VIP-only shortcut padself.Unhook = self.Kernel.Hooks:on("Intent.Checkpoint", function(context): boolean? if context.Stage == 10 and not context.Session.Data.VipShortcut then return false end return nilend, 10)Respawning at the checkpoint
Section titled “Respawning at the checkpoint”The kit records progress; it does not move characters. CharacterAdded fires before the character is parented, and
the engine moves the character to its spawn after parenting, so a pivot has to wait until the character is in the
Workspace and then one deferral more. A server pivot also looks exactly like the teleport the
movement monitor exists to catch, so publish AntiExploit.Forgive first.
--!strict-- ObbyService: respawns runners at their best checkpoint.
local ObbyService = { Name = "ObbyService", Dependencies = { "CheckpointKit" },}
function ObbyService.init(self: any, kernel: any) self.Kernel = kernelend
function ObbyService.place(self: any, session: any, character: Model) task.defer(function() local Player = session.Player if Player.Character ~= character or not character:IsDescendantOf(workspace) then return -- a newer life, or the character left end local Data = if session.Profile then session.Profile.Data else session.Data local Stage = Data.BestStage or 0 local Folder = workspace:FindFirstChild("Checkpoints") local Pad = if Folder and Stage >= 1 then Folder:FindFirstChild(tostring(Stage)) else nil if Pad and Pad:IsA("BasePart") then self.Kernel.Bus:publish("AntiExploit.Forgive", Player) character:PivotTo(Pad.CFrame + Vector3.new(0, 4, 0)) end end)end
function ObbyService.start(self: any) self.SessionConnection = self.Kernel:onSession(function(session: any) session:bind(session.Player.CharacterAdded:Connect(function(character: Model) if character:IsDescendantOf(workspace) then self:place(session, character) return end local Parented: RBXScriptConnection Parented = character.AncestryChanged:Connect(function() if character:IsDescendantOf(workspace) then Parented:Disconnect() self:place(session, character) end end) end)) end)end
function ObbyService.stop(self: any) if self.SessionConnection then self.SessionConnection:disconnect() endend
return ObbyServiceRun timers and speedrun boards
Section titled “Run timers and speedrun boards”The split timestamp is validation state between adjacent pads, not a run timer. Time the whole tower with a Bus subscriber and a global leaderboard, as the Obby starter template does. The default OrderedDataStore backend stores integers only, so submit whole milliseconds:
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(_topic, 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, math.round((os.clock() - RunStart[player]) * 1000)) endend)Obby.Checkpoint publishes only for touches that passed every check, so each submitted time is a run whose splits all
passed the sequence, time and reach checks. QuestKit can count the same topic:
{ Topic = "Obby.Checkpoint", Count = 10 } is a working daily objective.
Resetting a run
Section titled “Resetting a run”There is no reset API, because what a reset means (wipe the best stage, or restart only this attempt) is a design
decision. The kit reads one field, so a reset writes it. As a client request, declared in Game.Channels:
return Registry.define({ ResetRun = { Kind = "Intent", Schema = {}, RateLimit = 1, Open = true }, -- no payload to validate})-- In ObbyService.start()self.Net.ResetRun.on(function(session: any) local Data = if session.Profile then session.Profile.Data else session.Data Data.BestStage = 0 session.Data.CKReachedAt = workspace:GetServerTimeNow() local Character = session.Player.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid.Health = 0 -- the respawn sees BestStage 0 and leaves them at the spawn endend)After the write the pads accept stage 1 again. Restamp CKReachedAt too: without it, stage 2 of the new run is timed
against the old run’s last checkpoint.
Configuration
Section titled “Configuration”CheckpointKit.service(config?)
Section titled “CheckpointKit.service(config?)”| Field | Type | Default | Description |
|---|---|---|---|
FolderName |
string? |
"Checkpoints" |
Workspace folder holding the numbered pads as direct children. |
MinimumLegitSeconds |
number? |
3 |
Least server-clock time between consecutive accepted checkpoints after stage 1. Set it to the tightest honest split on the map, minus a margin. |
StageField |
string? |
"BestStage" |
Key in session.Profile.Data or session.Data holding the best stage (0 when fresh). |
TouchSlack |
number? |
1.5 |
Multiplier on the accepted root-to-pad distance, half the pad’s diagonal plus 6 studs. |
TouchDebounce |
number? |
0.5 |
Seconds one player’s further touches on the same pad are ignored after an attempt. |
CheckpointKit.service() with no table uses every default.
API reference
Section titled “API reference”| Member | Description |
|---|---|
CheckpointKit.service(config?) -> service |
Returns a service definition named CheckpointKit. |
service:init(kernel) |
Registers the priority-50 Intent.Checkpoint handler, stamps the split time on new sessions and subscribes the profile merge. The kernel calls it. |
service:start() |
Finds the folder and connects Touched on every numbered pad with CanTouch on. Warns when the folder is missing or a pad has CanTouch off. The kernel calls it. |
service:stop() |
Removes the hook handler, disconnects the session and profile subscriptions and every pad connection. |
The kit has no progress getters. Read StageField from the session’s data, or listen on the Bus.
Hooks and Bus topics
Section titled “Hooks and Bus topics”| Hook point | Context | Notes |
|---|---|---|
Intent.Checkpoint |
{ Session, Player, Stage, Pad } |
Fired once per player, pad and debounce window. The kit’s handler (priority 50) checks sequence, split time and reach. Any handler returning false rejects, and a handler that errors rejects too. |
| Topic | Payload | Direction | When |
|---|---|---|---|
Obby.Checkpoint |
player, stage |
Published | A touch passed the whole chain and the stage was recorded. |
Kernel.ProfileLoaded |
session, profile |
Consumed | Copies a higher pre-profile stage into the profile. |
AntiExploit.Forgive |
player |
Published by game code | Before any respawn or teleport pivot the game performs. |