IntermediateDataStorePersistenceServerAnti-loss

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.

1 file138 lines of Luau
The Prompt

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.

File 1 of 1Script
ServerScriptService/CoinDataService.lualuau
--!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

DataStoreService:GetDataStore()UpdateAsync vs SetAsyncExponential backoff retriesSession locking via :BindToClose()Player.PlayerAdded / Players.PlayerRemovingleaderstats folder for built-in leaderboard

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.

Build it free