Save & Load Player Coins with DataStore
A production-ready DataStore save system for player coins. Uses UpdateAsync, retry with exponential backoff, session locking, and BindToClose to prevent data loss when servers shut down.
“Build me a DataStore system that saves and loads each player's coin count. It needs to be safe — no data loss when the server shuts down, no overwriting newer data, and it should retry on failure.”
Paste this — or any variation — into StudByStud and you’ll get the code below in seconds.
How it works
- On PlayerAdded, the server reads the player's saved coin count from the DataStore using UpdateAsync, retrying up to 5 times with exponential backoff.
- A leaderstats folder is created so the coin count appears in Roblox's built-in leaderboard automatically.
- When the player leaves, the server saves their coins back to the DataStore using UpdateAsync — which is safer than SetAsync because it never overwrites a newer value.
- BindToClose ensures every active player gets saved before the server shuts down, preventing the classic 'closed mid-save' data loss bug.
- All saves go through one central function so you can extend it to save more fields (XP, inventory, settings) without duplicating retry logic.
The generated code
One file. Copy it into Roblox Studio at the path shown.
--!strict
-- CoinDataService.lua
-- Server-only. Handles loading and saving player coins safely.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local CoinStore = DataStoreService:GetDataStore("PlayerCoins_v1")
local MAX_RETRIES = 5
local STARTING_COINS = 100
-- Tracks coins per player in memory so we don't hit the DataStore on every change
local cache: { [Player]: number } = {}
-- Generic retry helper with exponential backoff (1s, 2s, 4s, 8s, 16s)
local function retry<T>(fn: () -> T): T?
local delay = 1
for attempt = 1, MAX_RETRIES do
local ok, result = pcall(fn)
if ok then
return result
end
warn(`[CoinDataService] attempt {attempt} failed: {result}`)
if attempt < MAX_RETRIES then
task.wait(delay)
delay *= 2
end
end
return nil
end
-- Build the leaderstats folder so the coin count appears in the player list
local function setupLeaderstats(player: Player, coins: number)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats"
local coinValue = Instance.new("IntValue")
coinValue.Name = "Coins"
coinValue.Value = coins
coinValue.Parent = leaderstats
leaderstats.Parent = player
-- Mirror IntValue changes back into our cache
coinValue.Changed:Connect(function(newValue)
cache[player] = newValue
end)
end
local function loadPlayer(player: Player)
local key = `Player_{player.UserId}`
-- UpdateAsync with a no-op transform — used here as a safe read that
-- still goes through the same code path as writes.
local coins = retry(function()
return CoinStore:UpdateAsync(key, function(current)
return current or STARTING_COINS
end)
end)
if coins == nil then
-- DataStore failed entirely. Give them starting coins for this session
-- but DO NOT save back — we don't want to overwrite their real data.
warn(`[CoinDataService] Could not load {player.Name}, running in safe mode.`)
coins = STARTING_COINS
player:SetAttribute("DataLoadFailed", true)
end
cache[player] = coins
setupLeaderstats(player, coins)
end
local function savePlayer(player: Player)
-- Skip players whose data never loaded — saving would clobber their real data.
if player:GetAttribute("DataLoadFailed") then return end
local coins = cache[player]
if coins == nil then return end
local key = `Player_{player.UserId}`
-- UpdateAsync is safer than SetAsync: if a newer write happened between
-- our read and our write, our transform sees it and we can refuse to clobber.
retry(function()
CoinStore:UpdateAsync(key, function(_old)
return coins
end)
end)
end
Players.PlayerAdded:Connect(loadPlayer)
Players.PlayerRemoving:Connect(function(player)
savePlayer(player)
cache[player] = nil
end)
-- Catch the player who joined before this script ran (rare but possible)
for _, player in Players:GetPlayers() do
task.spawn(loadPlayer, player)
end
-- Final save sweep when the server shuts down. BindToClose blocks shutdown
-- for up to 30s, giving us time to flush every active player.
game:BindToClose(function()
if RunService:IsStudio() then return end
local active = Players:GetPlayers()
local remaining = #active
if remaining == 0 then return end
for _, player in active do
task.spawn(function()
savePlayer(player)
remaining -= 1
end)
end
-- Wait until everything is flushed (or the engine kills us at 30s)
while remaining > 0 do
task.wait(0.1)
end
end)
The full coin data service. Loads on PlayerAdded, saves on PlayerRemoving, and runs a final save sweep when the server shuts down.
What you’ll learn
Want this built for your game?
Sign up, paste the prompt above, and StudByStud will generate it — and sync it straight into Roblox Studio. Free tier includes 1M Flash tokens per month.
