ai-behavior-trees-util…
Engine-neutral behavior tree and utility AI code for NPCs in any language: Blackboard, reactive composites, decorators and…
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
Claude Code
$ npx -y skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores --agent claude-code
Codex
$ npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores -a codex -y
Cursor
$ npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores -a cursor -y
Gemini CLI
$ npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores -a gemini-cli -y
GitHub Copilot
$ npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores -a github-copilot -y
OpenCode
$ npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores -a opencode -y
Windsurf
$ npx skills add gamedev-skills/awesome-gamedev-agent-skills --skill roblox-datastores -a windsurf -y
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`).
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.
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 reasonlocal 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)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
endPlayers.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)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
endA 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.
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
endValues must be integers.
<img src="docs/assets/awesome-gamedev-agent-skills-banner.png" width="820" alt="awesome-gamedev-agent-skills — game-dev skills for AI coding agents.
Engine-neutral behavior tree and utility AI code for NPCs in any language: Blackboard, reactive composites, decorators and…
Engine-neutral game audio technique: bus layout, volume sliders in decibels, loudness, ducking music under dialogue, adaptive…
Builds and tunes game cameras, engine-neutral with Godot and Cinemachine 3 mappings: 2D follow with deadzone and look-ahead,…
Plans, generates or sources, and validates consistent game art: style guides, sprites, pixel art, tilesets, icons and textures,…
Builds engine-neutral branching dialogue with Ink, Yarn Spinner or a small JSON graph runner: lines, choices, conditions,…
Designs engine-neutral enemy and NPC AI: picks FSM, behavior tree, utility or GOAP, and builds perception, steering and A*…
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or…
Ask which skill or flow fits your situation. A router over the skills in this repo.