Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Persist player data in Roblox with DataStoreService: GetDataStore, GetAsync/ SetAsync/UpdateAsync/IncrementAsync wrapped in pcall, load-on-join and save-on-leave plus BindToClose, retries, and OrderedDataStore leaderboards. Use when saving or loading persistent data in a Roblox experience — when the user mentions DataStore, DataStoreService, GetAsync, SetAsync, UpdateAsync, save player data, or leaderboards. For general Luau scripting use roblox-luau.
.claude/skills/gamedev-skills-roblox-datastores/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 27% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 112% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 95% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 42% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 56% | 0% |
Persist data across sessions in Roblox with DataStoreService: loading on join, saving on leave and shutdown, safe updates, retries, and ordered stores for leaderboards. Server-side only.
leaderboards, or fix data loss, overwrites, and throttling.
DataStoreService, GetDataStore, GetAsync,SetAsync, UpdateAsync, or GetOrderedDataStore.
When not to use: general scripting, services, remotes, the client/server split → roblox-luau. High-frequency temporary state (matchmaking, per-round) → memory stores (a different service). Engine-agnostic persistence theory → save-systems.
Access to API Services (use a test place; Studio hits live data). DataStores work only from server Scripts, never LocalScripts.
DataStoreService:GetDataStore("Name");key per player is usually "Player_" .. player.UserId.
pcall. GetAsync/SetAsync/UpdateAsync are networkcalls that can fail; an unguarded failure errors the thread and risks data loss.
PlayerAdded, save on PlayerRemoving, and also BindToClose. Aleaving player and a shutting-down server both need a final save.
UpdateAsync for read-modify-write (multi-server safe) over SetAsync(blind overwrite). On a failed load, do not overwrite with defaults — abort the save so you don't wipe good data.
OrderedDataStore for ranked data (leaderboards) via GetSortedAsync.Test by joining, changing data, rejoining, and confirming it persisted.
lualocal 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 -- Load FAILED (network). Do not treat as a new player; flag so we never save -- over their real data with defaults. warn("Load failed for", player.Name, data) player:SetAttribute("DataLoaded", false) return end player:SetAttribute("DataLoaded", true) local profile = data or DEFAULT -- nil == genuinely new player applyToLeaderstats(player, profile) end)
lua-- UpdateAsync reads the latest value, then writes what the callback returns. -- The callback MUST NOT yield (no task.wait, no further Async calls inside it). local function savePlayer(player) if player:GetAttribute("DataLoaded") == false then return end -- never overwrite on a bad load local key = "Player_" .. player.UserId local newData = gatherDataFor(player) -- a plain table of serializable values local ok, err = pcall(function() store:UpdateAsync(key, function(old) -- merge/decide here; return nil to cancel the write return newData end) end) if not ok then warn("Save failed for", player.Name, err) end end
luaPlayers.PlayerRemoving:Connect(savePlayer) -- BindToClose runs when the server shuts down; save everyone still in. -- It has a limited time budget, so save in parallel and yield until done. game:BindToClose(function() local players = Players:GetPlayers() local remaining = #players if remaining == 0 then return end for _, player in players do task.spawn(function() savePlayer(player) remaining -= 1 end) end while remaining > 0 do task.wait() end end)
lualocal 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 -- 2s, 4s, ... backoff end return false end local ok, data = withRetry(function() return store:GetAsync(key) end)
lua-- IncrementAsync is a convenience for integer read-modify-write (still wrap it). local ok, newTotal = pcall(function() return store:IncrementAsync("Visits_" .. player.UserId, 1) end)
lualocal boards = DataStoreService:GetOrderedDataStore("Coins") -- Write a player's score (call when it changes, not every frame). pcall(function() boards:SetAsync("Player_" .. player.UserId, coins) end) -- Read the top 10, descending. local ok, pages = pcall(function() return boards:GetSortedAsync(false, 10) -- ascending=false → highest first end) if ok then for rank, entry in ipairs(pages:GetCurrentPage()) do print(rank, entry.key, entry.value) -- entry.value is the number end end
pcall Async calls; on a failedload, mark the session and refuse to save so defaults never overwrite real data.
SetAsync race between servers → two servers writing the same key can clobbereach other. Use UpdateAsync for read-modify-write so each write sees the latest.
UpdateAsync callback → the callback can't calltask.wait or other Async functions; compute the new value beforehand and return it.
BindToClose save → players in the server at shutdown lose unsaved progress;add game:BindToClose and wait for saves to finish within its budget.
save on every value change. Batch and save on a timer / on leave. GetAsync is cached briefly, so immediate re-reads may be stale.
strings, booleans, and tables with string/number keys. Instances, Vector3, CFrame, and functions do not — serialize them to plain tables first.
Enable Studio Access to API Services is on (and they don't work from a LocalScript).
DataStoreKeyInfo is nil for ordered stores → OrderedDataStore doesn'tsupport versioning/metadata; use a regular DataStore when you need those.
metadata with DataStoreSetOptions, ordered-store pagination (AdvanceToNextPageAsync), the key error codes and request limits, and Right-to-be-Forgotten compliance, read references/sessions-and-limits.md.
roblox-luau — services, instances, events, and the server/client model.save-systems — engine-agnostic serialization, slots, and migration.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 17,504 | 10,544 | -40% | 1 | 1 | 0% | 3,466 | 4,410 | +27% | 0 | 0 | — |
case-02 | fail→pass | 7,378 | 5,515 | -25% | 1 | 1 | 0% | 1,455 | 3,079 | +112% | 0 | 0 | — |
case-03 | pass→pass | 13,042 | 7,753 | -41% | 1 | 1 | 0% | 2,373 | 3,366 | +42% | 0 | 0 | — |
case-04 | pass→pass | 13,277 | 7,242 | -45% | 1 | 1 | 0% | 2,037 | 3,181 | +56% | 0 | 0 | — |
case-05 | pass→pass | 12,163 | 5,784 | -52% | 1 | 1 | 0% | 2,092 | 3,008 | +44% | 0 | 0 | — |
case-06 | pass→pass | 5,451 | 4,745 | -13% | 1 | 1 | 0% | 881 | 2,658 | +202% | 0 | 0 | — |
case-07 | pass→pass | 3,685 | 2,615 | -29% | 1 | 1 | 0% | 592 | 2,397 | +305% | 0 | 0 | — |
case-08 | fail→pass | 8,513 | 3,655 | -57% | 1 | 1 | 0% | 1,313 | 2,557 | +95% | 0 | 0 | — |
case-09 | pass→pass | 9,573 | 5,455 | -43% | 1 | 1 | 0% | 1,969 | 3,031 | +54% | 0 | 0 | — |
case-10 | pass→pass | 5,075 | 2,348 | -54% | 1 | 1 | 0% | 893 | 2,296 | +157% | 0 | 0 | — |
case-11 | pass→pass | 10,516 | 7,484 | -29% | 1 | 1 | 0% | 2,007 | 3,312 | +65% | 0 | 0 | — |
case-12 | pass→pass | 10,720 | 7,179 | -33% | 1 | 1 | 0% | 1,932 | 3,239 | +68% | 0 | 0 | — |
case-13 | fail→fail | 13,745 | 11,662 | -15% | 1 | 1 | 0% | 2,196 | 4,218 | +92% | 0 | 0 | — |
case-14 | pass→pass | 6,919 | 7,148 | +3% | 1 | 1 | 0% | 1,186 | 3,140 | +165% | 0 | 0 | — |
case-15 | pass→pass | 8,275 | 6,523 | -21% | 1 | 1 | 0% | 1,372 | 3,019 | +120% | 0 | 0 | — |
case-16 | pass→pass | 3,616 | 3,972 | +10% | 1 | 1 | 0% | 641 | 2,707 | +322% | 0 | 0 | — |
case-17 | pass→pass | 8,259 | 5,590 | -32% | 1 | 1 | 0% | 1,474 | 2,929 | +99% | 0 | 0 | — |
case-18 | pass→pass | 2,568 | 2,976 | +16% | 1 | 1 | 0% | 449 | 2,446 | +445% | 0 | 0 | — |
case-19 | pass→pass | 9,730 | 4,121 | -58% | 1 | 1 | 0% | 1,727 | 2,596 | +50% | 0 | 0 | — |
case-20 | pass→pass | 3,630 | 2,843 | -22% | 1 | 1 | 0% | 644 | 2,433 | +278% | 0 | 0 | — |
case-21 | pass→pass | 10,357 | 4,905 | -53% | 1 | 1 | 0% | 1,897 | 2,763 | +46% | 0 | 0 | — |
case-22 | pass→pass | 4,965 | 3,761 | -24% | 1 | 1 | 0% | 904 | 2,469 | +173% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +14 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.