Verification: Static review complete; Studio/device tests unverifiedLast verified: 2026-10-03Prerequisites: The client-server boundary, Testing multiplayer

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:

  1. ServerScriptService > PracticeBest — Script, using 15-practice-best.server.luau.
  2. 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

  1. Start Test. Expect “Loaded,” a count increasing every five seconds, and a best of zero for a genuinely new key.
  2. Stay for at least 75 seconds. Expect “Saved” and a confirmed best greater than zero. Read the server Output too.
  3. End the test, allow shutdown to finish, then start again with the same test identity. The session count restarts, but the loaded best remains.
  4. Exit before beating your old best. Rejoin and confirm the best did not decrease.
  5. 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.
  6. 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

Progress and help notes

Nothing is sent automatically. Copying happens only when you press the button.

Sources