Skip to content
Development
Skill

/roblox-datastores

Roblox DataStoreService persistence: load on join, save on leave and BindToClose, UpdateAsync vs SetAsync, session locking and OrderedDataStore leaderboards. Use for lost player data, items duplicating after teleport, or "too many requests" errors. Not for engine-neutral saves

From plugin
awesome-gamedev-agent-skills
8.6k79 skills

Claude Code

$ npx -y skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores --agent claude-code

SKILL.md

roblox-datastores.SKILL.md
name: roblox-datastores
description: >
  Roblox DataStoreService persistence: load on join, save on leave and BindToClose, UpdateAsync vs
  SetAsync, session locking and OrderedDataStore leaderboards. Use for lost player data, items
  duplicating after teleport, or "too many requests" errors. Not for engine-neutral saves
  (`save-systems`), Luau basics (`roblox-luau`) or economy numbers (`roblox-simulator-economy`).

Roblox DataStores

Persist data across sessions without losing or duplicating it. All DataStore calls are network requests from server `Script`s; they can fail, be throttled, and race with another server writing the same key.

Workflow

1. **Find the existing persistence layer.** Search for `DataStoreService`, `GetDataStore`, and any profile or session library before writing new code. If the project already uses one, extend it; two systems writing the same key is the most common cause of rollbacks. 2. **Use a test place for Studio work.** Studio reads and writes the same live data as players. In a separate published test place, turn on File → Experience Settings → Security → *Enable Studio Access to API Services*; without it, requests fail with `StudioAccessToApisNotAllowed`. 3. **Key by `UserId`**, never by name: `"Player_" .. player.UserId`. 4. **Guard every call with `pcall`.** On a failed *load*, mark the session as not loaded and refuse to save, so defaults never overwrite real data. 5. **Load on `PlayerAdded`, save on `PlayerRemoving` and in `game:BindToClose`**, plus a periodic save (every 60-120 s), never on every value change. 6. **Write with `UpdateAsync` and session ownership.** `UpdateAsync` reads the latest value before writing; session ownership (in `references/sessions-and-limits.md`) stops a stale server from writing an old snapshot over newer progress. 7. **Use `OrderedDataStore` only for numeric rankings** (leaderboards). 8. **Check before reporting.** Run the static check below and fix every hit. Then, with the Studio MCP server connected and API access on in a test place, play, change a value, stop, play again, and read it back with an uncached read from `execute_luau` on the `Server` datamodel. Without MCP, ask the user to do join, change, leave, rejoin in the test place and report the value seen after rejoining and any Output warnings. Rate-limit behavior needs Team Create, not Run mode.

# Every DataStore call should sit inside pcall; this lists the lines to inspect by hand.
rg -n ":(GetAsync|SetAsync|UpdateAsync|IncrementAsync|RemoveAsync|GetSortedAsync)\(" src
rg -n "BindToClose" src        # expect at least one save-everyone handler
rg -n ":SetAsync\(" src        # each one on a player profile key needs a reason

Patterns

Load on join

local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local store = DataStoreService:GetDataStore("PlayerData")

local DEFAULT = { Coins = 0, Level = 1 }

Players.PlayerAdded:Connect(function(player)
    local key = "Player_" .. player.UserId
    local ok, data = pcall(function()
        return store:GetAsync(key)
    end)
    if not ok then
        warn("Load failed for", player.UserId, data)  -- network failure, not a new player
        player:SetAttribute("DataLoaded", false)
        return
    end
    player:SetAttribute("DataLoaded", true)
    applyToLeaderstats(player, data or table.clone(DEFAULT))  -- nil means a genuinely new player
end)

Save with UpdateAsync (session ownership still required)

This callback ignores `old`, so on its own it can overwrite newer progress from another server. Before shipping, add the session check from `references/sessions-and-limits.md` inside the callback, or use a vetted profile library.

local function savePlayer(player)
    -- `~= true` also covers a player who left while GetAsync was still in flight.
    if player:GetAttribute("DataLoaded") ~= true then return end
    local key = "Player_" .. player.UserId
    local newData = gatherDataFor(player)    -- plain, JSON-serializable table

    local ok, err = pcall(function()
        store:UpdateAsync(key, function(old)
            -- Must not yield and may run more than once. Return nil to cancel the write
            -- when this session no longer owns the key.
            return newData
        end)
    end)
    if not ok then warn("Save failed for", player.UserId, err) end
end

Save on leave and on shutdown

Players.PlayerRemoving:Connect(savePlayer)

game:BindToClose(function()
    local players = Players:GetPlayers()
    local remaining = #players
    for _, player in players do
        task.spawn(function()
            savePlayer(player)
            remaining -= 1
        end)
    end
    while remaining > 0 do task.wait() end  -- the server waits at most 30 s in total
end)

Retry only what is safe to repeat

local function withRetry(fn, attempts)
    attempts = attempts or 3
    for i = 1, attempts do
        local ok, result = pcall(fn)
        if ok then return true, result end
        if i < attempts then task.wait(2 ^ i) end
    end
    return false
end

A failed write does not prove the write did not happen; the server may only have missed the response. Re-running an `UpdateAsync` that writes the full state is harmless; re-running `IncrementAsync` can add twice. When the outcome matters, confirm with an uncached read (`DataStoreGetOptions` with `UseCache = false`) before retrying.

Leaderboard with OrderedDataStore

local board = DataStoreService:GetOrderedDataStore("Coins")
pcall(function() board:SetAsync("Player_" .. player.UserId, coins) end)  -- on change, throttled

local ok, pages = pcall(function()
    return board:GetSortedAsync(false, 10)   -- false = descending, highest first
end)
if ok then
    for rank, entry in pages:GetCurrentPage() do
        print(rank, entry.key, entry.value)
    end
end

Values must be integers.

Read more
@gamedev-skills
Ships withawesome-gamedev-agent-skills

<img src="docs/assets/awesome-gamedev-agent-skills-banner.png" width="820" alt="awesome-gamedev-agent-skills — game-dev skills for AI coding agents.

Stars on GitHub

Momentum

stars in total
8.6k
Star history of gamedev-skills/awesome-gamedev-agent-skills on GitHub, a chart by star-history.com

More skills from other plugins.

brainstorming
Auto-invoked

brainstorming

You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or…

@obra@obraInstall Skill
ask-matt

ask-matt

Ask which skill or flow fits your situation. A router over the skills in this repo.

skills13d309k
@mattpocock@mattpocockInstall Skill
accessibility
Auto-invoked

accessibility

Design, implement, and audit accessible UI to WCAG 2.2 Level AA across Web, iOS, and Android — semantic ARIA roles and labels,…

ecc2w293k
@affaan-m@affaan-mInstall Skill
compiler-commit

compiler-commit

Use when you want to verify compiler changes and commit with the correct convention. Runs tests, lint, and format, then commits…

react4d252k
@facebook@facebookInstall Skill

Questions about roblox-datastores.

Which coding agents can use roblox-datastores?
roblox-datastores installs in Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and Windsurf with the skills CLI, using each agent's flag shown on this page. Flowy's auto-invocation, which fires a skill when your prompt matches, runs in Claude Code only.