A checkpoint must detect a touch, respawn the player, and save the stage for a later session. Test those jobs in that order. The server script below saves the highest stage reached and reports failed DataStore requests in Output.

Create the Checkpoint Parts#

Add a Folder named Checkpoints to Workspace. Put one anchored Part in it for each stage and name the Parts 1, 2, 3, and so on.

Workspace
└── Checkpoints
    ├── 1
    ├── 2
    ├── 3
    └── 4

ServerScriptService
└── CheckpointSystem

Each checkpoint must be a BasePart with Anchored and CanTouch enabled. Use only the stage number as its name.

Add a Script named CheckpointSystem to ServerScriptService, then paste this code:

local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local checkpointFolder = workspace:WaitForChild("Checkpoints")
local checkpointStore = DataStoreService:GetDataStore("ObbyCheckpoint_v1")

local DEFAULT_STAGE = 1
local RESPAWN_HEIGHT = 4
local touchDebounce = {}

local function getKey(player)
	return "Player_" .. player.UserId
end

local function getCheckpoint(stageNumber)
	local checkpoint = checkpointFolder:FindFirstChild(tostring(stageNumber))

	if checkpoint and checkpoint:IsA("BasePart") then
		return checkpoint
	end

	return nil
end

local function createLeaderstats(player)
	local leaderstats = player:FindFirstChild("leaderstats")

	if not leaderstats then
		leaderstats = Instance.new("Folder")
		leaderstats.Name = "leaderstats"
		leaderstats.Parent = player
	end

	local stage = leaderstats:FindFirstChild("Stage")

	if not stage then
		stage = Instance.new("IntValue")
		stage.Name = "Stage"
		stage.Value = DEFAULT_STAGE
		stage.Parent = leaderstats
	end

	return stage
end

local function loadStage(player, stageValue)
	local success, result = pcall(function()
		return checkpointStore:GetAsync(getKey(player))
	end)

	if not success then
		warn("Could not load checkpoint for " .. player.Name .. ": " .. tostring(result))
		return
	end

	local savedStage = tonumber(result)
	if savedStage and getCheckpoint(savedStage) then
		stageValue.Value = math.max(stageValue.Value, savedStage)
	end
end

local function saveStage(player)
	local leaderstats = player:FindFirstChild("leaderstats")
	local stageValue = leaderstats and leaderstats:FindFirstChild("Stage")
	if not stageValue then
		return false
	end

	local stageToSave = stageValue.Value
	local success, errorMessage = pcall(function()
		checkpointStore:UpdateAsync(getKey(player), function(oldStage)
			oldStage = tonumber(oldStage) or DEFAULT_STAGE
			return math.max(oldStage, stageToSave)
		end)
	end)

	if not success then
		warn("Could not save checkpoint for " .. player.Name .. ": " .. tostring(errorMessage))
	end
	return success
end

local function moveCharacterToCheckpoint(player, character)
	local leaderstats = player:FindFirstChild("leaderstats")
	local stageValue = leaderstats and leaderstats:FindFirstChild("Stage")
	local checkpoint = stageValue and getCheckpoint(stageValue.Value)
	if not checkpoint or not checkpoint:IsA("BasePart") then
		return
	end

	local rootPart = character:WaitForChild("HumanoidRootPart", 10)
	if rootPart then
		character:PivotTo(checkpoint.CFrame + Vector3.new(0, RESPAWN_HEIGHT, 0))
	end
end

local function onCheckpointTouched(checkpoint, hit)
	local character = hit:FindFirstAncestorOfClass("Model")
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return
	end

	local player = Players:GetPlayerFromCharacter(character)
	local stageNumber = tonumber(checkpoint.Name)
	if not player or not stageNumber then
		return
	end

	local leaderstats = player:FindFirstChild("leaderstats")
	local stageValue = leaderstats and leaderstats:FindFirstChild("Stage")
	if not stageValue or stageNumber <= stageValue.Value then
		return
	end

	local now = os.clock()
	if touchDebounce[player] and now - touchDebounce[player] < 1 then
		return
	end
	touchDebounce[player] = now
	stageValue.Value = stageNumber

	task.spawn(function()
		saveStage(player)
	end)
end

