Skip to content

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.

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:

  1. 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.
  2. The split is too fast: for stages after the first, less than MinimumLegitSeconds passed since the last accepted checkpoint, measured with workspace:GetServerTimeNow(). Stage 1 is exempt. The kit stamps the start time when the session starts, so any walk to the first pad is fine.
  3. The touch is out of reach: the character has no HumanoidRootPart (or PrimaryPart), or the root is farther from the pad’s physical centre (Pad.ExtentsCFrame) than (Pad.ExtentsSize.Magnitude / 2 + 6) * TouchSlack studs. The 6 studs cover any body part’s distance from the root on a scaled avatar, and TouchSlack defaults to 1.5. This check runs only when the context carries a Pad.

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.Data

When 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:

src/Server/Game/Config/Checkpoints.luau
--!strict
-- Checkpoints: CheckpointKit tuning for this map.
return {
MinimumLegitSeconds = 2, -- the tightest honest split between two adjacent pads
}
src/Server/Game/Registry.luau
--!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 pad
self.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 nil
end, 10)

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.

src/Server/Game/Services/ObbyService.luau
--!strict
-- ObbyService: respawns runners at their best checkpoint.
local ObbyService = {
Name = "ObbyService",
Dependencies = { "CheckpointKit" },
}
function ObbyService.init(self: any, kernel: any)
self.Kernel = kernel
end
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()
end
end
return ObbyService

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

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.

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:

src/Shared/Game/Channels.luau
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
end
end)

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.

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.

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.

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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.