Tutorial · Game systems

How to make obby checkpoints in Roblox Studio (with saving)

An obby checkpoint has three jobs: record how far a player has got, bring them back there when they fall, and remember it when they return. This guide builds all three as one server script you can paste in, explains how each part works, and covers the usual failures: players respawning at the start, stages being skipped and progress that does not save.

Updated 9 October 202615 min readBeginnerLuau

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 Touched events harmless, so no cooldown timer is needed.
  • Move the character after Roblox has finished spawning it. CharacterAdded fires 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 UpdateAsync and 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
  1. How it works
  2. 1. Build checkpoints
  3. 2. The script
  4. How it stops skipping
  5. How respawning works
  6. 3. Saving
  7. PivotTo or SpawnLocations
  8. Skip stage product
  9. Common errors
  10. With RoCode
  11. Questions
  12. Sources

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.

The three jobs of a checkpoint system
JobWhat the script doesRoblox features it uses
Track progressRaises the player's stage when their character touches the next checkpointBasePart.Touched and a Stage IntValue in leaderstats
Respawn at the stageSpawns the character, then moves it onto the checkpoint for that stagePlayer:LoadCharacterAsync and PVInstance:PivotTo
Remember itLoads the stage when the player joins and saves it when it goes upDataStoreService: 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

  1. In the Explorer, hover over Workspace, click + and insert a Folder. Name it Checkpoints, with a capital C.
  2. Insert a Part into the folder for each checkpoint and name them 1, 2, 3 and so on, in the order players reach them. Use the number only: a part named Stage 2 or Checkpoint2 is ignored.
  3. Put checkpoint 1 at the start of the course. A player with no saved stage spawns there.
  4. 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.
  5. 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 use Humanoid:TakeDamage.
Explorer after Steps 1 and 2
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

  1. Hover over ServerScriptService, click + and insert a Script (not a LocalScript). Name it ObbyCheckpoints.
  2. Replace its contents with the code below.
  3. Press Play. You spawn on checkpoint 1, and the player list shows Stage 1.
Script in ServerScriptService named ObbyCheckpoints
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

  1. Walk onto checkpoint 2. Stage changes to 2.
  2. Jump past checkpoint 3 and land on 4. Stage stays at 2, because 4 is not the next checkpoint.
  3. Reset your character from the Roblox menu. Two seconds later you appear on checkpoint 2. Falling off the course does the same.
  4. 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:

  1. 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.
  2. Roblox lists the move to a spawn location as part of LoadCharacterAsync, so once the call returns, the script moves the character with PivotTo to a point SPAWN_HEIGHT studs above the checkpoint, facing the checkpoint's front.
  3. When the character dies, it waits RESPAWN_DELAY seconds 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

  1. Play, reach stage 3 and press Stop.
  2. Play again. You spawn on checkpoint 3, and the player list shows Stage 3.

How the script saves:

  • Loading: when a player joins, GetAsync reads 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: UpdateAsync reads 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. BindToClose waits 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.

Three ways to respawn players at a checkpoint
Parts and PivotTo (this guide)SpawnLocations and RespawnLocationSpawnLocations that change teams
Checkpoints areAny part, in a folder or taggedSpawnLocation partsSpawnLocation parts, with one Team per stage
How a touch countsThe script raises the stage, next checkpoint onlyYour script sets Player.RespawnLocation to the touched spawnWith AllowTeamChangeOnTouch on, touching a spawn moves the player to its team
Who respawns playersThe script, with LoadCharacterAsync and PivotToRobloxRoblox
Order checkBuilt inYou write itNone: touching an earlier spawn moves the player back to that team
SavingBuilt inYou write itYou write it
Spawn protectionNone, once the start SpawnLocation's Duration is 0 (Step 1)A ForceField for the spawn's Duration, 10 seconds by defaultThe same
The catchOne script must own all spawningBefore a player's respawn location is set, Roblox picks a random eligible spawn, which can be a later checkpointA 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.

  1. Publish the game. In Creator Hub, open Creations, select the game and go to Monetization > Developer Products.
  2. 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.
  3. Hover over the product, click the ⋯ button and choose Copy Asset ID. You paste this ID into both scripts below.
Script in ServerScriptService named SkipStageProduct
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 ProcessReceipt once, 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 SkipStage function in section 7 of the checkpoint script writes the new stage to the data store first. The callback returns PurchaseGranted only 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 UpdateAsync as 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.

LocalScript in a TextButton in a ScreenGui named SkipButton
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.

You type

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.

RoCode does
  • 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

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.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.