for _, checkpoint in checkpointFolder:GetChildren() do
	if checkpoint:IsA("BasePart") and tonumber(checkpoint.Name) then
		checkpoint.Touched:Connect(function(hit)
			onCheckpointTouched(checkpoint, hit)
		end)
	else
		warn("Ignored checkpoint with invalid name or type: " .. checkpoint:GetFullName())
	end
end

Players.PlayerAdded:Connect(function(player)
	local stageValue = createLeaderstats(player)
	loadStage(player, stageValue)

	player.CharacterAdded:Connect(function(character)
		moveCharacterToCheckpoint(player, character)
	end)

	if player.Character then
		moveCharacterToCheckpoint(player, player.Character)
	end
end)

Players.PlayerRemoving:Connect(function(player)
	saveStage(player)
	touchDebounce[player] = nil
end)

game:BindToClose(function()
	local players = Players:GetPlayers()
	local remaining = #players
	local deadline = os.clock() + 25

	for _, player in players do
		task.spawn(function()
			saveStage(player)
			remaining -= 1
		end)
	end

	while remaining > 0 and os.clock() < deadline do
		task.wait()
	end

	if remaining > 0 then
		warn("Server closed before every checkpoint save returned")
	end
end)

UpdateAsync keeps the larger of the stored and current stages, so this monotonic checkpoint value cannot move backward because of an older write. DataStore requests can still fail or be throttled; pcall only catches and reports the failure. This example does not add retries or session locking, so treat it as a basic checkpoint system rather than a general-purpose player-data framework.

Test Touch and Respawn First#

Press Play in Studio without relying on persistence yet.

  1. Touch checkpoint 2 and confirm that Stage changes to 2 in the player’s leaderstats.
  2. Reset the character and confirm that it appears above checkpoint 2.
  3. Touch checkpoint 3, reset again, and check the new position.

If Stage never changes, inspect Explorer and Output. Ignored checkpoint with invalid name or type identifies a non-Part item or nonnumeric name. Otherwise, check CanTouch, the Script location, and whether the Parts existed when the server started.

If Reset does not move the character, confirm that the matching numbered Part exists. Increase RESPAWN_HEIGHT if the character appears inside it.

Test Data Saving in a Separate Published Experience#

DataStore persistence requires a published experience. Enabling Studio API access can expose the same stores used by live servers, so publish a separate test copy before changing that Security setting.

In that test copy:

  1. Reach checkpoint 2 or 3.
  2. Check Output for a Could not save checkpoint warning.
  3. Start a new session and confirm that Stage loads to the saved value.
  4. Reset once more, then repeat with another test account.

A Studio pass proves only that the test configuration reached its store. It does not verify the published script, live store name, or request budget. Test the published experience with a non-production account.

Diagnose Save and Load Failures#

Progress resets only after leaving#

Look for Could not save checkpoint for <name>: in Output. The text after the colon may identify permissions, throttling, or a service problem. Compare it with Roblox’s current error documentation.

The player always loads stage 1#

Fix any Could not load checkpoint warning first. With no warning, confirm the store name is ObbyCheckpoint_v1 and the saved stage has a matching Part. Missing Parts are ignored.

Studio fails but the published test works#

Check Studio API access and the published experience being tested. Keep production data isolated.

Studio works but the live experience fails#

Confirm the server Script was published, the store name did not change, and live server Output contains no request errors. A local Studio pass does not verify the live version.

Requests are throttled#

Do not add saves to a frame loop or every repeated touch. BindToClose also has limited time; it is a final attempt, not proof that every write finished.

Saving immediately after each new checkpoint is reasonable for a small obby with infrequent progress. For a large or busy experience, keep checkpoint changes in memory and flush them periodically instead of assuming that one write per checkpoint will scale indefinitely.

Choose Whether Players May Skip Stages#

The script accepts any higher checkpoint. To require strict order, replace:

if not stageValue or stageNumber <= stageValue.Value then
	return
end

with:

if not stageValue or stageNumber ~= stageValue.Value + 1 then
	return
end

Release Verification#

In a published test experience, touch a checkpoint, reset, leave, rejoin, and reset again. Treat any save, load, or throttling warning as a failed test even if Stage looks correct.

Keep ObbyCheckpoint_v1 stable after release. A new name points to a different store, while a new data format requires migration.