In short
Put numbered parts (1, 2, 3 and so on) in a Workspace folder named Checkpoints. Then add one server Script in ServerScriptService that gives each player a Stage value in leaderstats, raises it only when they touch the next checkpoint, moves their character onto that checkpoint every time they spawn, and saves the stage with a data store. The complete script is in Step 2.
- Decide the stage on the server and accept only the next checkpoint. That one rule stops stage skipping and makes repeated
Touchedevents harmless, so no cooldown timer is needed. - Move the character after Roblox has finished spawning it.
CharacterAddedfires before Roblox moves the character to a spawn point, so a move made there can be undone and the player appears back at the start. - Save with
UpdateAsyncand never store a lower stage than the one already saved. A failed load or an out-of-date server then cannot wipe anyone's progress.
On this page
How an obby checkpoint system works
Every player has one number: their stage, the highest checkpoint they have reached in order. Everything else follows from it. The player list shows it, the respawn uses it to pick a checkpoint, and the data store keeps it between visits.
| Job | What the script does | Roblox features it uses |
|---|---|---|
| Track progress | Raises the player's stage when their character touches the next checkpoint | BasePart.Touched and a Stage IntValue in leaderstats |
| Respawn at the stage | Spawns the character, then moves it onto the checkpoint for that stage | Player:LoadCharacterAsync and PVInstance:PivotTo |
| Remember it | Loads the stage when the player joins and saves it when it goes up | DataStoreService: GetAsync and UpdateAsync |
All three jobs run in one server Script. A stage changed by a LocalScript is seen by that one player only, and the server never saves it, so checkpoint logic belongs on the server. If you are not sure where code runs, read Script vs LocalScript vs ModuleScript first. The Stage column uses the same leaderstats folder explained in the leaderboard guide.
Step 1: Build the checkpoint parts
- In the Explorer, hover over Workspace, click + and insert a Folder. Name it
Checkpoints, with a capital C. - Insert a Part into the folder for each checkpoint and name them
1,2,3and so on, in the order players reach them. Use the number only: a part namedStage 2orCheckpoint2is ignored. - Put checkpoint
1at the start of the course. A player with no saved stage spawns there. - For each part, turn on Anchored and leave CanCollide and CanTouch on. Make it flat and wide enough to stand on, with its front face pointing along the course: players spawn on top of it, facing that way.
- Keep one SpawnLocation at the start, next to checkpoint 1. Roblox places each new character there first, and the script then moves it to its checkpoint. Set the SpawnLocation's Duration to
0: by default it gives every character that spawns there a ForceField for 10 seconds, and a ForceField blocks kill parts that useHumanoid:TakeDamage.
Workspace
SpawnLocation SpawnLocation
Checkpoints Folder new
1 Part new
2 Part new
3 Part new
ServerScriptService
ObbyCheckpoints Script new
Using tags instead of a folder
If your checkpoints sit inside stage models rather than one folder, tag them instead. Select a checkpoint part, scroll to the Tags section of the Properties window, click + and add the tag Checkpoint. Then in the Attributes section, click +, add a Number attribute named Stage and set it to that checkpoint's stage. The script reads both setups, so there is nothing to change in the code. Give each stage number to one part only: the Output warns about duplicates. Tagged parts outside Workspace, such as templates in ServerStorage, are ignored.
Step 2: Add the checkpoint script
- Hover over ServerScriptService, click + and insert a Script (not a LocalScript). Name it
ObbyCheckpoints. - Replace its contents with the code below.
- Press Play. You spawn on checkpoint 1, and the player list shows Stage 1.
local Players = game:GetService("Players")
local CollectionService = game:GetService("CollectionService")
local DataStoreService = game:GetService("DataStoreService")
local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService")
-- Settings
local ALLOW_SKIPPING = false -- true: touching any later checkpoint moves a player there
local RESPAWN_DELAY = 2 -- seconds between dying and respawning
local SPAWN_HEIGHT = 3 -- studs from the top of the checkpoint to the character's root part
local AUTOSAVE_SECONDS = 60
local MAX_SAVED_PURCHASES = 50 -- skip-stage receipts remembered per player
-- In Studio a failed load gives up at once, so testing without data store access is quick
local LOAD_ATTEMPTS = if RunService:IsStudio() then 1 else 3
local STORE_NAME = "ObbyStage_v1"
-- Called inside each pcall below, so a place without data store access
-- still runs everything else in this script
local function stageStore(): DataStore
return DataStoreService:GetDataStore(STORE_NAME)
end
-- 1. Find the checkpoints: parts named 1, 2, 3... in a Workspace folder named
-- Checkpoints, or parts in Workspace with the tag Checkpoint and a number attribute Stage.
local checkpoints: { [number]: BasePart } = {}
local function addCheckpoint(instance: Instance, stage: number?)
if not instance:IsA("BasePart") or not instance:IsDescendantOf(workspace) then
return -- skips tagged templates kept in ServerStorage or ReplicatedStorage
end
if not stage or stage < 1 or stage % 1 ~= 0 then
return
end
local existing = checkpoints[stage]
if existing and existing ~= instance then
warn(`Two checkpoints use stage {stage}: {existing:GetFullName()} and {instance:GetFullName()}`)
return
end
checkpoints[stage] = instance
end
local folder = workspace:FindFirstChild("Checkpoints")
if folder then
for _, child in folder:GetChildren() do
addCheckpoint(child, tonumber(child.Name))
end
end
for _, tagged in CollectionService:GetTagged("Checkpoint") do
local stage = tagged:GetAttribute("Stage")
addCheckpoint(tagged, if type(stage) == "number" then stage else nil)
end
-- Stages must run 1, 2, 3... with no gaps; anything after a gap can never be reached
local lastStage = 0
while checkpoints[lastStage + 1] do
lastStage += 1
end
assert(lastStage > 0, "No checkpoint 1: add a part named 1 to Workspace.Checkpoints")
for stage in checkpoints do
if stage > lastStage then
warn(`Checkpoint {stage} can never be reached: there is no checkpoint {lastStage + 1}`)
end
end
workspace:SetAttribute("ObbyLastStage", lastStage) -- read by the optional skip button
-- From here on this script spawns every character itself, so each one appears at its stage
Players.CharacterAutoLoads = false
-- 2. Player state, kept on the server only
local stageValues: { [Player]: IntValue } = {} -- the Stage value in each player's leaderstats
local savedStages: { [Player]: number } = {} -- the stage this server last saved or loaded
local pendingSaves = 0
local function keyFor(player: Player): string
return "player_" .. player.UserId
end
-- 3. Spawning
local function spawnCFrame(part: BasePart): CFrame
local _, yaw = part.CFrame:ToOrientation()
local position = part.Position + Vector3.new(0, part.Size.Y / 2 + SPAWN_HEIGHT, 0)
return CFrame.new(position) * CFrame.Angles(0, yaw, 0)
end
local spawnAtStage: (player: Player) -> ()
local function watchCharacter(player: Player, character: Model)
local respawning = false
local function respawnLater()
if respawning then
return
end
respawning = true
task.wait(RESPAWN_DELAY)
-- Only respawn if they are still here and nothing has spawned them since
if player.Parent and player.Character == character then
spawnAtStage(player)
end
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid then
humanoid.Died:Connect(respawnLater)
end
-- Falling off the map can remove the character from Workspace, and Died only
-- fires while the Humanoid is in Workspace, so this catches that case too
character.AncestryChanged:Connect(function()
if not character:IsDescendantOf(workspace) then
respawnLater()
end
end)
end
spawnAtStage = function(player: Player)
local oldCharacter = player.Character
local ok, err = pcall(function()
player:LoadCharacterAsync()
end)
if not ok then
warn(`Could not spawn {player.Name}: {err}`)
-- Try again shortly, unless they have left or have been spawned since
task.delay(RESPAWN_DELAY, function()
if player.Parent and stageValues[player] and player.Character == oldCharacter then
spawnAtStage(player)
end
end)
return
end
if oldCharacter and oldCharacter ~= player.Character then
oldCharacter:Destroy() -- releases the old character's connections
end
local character = player.Character
local stageValue = stageValues[player]
if not (character and stageValue) then
return
end
-- Move the character only after LoadCharacterAsync has placed it
character:PivotTo(spawnCFrame(checkpoints[stageValue.Value]))
watchCharacter(player, character)
end
-- 4. Touching checkpoints
local function onCheckpointTouched(stage: number, hit: BasePart)
-- FindFirstAncestorOfClass also finds the character when an accessory or tool touches
local character = hit:FindFirstAncestorOfClass("Model")
local player = character and Players:GetPlayerFromCharacter(character)
if not (character and player) then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then
return -- a dead character falling onto a checkpoint does not count
end
local stageValue = stageValues[player]
if not stageValue then
return -- their saved stage has not loaded yet
end
local current = stageValue.Value
-- The next stage only. Touching the current or an earlier checkpoint does nothing,
-- so however many times Touched fires, the stage goes up once.
if stage == current + 1 or (ALLOW_SKIPPING and stage > current) then
stageValue.Value = stage
end
end
for stage = 1, lastStage do
checkpoints[stage].Touched:Connect(function(hit)
onCheckpointTouched(stage, hit)
end)
end
-- 5. Loading and saving
local function loadStage(player: Player): number
for attempt = 1, LOAD_ATTEMPTS do
local ok, data = pcall(function()
return stageStore():GetAsync(keyFor(player))
end)
if ok then
local saved = if type(data) == "table" then tonumber(data.Stage) else nil
return if saved then math.floor(saved) else 1
end
warn(`Could not load {player.Name}'s stage (attempt {attempt} of {LOAD_ATTEMPTS}): {data}`)
if attempt < LOAD_ATTEMPTS then
task.wait(attempt * 2)
end
end
-- Start at stage 1. The save below never lowers a saved stage, so this cannot wipe progress.
return 1
end
local function saveStage(player: Player)
local stageValue = stageValues[player]
if not stageValue then
return
end
local stage = stageValue.Value
if stage <= (savedStages[player] or 0) then
return -- nothing new since the last save
end
pendingSaves += 1
local ok, err = pcall(function()
stageStore():UpdateAsync(keyFor(player), function(old)
local data: { [string]: any } = if type(old) == "table" then old else {}
if (tonumber(data.Stage) or 0) >= stage then
return nil -- the saved stage is already this far: cancel the write
end
data.Stage = stage
return data
end)
end)
pendingSaves -= 1
if ok and stageValues[player] then
savedStages[player] = math.max(savedStages[player] or 0, stage)
elseif not ok then
warn(`Could not save {player.Name}'s stage: {err}`)
end
end
-- 6. Players joining and leaving
local function onPlayerAdded(player: Player)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local stageValue = Instance.new("IntValue")
stageValue.Name = "Stage"
stageValue.Parent = leaderstats
local stage = math.clamp(loadStage(player), 1, lastStage)
if not player.Parent then
return -- they left while their stage was loading
end
stageValue.Value = stage
savedStages[player] = stage
stageValues[player] = stageValue
leaderstats.Parent = player
spawnAtStage(player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
for _, player in Players:GetPlayers() do
task.spawn(onPlayerAdded, player)
end
Players.PlayerRemoving:Connect(function(player: Player)
local character = player.Character
saveStage(player)
stageValues[player] = nil
savedStages[player] = nil
-- Destroying the character disconnects the Died and AncestryChanged
-- connections from section 3, whatever PlayerCharacterDestroyBehavior is set to
if character then
character:Destroy()
end
end)
task.spawn(function()
while true do
task.wait(AUTOSAVE_SECONDS)
for _, player in Players:GetPlayers() do
task.spawn(saveStage, player)
end
end
end)
game:BindToClose(function()
for _, player in Players:GetPlayers() do
task.spawn(saveStage, player)
end
-- Wait for every save, including ones started by PlayerRemoving
while pendingSaves > 0 do
task.wait()
end
end)
-- 7. Hook for the optional skip-stage developer product (see the guide).
-- Returns true once the skip is saved, or was already saved by any server.
local skipStage = Instance.new("BindableFunction")
skipStage.Name = "SkipStage"
skipStage.Parent = ServerStorage
skipStage.OnInvoke = function(player: Player, purchaseId: string): boolean
-- A receipt can arrive while the player is still joining: wait for their stage
while player.Parent and not stageValues[player] do
task.wait(0.5)
end
local stageValue = stageValues[player]
if not stageValue then
return false -- they left; Roblox runs ProcessReceipt again when they rejoin
end
local newStage = stageValue.Value
pendingSaves += 1
local ok, err = pcall(function()
stageStore():UpdateAsync(keyFor(player), function(old)
local data: { [string]: any } = if type(old) == "table" then old else {}
local purchases: { string } = if type(data.Purchases) == "table" then data.Purchases else {}
newStage = math.max(tonumber(data.Stage) or 1, stageValue.Value)
if table.find(purchases, purchaseId) then
return nil -- already granted: change nothing
end
newStage = math.min(newStage + 1, lastStage)
table.insert(purchases, purchaseId)
if #purchases > MAX_SAVED_PURCHASES then
table.remove(purchases, 1)
end
data.Stage = newStage
data.Purchases = purchases
return data
end)
end)
pendingSaves -= 1
if not ok then
warn(`Could not save a stage skip for {player.Name}: {err}`)
return false
end
if stageValues[player] ~= stageValue then
return true -- saved, but they have left: the new stage loads next time
end
newStage = math.min(newStage, lastStage)
savedStages[player] = math.max(savedStages[player] or 0, newStage)
if newStage > stageValue.Value then
stageValue.Value = newStage
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if character and humanoid and humanoid.Health > 0 then
character:PivotTo(spawnCFrame(checkpoints[newStage]))
end
end
return true
end
Section 7 at the end is only used by the optional skip stage product. If you never sell one, nothing calls it.
Test it
- Walk onto checkpoint
2. Stage changes to 2. - Jump past checkpoint
3and land on4. Stage stays at 2, because 4 is not the next checkpoint. - Reset your character from the Roblox menu. Two seconds later you appear on checkpoint 2. Falling off the course does the same.
- Open the Output window. Until you finish Step 3, it shows a "Could not load" warning each time you play, because Studio cannot reach data stores yet. Everyone starts at stage 1 until then.
How the stage check stops skipping
The core of the script is one comparison in section 4: a touch counts only when the checkpoint's number is exactly the player's stage plus one.
- The next checkpoint: the stage goes up by one.
- The current or an earlier checkpoint: nothing happens. Walking back down the course never lowers a stage.
- A later checkpoint: nothing happens. A player on stage 4 who finds a gap and lands on checkpoint 7 gets no credit until they have touched 5 and 6.
The same rule does the job of a debounce. Touched can fire many times while a character walks across a part, once for each body part that makes contact. Because a checkpoint only counts when it is the next one, the first touch raises the stage and every later touch finds it already there. If you add a sound or a particle effect, play it where the stage changes, not on every touch.
The touch handler checks two more things. It finds the character with FindFirstAncestorOfClass("Model"), so a touch from a hat or a held tool still counts. And it ignores a character whose Humanoid has no health left, so a falling body cannot collect the checkpoint below it.
Set ALLOW_SKIPPING to true only if your course has shortcuts you want to reward: any later checkpoint then moves the player there. Roblox's defensive design guide uses obbies as an example and recommends the opposite for anything that gives rewards: mandatory checkpoints in sequence, checked in order by the server.
How respawning at the checkpoint works
A common cause of players respawning at the start is timing. Roblox's LoadCharacterAsync reference lists the order in which a character loads: CharacterAdded fires second, the character is parented into the game sixth, and moving it to a spawn location is the eighth and last step. A script that moves the character the moment CharacterAdded fires is moving it before Roblox has placed it, and Roblox's own placement can put it straight back on a SpawnLocation.
This script avoids the race by spawning characters itself. It sets Players.CharacterAutoLoads to false, which stops Roblox spawning anyone automatically, and then:
- When a player joins, it loads their stage first and only then calls
Player:LoadCharacterAsync(), so nobody appears at stage 1 and jumps to stage 30 a moment later. - Roblox lists the move to a spawn location as part of
LoadCharacterAsync, so once the call returns, the script moves the character withPivotToto a pointSPAWN_HEIGHTstuds above the checkpoint, facing the checkpoint's front. - When the character dies, it waits
RESPAWN_DELAYseconds and does the same again.
Falling off the map needs one extra case. Roblox removes parts that fall below Workspace.FallenPartsDestroyHeight, and removes the whole character model once its last part has gone, while Humanoid.Died only fires for a Humanoid that is still in Workspace. So the script also respawns a character that has left Workspace, and a flag makes sure each character causes one respawn at most. When a player leaves, the script destroys their character, which disconnects these connections: Roblox's performance guide recommends that clean-up unless Workspace.PlayerCharacterDestroyBehavior is enabled.
Two things change with automatic spawning off. Players.RespawnTime only applies while CharacterAutoLoads is on, so the delay comes from RESPAWN_DELAY instead. And a player sees no character until their stage has loaded. If another script in your game also spawns players, such as a lobby, let one script own spawning.
Step 3: Turn on saving and test it
- Play, reach stage 3 and press Stop.
- Play again. You spawn on checkpoint 3, and the player list shows Stage 3.
How the script saves:
- Loading: when a player joins,
GetAsyncreads their key,player_followed by their user ID. A failed read is tried three times on a live server and once in Studio. If it still fails, the player starts at stage 1. - Saving:
UpdateAsyncreads the stored stage and writes the new one only if it is higher. A player who started at stage 1 after a failed load cannot overwrite a saved stage 20, and neither can a server holding an out-of-date copy. - When: when the player leaves, every 60 seconds for players whose stage went up, and when the server shuts down.
BindToClosewaits for every save to finish, including saves started by players leaving at that moment.
That is one GetAsync per visit and at most one timed UpdateAsync a minute per player, a small share of a server's data store budget. Each UpdateAsync counts against both the read and the write budget. For retries, request limits, session locking and backups, continue with the DataStore tutorial.
To start a test account from scratch, open Creator Hub, go to Creations, select the game, then Configure > Data Stores Manager. Open ObbyStage_v1, click the Action menu next to the player's key and choose Mark for Deletion. Renaming the data store in the script starts every player again, so only do that on purpose.
PivotTo or SpawnLocations: which to use
There are three common ways to bring players back to their checkpoint. This guide uses the first. The other two are built into Roblox, and you will meet them in other checkpoint scripts.
| Parts and PivotTo (this guide) | SpawnLocations and RespawnLocation | SpawnLocations that change teams | |
|---|---|---|---|
| Checkpoints are | Any part, in a folder or tagged | SpawnLocation parts | SpawnLocation parts, with one Team per stage |
| How a touch counts | The script raises the stage, next checkpoint only | Your script sets Player.RespawnLocation to the touched spawn | With AllowTeamChangeOnTouch on, touching a spawn moves the player to its team |
| Who respawns players | The script, with LoadCharacterAsync and PivotTo | Roblox | Roblox |
| Order check | Built in | You write it | None: touching an earlier spawn moves the player back to that team |
| Saving | Built in | You write it | You write it |
| Spawn protection | None, once the start SpawnLocation's Duration is 0 (Step 1) | A ForceField for the spawn's Duration, 10 seconds by default | The same |
| The catch | One script must own all spawning | Before a player's respawn location is set, Roblox picks a random eligible spawn, which can be a later checkpoint | A Team per stage, and only the first may be auto-assignable or players do not start at checkpoint 1 |
Roblox documents Player.RespawnLocation for progression systems such as checkpoints. The spawn must be in Workspace, and either be Neutral or have a TeamColor that matches the player's team. The hard part is the first spawn: a saved stage takes a moment to load, and a character that spawns before then lands on any enabled spawn, chosen at random. Turning off CharacterAutoLoads and spawning after the load, as this guide does, fixes that for either method.
The team method needs no script, which makes it handy for a quick prototype. It has no order check and saves nothing.
Optional: sell a skip stage developer product
In Roblox terms, a "skip stage" purchase is a developer product: an item a player can buy more than once. Your game grants it in a server callback, MarketplaceService.ProcessReceipt. A wrong callback can leave a player who paid with nothing, or let one purchase skip two stages, so this part follows Roblox's receipt rules closely.
- Publish the game. In Creator Hub, open Creations, select the game and go to Monetization > Developer Products.
- Click Create developer product, upload an icon, enter a name such as Skip stage and a description, choose a category, set the price in Robux and click Save Changes.
- Hover over the product, click the ⋯ button and choose Copy Asset ID. You paste this ID into both scripts below.
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")
local SKIP_STAGE_PRODUCT_ID = 0 -- replace with your developer product ID
-- Set ProcessReceipt once, in one server Script, for every developer product in
-- the game. If another script already sets it, move this branch into that one.
-- It is set straight away; the wait for the checkpoint script happens inside it.
MarketplaceService.ProcessReceipt = function(receiptInfo)
local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
if not player then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
if receiptInfo.ProductId == SKIP_STAGE_PRODUCT_ID then
-- Created by the ObbyCheckpoints script, which owns the saved stage
local skipStage = ServerStorage:WaitForChild("SkipStage", 30)
if not (skipStage and skipStage:IsA("BindableFunction")) then
warn("SkipStage not found: is the ObbyCheckpoints script running?")
return Enum.ProductPurchaseDecision.NotProcessedYet
end
local ok, granted = pcall(function()
return skipStage:Invoke(player, receiptInfo.PurchaseId)
end)
if ok and granted == true then
return Enum.ProductPurchaseDecision.PurchaseGranted
elseif not ok then
warn(`Skip stage failed for {player.Name}: {granted}`)
end
end
-- Not saved yet, or a product this script does not handle. Roblox calls
-- ProcessReceipt again when the player rejoins or buys something else.
return Enum.ProductPurchaseDecision.NotProcessedYet
end
How this follows Roblox's rules:
- One callback. Roblox says to set
ProcessReceiptonce, in a single server Script, and to handle every developer product there. If your game already sets it, add the skip branch to that script instead of pasting a second one. The script sets it as soon as it runs and waits for the checkpoint script inside the callback, so a slow or broken checkpoint script cannot leave the game without one. - Save before granting. The
SkipStagefunction in section 7 of the checkpoint script writes the new stage to the data store first. The callback returnsPurchaseGrantedonly when that write succeeded. - Never twice. Roblox notes that the callback for one purchase can run on two servers at the same time. The purchase ID is saved in the same
UpdateAsyncas the new stage, so a receipt that is already recorded changes nothing. - Try again later. If the save fails or the player leaves first, the callback returns
NotProcessedYet, and Roblox calls it again when the player rejoins or buys something else.
Do not grant anything from PromptProductPurchaseFinished. Roblox warns that the event firing does not mean the purchase went through.
For the button, add a ScreenGui to StarterGui, a TextButton inside it, and this LocalScript inside the button. It hides the button on the last stage. The server still decides what a skip does, so a player who changes the button gains nothing.
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local SKIP_STAGE_PRODUCT_ID = 0 -- the same ID as in SkipStageProduct
local player = Players.LocalPlayer
local button = script.Parent :: GuiButton
local stage = player:WaitForChild("leaderstats"):WaitForChild("Stage") :: IntValue
-- Hide the button on the last stage, where there is nothing left to skip.
-- This is for the player's benefit only: the server decides what a skip does.
local function refresh()
local lastStage = workspace:GetAttribute("ObbyLastStage")
button.Visible = type(lastStage) == "number" and stage.Value < lastStage
end
button.Activated:Connect(function()
MarketplaceService:PromptProductPurchase(player, SKIP_STAGE_PRODUCT_ID)
end)
stage.Changed:Connect(refresh)
workspace:GetAttributeChangedSignal("ObbyLastStage"):Connect(refresh)
refresh()
Common errors and fixes
These are common checkpoint problems and their fixes. Check the Output window first: the script warns about duplicate and unreachable checkpoints and about failed loads and saves. In a live game, open the Developer Console with F9 and switch to the Server view.
Players respawn at the start, or at the wrong checkpoint
There are three usual causes. The character is moved in CharacterAdded, before Roblox has placed it (see How respawning works). The checkpoints are SpawnLocations, and Roblox picked a random one before the player's spawn was set. Or a second checkpoint script, often inside a free model, is also moving players. Press Ctrl+Shift+F (Cmd+Shift+F on Mac) to search every script for CharacterAdded and RespawnLocation, and keep one system.
Stage 10 comes before stage 2, or stages are out of order
Scripts that loop over GetChildren() in whatever order it returns, or sort the names as text, get 1, 10, 2. This script reads each name with tonumber, so check that every checkpoint name is only a number, with no spaces or letters. The Output warns about duplicate numbers and about checkpoints after a gap, which can never be reached.
Touching a checkpoint with a hat or tool does nothing
Your script finds the character with hit.Parent. When the touching part is an accessory's Handle, hit.Parent is the Accessory, so GetPlayerFromCharacter returns nil. Use hit:FindFirstAncestorOfClass("Model"), as this script does.
Checkpoints work for one player but not for others
Usually one debounce variable is shared by every player, so one player's touch blocks everyone else, or the checkpoint code is in a LocalScript, so only that player sees the change. Keep each player's stage on the server, stored per player, as this script does.
Touching the next checkpoint does nothing
Check that the part is inside Workspace.Checkpoints or has the Checkpoint tag, that CanTouch is on, and that it really is the next number. If the Output shows "No checkpoint 1", the script stopped before connecting anything: check the folder name and that a part is named 1.
The stage resets to 1 every time I rejoin
The saves are not reaching the data store. In Studio, turn on Enable Studio Access to API Services in a published test copy, as in Step 3. In a live game, look for "Could not save" warnings in the Developer Console's Server view. More causes and fixes are in the script errors guide and the DataStore tutorial.
Build it with RoCode
If you would rather not set this up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio: you describe the change in a web chat, and its Studio plugin creates the scripts and instances in your Explorer, so there is no code to paste.
Add obby checkpoints: a Checkpoints folder with parts named 1 to 10, a Stage stat in leaderstats that only goes up one checkpoint at a time, respawning at the last checkpoint reached, and saving with a data store.
- Can look through your place first with its reading tools, such as Search Scripts and Read Script, so it can work with a checkpoint script you already have instead of adding a second one
- Creates the folder and parts in Workspace and the Script in ServerScriptService
- Runs Check Script on the script it wrote, a compile check inside Studio rather than a playtest
- Sends its changes in batches, and each batch is an undo point in Studio's history
You still press Play and test the course yourself: RoCode does not start playtests. It cannot turn on Studio Access to API Services or create developer products, so do those in Studio and Creator Hub. If you already have a checkpoint script that misbehaves, Ask mode answers questions about your place with read-only tools and changes nothing. How RoCode connects to Studio.
Questions
How do I make obby checkpoints save in Roblox?
Load the stage with GetAsync when the player joins, and save it with UpdateAsync when they leave, on a timer and when the server shuts down. The script in Step 2 does all three and never stores a lower stage than the saved one. To test saving in Studio, turn on Studio Access to API Services in a published test copy.
How do I stop players skipping stages in my obby?
Keep the stage on the server and count a checkpoint only when its number is the player's stage plus one. Touching a later checkpoint then does nothing until the ones before it have been reached.
Should obby checkpoints be SpawnLocations or parts?
Either works. SpawnLocations with Player.RespawnLocation let Roblox respawn players, but they add a ForceField by default and can start a player on a random checkpoint before their stage is set. Plain parts with PivotTo, as in this guide, work with any part and leave the timing to your script.
How do I give a reward when a player finishes the obby?
In onCheckpointTouched, right after the line that sets stageValue.Value, check whether stage == lastStage and give the reward there. The stage only rises once per checkpoint, so it runs once per player. If the reward must never repeat, save that it was given, too.
How do I reset a player's stage?
For testing, delete their key in Data Stores Manager, as in Step 3. For a restart button in your game, remember that this script's save never lowers a stage: write the reset with its own UpdateAsync that sets Stage to 1 without that check, then set the player's Stage value to match.
Sources and further reading
- Roblox Engine API: Player. RespawnLocation, CharacterAdded, and the load order of LoadCharacterAsync
- Roblox Engine API: Players. CharacterAutoLoads and RespawnTime
- Roblox Engine API: SpawnLocation. checkpoints with AllowTeamChangeOnTouch, spawning rules and ForceField Duration
- Roblox Creator Docs: Spawn and respawn. RespawnLocation for progression systems such as checkpoints
- Roblox Creator Docs: Defensive design tactics. mandatory, sequential checkpoints validated on the server
- Roblox Creator Docs: Data stores. Studio access, GetAsync and UpdateAsync
- Roblox Creator Docs: Data store error codes and limits. request budgets, and UpdateAsync counting against both read and write
- Roblox Creator Docs: Improve performance. destroying player characters after leaving to prevent memory leaks
- Roblox Creator Docs: Developer products. creating a product, ProcessReceipt and listing outside the game
- Roblox Engine API: MarketplaceService.ProcessReceipt. when the callback runs, retries and its limitations
- Roblox Creator Docs: Implement player data and purchasing systems. recording purchase IDs with the save before granting
How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 9 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.