Solid reference for Roblox DataStoreService that covers the full lifecycle: loading with GetAsync, saving with SetAsync vs UpdateAsync for atomic operations, and crucially, the BindToClose pattern so you don't lose data on server shutdown. It walks through retry logic, ordered datastores for leaderboards, and data migration strategies with versioned schemas. The common mistakes table is the most useful part, calling out things like rate limit traps from over-saving and why you should kick players on load failure instead of silently giving them default data. If you're building any kind of persistent player data in Roblox, this hits all the patterns you actually need.
npx -y skills add sentinelcore/roblox-skills --skill roblox-datastores --agent claude-codeInstalls into .claude/skills of the current project.
Reference for Roblox DataStoreService — saving, loading, and managing player data on the server.
| Method | Signature | Notes |
|---|---|---|
GetDataStore | DSS:GetDataStore(name, scope?) | Returns a GlobalDataStore |
GetOrderedDataStore | DSS:GetOrderedDataStore(name, scope?) | For leaderboards |
GetAsync | store:GetAsync(key) | Returns value or nil |
SetAsync | store:SetAsync(key, value) | No return value needed |
UpdateAsync | store:UpdateAsync(key, fn) | Atomic read-modify-write |
RemoveAsync | store:RemoveAsync(key) | Deletes key, returns old value |
GetSortedAsync | orderedStore:GetSortedAsync(asc, pageSize) | Returns DataStorePages |
-- Server Script (ServerScriptService)
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local playerStore = DataStoreService:GetDataStore("PlayerData_v1")
local DEFAULT_DATA = {
coins = 0,
level = 1,
xp = 0,
}
Always wrap datastore calls in pcall. They can fail due to network issues or rate limits.
local function loadData(player)
local key = "player_" .. player.UserId
local success, data = pcall(function()
return playerStore:GetAsync(key)
end)
if success then
local result = {}
for k, v in pairs(DEFAULT_DATA) do result[k] = v end
if data then
for k, v in pairs(data) do result[k] = v end
end
return result
else
warn("Failed to load data for", player.Name, ":", data)
return nil -- signal failure; do not give default data silently
end
end
Use SetAsync for simple overwrites. Use UpdateAsync when the value must be based on the current stored value (e.g., incrementing a counter safely across servers).
-- Simple save
local function saveData(player, data)
local key = "player_" .. player.UserId
local success, err = pcall(function()
playerStore:SetAsync(key, data)
end)
if not success then
warn("Failed to save data for", player.Name, ":", err)
end
end
-- Atomic increment with UpdateAsync
local function addCoinsAtomic(userId, amount)
local key = "player_" .. userId
pcall(function()
playerStore:UpdateAsync(key, function(current)
current = current or { coins = 0 }
current.coins = current.coins + amount
return current
end)
end)
end
local MAX_RETRIES = 3
local RETRY_DELAY = 2
local function safeGet(store, key)
for attempt = 1, MAX_RETRIES do
local success, result = pcall(function()
return store:GetAsync(key)
end)
if success then return true, result end
warn(string.format("GetAsync attempt %d/%d failed: %s", attempt, MAX_RETRIES, result))
if attempt < MAX_RETRIES then task.wait(RETRY_DELAY) end
end
return false, nil
end
Server shutdown without BindToClose silently discards unsaved data.
local sessionData = {} -- [userId] = data table
Players.PlayerAdded:Connect(function(player)
local data = loadData(player)
if data then
sessionData[player.UserId] = data
else
player:Kick("Could not load your data. Please rejoin.")
end
end)
Players.PlayerRemoving:Connect(function(player)
local data = sessionData[player.UserId]
if data then
saveData(player, data)
sessionData[player.UserId] = nil
end
end)
-- Flush all sessions on server shutdown
game:BindToClose(function()
for userId, data in pairs(sessionData) do
local key = "player_" .. userId
pcall(function()
playerStore:SetAsync(key, data)
end)
end
end)
Values must be positive integers.
local coinsLeaderboard = DataStoreService:GetOrderedDataStore("Coins_v1")
local function setLeaderboardScore(userId, coins)
pcall(function()
coinsLeaderboard:SetAsync("player_" .. userId, math.floor(coins))
end)
end
local function getTopPlayers(count)
local success, pages = pcall(function()
return coinsLeaderboard:GetSortedAsync(false, count) -- false = descending
end)
if not success then return {} end
local results = {}
for rank, entry in ipairs(pages:GetCurrentPage()) do
table.insert(results, { rank = rank, userId = entry.key, score = entry.value })
end
return results
end
Include a _version field and migrate in the load path.
local CURRENT_VERSION = 2
local function migrateData(data)
local version = data._version or 1
if version < 2 then
data.coins = data.gold or 0 -- renamed field
data.gold = nil
data._version = 2
end
return data
end
Use a versioned datastore name (PlayerData_v2) for breaking schema changes.
| Mistake | Consequence | Fix |
|---|---|---|
No pcall around datastore calls | Unhandled error crashes the script | Always wrap in pcall |
Saving on every Changed event | Hits rate limits (60 + numPlayers×10 writes/min) | Throttle; save on remove + periodic interval |
No BindToClose handler | Data lost on server shutdown | Always flush all sessions in BindToClose |
| Giving default data on load failure | Player silently loses progress | Return nil on failure; kick or retry |
SetAsync for atomic counters | Race condition across servers | Use UpdateAsync for read-modify-write |
| Storing Instances or functions | Data silently drops | Store only strings, numbers, booleans, plain tables |
| Reusing datastore name after schema change | Old shape clashes with new code | Append _v2, _v3 to name on breaking changes |
juliusbrussee/caveman
mattpocock/skills
shadcn/improve
obra/superpowers
forrestchang/andrej-karpathy-skills
vercel-labs/skills