Saving progress safely
Persistence changes the stakes: stopping a test no longer necessarily undoes its effects. This lab saves one deliberately simple statistic, the largest practice count reached in a session. It does not persist the co-op game's coins, round state, an inventory, purchases, or currency.
Prerequisites: Server/client concepts, Output, and a separate Roblox test experience that you own. Read publishing and access before creating that test experience. If you prefer not to publish a test copy or change API access, read the lesson and skip execution.
Outcome: Load a small versioned record, save a monotonic best value, distinguish a missing key from a failed read, and understand why this is not a production data system.
Establish a genuinely separate test environment
Create a fresh Baseplate and publish it as a new, disposable test experience with a different universe ID. Name it clearly, such as “Persistence Lab TEST.” Adding another place inside your existing experience is insufficient: places in one experience share data stores.
Only in that isolated test experience, open File > Experience Settings > Security and enable Enable Studio Access to API Services if you choose to run the lab. Never enable this setting on a live production experience for these exercises. Studio can otherwise read and write the same persistent records as live servers. These boundaries and the setting are documented in Data stores.
Write the test experience's name and universe ID in your private project notes. Before every persistence experiment, compare the open project with those notes. A different store name is useful organization, but it is not a replacement for environment isolation.
The data contract
Each player has one key, user_ followed by UserId, in TutorialPracticeBest_v1. The record is a plain table with version = 1 and a nonnegative integer best. Display names are unsuitable identifiers because they can change. The server adds one practice count every five seconds; this is simply a repeatable source of sample progress.
There are three distinct states:
- A read succeeds with no value: initialize a new record in memory.
- A read succeeds with the expected schema: use the stored best.
- A read fails, or the schema is unexpected: show that data is unavailable and prohibit saves for the session.
A failure is never permission to overwrite someone else's progress with zero. See player-data failure handling.
Install the complete scripts
In the isolated lab only, create these two objects and replace their contents with the supplied code:
ServerScriptService > PracticeBest— Script, using 15-practice-best.server.luau.StarterPlayer > StarterPlayerScripts > PracticeStatus— LocalScript, using 15-practice-status.client.luau.
The server owns the data. The client only displays replicated attributes. No client is allowed to submit a best score.
Server script
-- Placement: ServerScriptService > PracticeBest (Script)
-- ONLY in a separate, disposable test experience (a different universe).
-- Educational monotonic best-count example, not a production player-data system.
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("TutorialPracticeBest_v1")
local sessions = {}
local closing = false
local MAX_COUNT = 1000000
local COUNT_INTERVAL = 5
local SAVE_INTERVAL = 60
local function validCount(value)
return typeof(value) == "number" and value == value
and value >= 0 and value <= MAX_COUNT and value % 1 == 0
end
local function decode(value)
if value == nil then
return true, 0 -- A successfully read missing key is a new player.
end
if typeof(value) == "table" and value.version == 1
and validCount(value.best) then
return true, value.best
end
return false, nil -- Unexpected data must never be replaced with defaults.
end
local function request(operation, needsWrite)
local lastError = "No request budget available"
for attempt = 1, 3 do
local reads = DataStoreService:GetRequestBudgetForRequestType(
Enum.DataStoreRequestType.StandardRead
)
local writes = DataStoreService:GetRequestBudgetForRequestType(
Enum.DataStoreRequestType.StandardWrite
)
if reads >= 1 and (not needsWrite or writes >= 1) then
local ok, result = pcall(operation)
if ok then
return true, result
end
lastError = tostring(result)
-- Setup errors are not transient. Fix setup before another test.
if string.find(lastError, "403", 1, true)
or string.find(lastError, "StudioAccessToApisNotAllowed", 1, true) then
break
end
end
if attempt < 3 then
task.wait(2 ^ (attempt - 1) + math.random() * 0.5)
end
end
return false, lastError
end
local function save(player, state)
-- Never write if loading failed; never overlap this session's writes.
if not state.loaded or state.saving or state.count <= state.savedBest then
return
end
state.saving = true
local candidate = state.count
local rejected = false
local ok, result = request(function()
return store:UpdateAsync("user_" .. player.UserId, function(old)
local valid, oldBest = decode(old)
if not valid then
rejected = true
return nil
end
-- Do not yield or award gameplay rewards here. Roblox may rerun this callback.
return { version = 1, best = math.max(oldBest, candidate) }
end)
end, true)
if ok and not rejected and typeof(result) == "table" and validCount(result.best) then
state.savedBest = math.max(state.savedBest, result.best)
player:SetAttribute("PracticeBest", state.savedBest)
player:SetAttribute("PracticeDataStatus", "Saved")
print("[PracticeBest] saved", player.UserId, state.savedBest)
else
player:SetAttribute("PracticeDataStatus", "Save unconfirmed; will retry later")
warn("[PracticeBest] save unconfirmed", player.UserId, rejected and "Unexpected stored schema" or tostring(result))
end
state.saving = false
end
local function join(player)
if sessions[player] or closing then
return
end
local state = { loaded = false, saving = false, count = 0, savedBest = 0 }
sessions[player] = state
player:SetAttribute("PracticeCount", 0)
player:SetAttribute("PracticeDataStatus", "Loading")
local ok, value = request(function()
return store:GetAsync("user_" .. player.UserId)
end, false)
if sessions[player] ~= state or player.Parent ~= Players or closing then
return
end
local valid, best = false, nil
if ok then valid, best = decode(value) end
if not ok or not valid then
player:SetAttribute("PracticeDataStatus", "Load failed; saving disabled")
warn("[PracticeBest] load failed; no writes for this session", player.UserId, tostring(value))
return
end
state.loaded = true
state.savedBest = best
player:SetAttribute("PracticeBest", best)
player:SetAttribute("PracticeDataStatus", "Loaded")
task.spawn(function()
while sessions[player] == state and not state.leaving and not closing do
task.wait(COUNT_INTERVAL)
if sessions[player] ~= state or state.leaving or closing then break end
state.count = math.min(state.count + 1, MAX_COUNT)
player:SetAttribute("PracticeCount", state.count)
end
end)
task.spawn(function()
task.wait(math.random(0, 10))
while sessions[player] == state and not state.leaving and not closing do
task.wait(SAVE_INTERVAL)
if sessions[player] ~= state or state.leaving or closing then break end
save(player, state)
end
end)
end
local function leave(player)
local state = sessions[player]
if not state then return end
-- Stop counting first. A pending save may have an older candidate.
state.leaving = true
while state.saving do task.wait(0.1) end
save(player, state)
sessions[player] = nil
end
Players.PlayerAdded:Connect(join)
Players.PlayerRemoving:Connect(leave)
for _, player in Players:GetPlayers() do
task.spawn(join, player)
end
game:BindToClose(function()
closing = true
local pending = 0
for player, state in pairs(sessions) do
pending += 1
task.spawn(function()
while state.saving do task.wait(0.1) end
save(player, state)
pending -= 1
end)
end
local deadline = os.clock() + 25
while pending > 0 and os.clock() < deadline do task.wait(0.1) end
-- Shutdown saving is best effort, never a guarantee after a crash.
end)
Client display
-- Placement: StarterPlayer > StarterPlayerScripts > PracticeStatus (LocalScript)
local Players = game:GetService("Players")
local player = Players.LocalPlayer
local gui = Instance.new("ScreenGui")
gui.Name = "PracticeStatusGui"
gui.ResetOnSpawn = false
gui.Parent = player:WaitForChild("PlayerGui")
local label = Instance.new("TextLabel")
label.AnchorPoint = Vector2.new(0.5, 0)
label.Position = UDim2.fromScale(0.5, 0.05)
label.Size = UDim2.new(0.9, 0, 0, 140)
label.BackgroundColor3 = Color3.fromRGB(24, 35, 55)
label.TextColor3 = Color3.new(1, 1, 1)
label.Font = Enum.Font.Gotham
label.TextSize = 18
label.TextWrapped = true
label.Parent = gui
local function update()
local best = player:GetAttribute("PracticeBest")
label.Text = string.format(
"Practice count: %s\nConfirmed best: %s\n%s",
tostring(player:GetAttribute("PracticeCount") or 0),
best == nil and "unavailable" or tostring(best),
tostring(player:GetAttribute("PracticeDataStatus") or "Waiting for server")
)
end
for _, attribute in { "PracticeCount", "PracticeBest", "PracticeDataStatus" } do
player:GetAttributeChangedSignal(attribute):Connect(update)
end
update()
Read the save path carefully
The script buffers progress in server memory and periodically saves changed progress, with a small initial offset. It also attempts a final save on departure and shutdown. Requests use pcall, bounded retries, and backoff with jitter. A budget check reduces avoidable requests but cannot reserve capacity or guarantee success. UpdateAsync consumes both read and write budgets. See DataStoreService, request limits, and data-store best practices.
The UpdateAsync callback returns the maximum of the stored best and the captured candidate. It cannot yield and may be invoked again during contention. Because taking the maximum is idempotent, retrying this particular operation cannot add the same reward twice. This reasoning would not apply to blindly incrementing a balance. GlobalDataStore.UpdateAsync
“Save unconfirmed” is intentional wording. A failed response does not prove the backend made no change. The next safe attempt still uses the maximum operation. Never reinterpret this small example as a general transaction recipe. Data-store errors
Verify success and failure
- Start Test. Expect “Loaded,” a count increasing every five seconds, and a best of zero for a genuinely new key.
- Stay for at least 75 seconds. Expect “Saved” and a confirmed best greater than zero. Read the server Output too.
- End the test, allow shutdown to finish, then start again with the same test identity. The session count restarts, but the loaded best remains.
- Exit before beating your old best. Rejoin and confirm the best did not decrease.
- For a deliberate failure check, stop testing and turn off Studio API access in the disposable lab. Start again. Expect “Load failed; saving disabled,” an unavailable best, and a warning. The script must not announce a successful save.
- Restore access only in the lab if continuing. Rejoin and confirm the previously stored best remains.
Studio multi-client identities are test identities; do not assume their keys correspond to your normal account. Also test with the actual account in the published test experience when permitted. A fresh experience or a deliberately new test-store name starts a new dataset; it is not evidence that old saves disappeared.
Scope limits and troubleshooting
This example has no session lock. A monotonic maximum can merge concurrent best values, but a profile containing spendable currency, inventory transfers, quests, and purchases needs stronger consistency and ownership rules. Roblox's player-data reference discusses stale sessions and session locking. Even reference systems need extensive testing.
Shutdown saving is best effort; crashes can lose progress since the last confirmed save. The retry helper is intentionally small and is not a complete error-classification or recovery framework. Before production, add tested migrations, observability, privacy/deletion handling, recovery procedures, and a proven strategy for concurrent sessions.
If nothing loads, confirm the project is published, the script is server-side, and API access is enabled only for the test universe. If a save is unconfirmed, record the actual error and stop rapid repeated testing. If the schema is rejected, inspect the test record rather than changing validation to accept arbitrary data.
Exercise and completion check
Draw the four paths: new record, returning player, failed load, and unconfirmed save. Explain why only a successful validated load permits saving. Then change the counter interval in the lab and verify that a smaller new result cannot reduce the stored best.
You are done when success and deliberate failure both behave as described, the live experience was never touched, and you can name the consistency problems this example does not solve.
Verification: Official APIs and guidance checked 2026-10-03. These scripts were statically reviewed, not run against Roblox Studio or real data stores for this manual. They are educational code, not production-ready persistence.
Previous: 14 Testing multiplayer · Next: 16 Debugging and performance
Related: 09 Client–server boundary · 18 Publishing and access · Sources
Linked from
Sources
- https://create.roblox.com/docs/cloud-services/data-stores
- https://create.roblox.com/docs/cloud-services/data-stores/player-data-purchasing
- https://create.roblox.com/docs/reference/engine/classes/DataStoreService
- https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits
- https://create.roblox.com/docs/cloud-services/data-stores/best-practices
- https://create.roblox.com/docs/reference/engine/classes/GlobalDataStore#UpdateAsync