§ How to · with Picoo

How to use Roblox DataStore the right way (with AI)

By Sametcan Tasgiran, Founder & Developer·Published ·Updated

The usual ways a Roblox game loses progress are a DataStore call with no pcall around it, a failed save that is never retried, a server that shuts down before the last save, and a game that only saves when the player leaves. Ask Picoo to save player data and it applies its datastore_save recipe: one server Script that covers all four for a leaderstats value, Coins by default.

1 file · 155 lines · one recipe call. Saves one number per player; name the stat, its starting value and the autosave interval in the prompt.

pcall and retry on every call

GetAsync and SetAsync both run through a safe() helper: up to 3 attempts, waiting 2 seconds after the first failure and 4 after the second. If all 3 fail, the script logs a warning instead of erroring. Opening the DataStore runs in pcall too: GetDataStore itself errors in a place that was never published, and the script keeps running instead of stopping before anyone joins.

Value in leaderstats

On join the script creates leaderstats with a Coins IntValue set to the saved number, or to the default of 0 for a new player or when the load fails, so the value shows in the player list.

Saves only what changed

A change listener on the IntValue copies every change into a cache and marks the player as changed. A save writes only when the value changed since the last successful save, and never for a player whose load failed, so a failed load cannot replace their stored value with the default. A change made while a save is still being written stays marked, so the next save writes it.

60-second autosave

A background loop saves every changed player every 60 seconds, so progress is written during play and not only when the player leaves. The interval is configurable, with a minimum of 10 seconds.

Save on leave

PlayerRemoving saves the player, then clears their cache entry. If an autosave for that player is still being written, the leave save waits for it, at most 10 seconds, and then writes the newest value, so an older value can never land last.

BindToClose on shutdown

When the server closes, the script starts a save for every player still in the game and waits until every save, leave saves included, has finished, for at most 25 seconds, inside Roblox's 30-second limit.

Files Picoo ships for this prompt

1 file · 155 lines · one recipe call

ServerScriptService/PlayerDataServer.lua

Server Script: loads the value on join and shows it in leaderstats, autosaves changed values every 60 seconds with retry, and saves on leave and on shutdown.

155 lines

Sample output: ServerScriptService/PlayerDataServer.lua

--!strict
-- Generated by Picoo recipe: datastore_save
-- Persists Coins per player with pcall + retry + BindToClose

local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local RunService = game:GetService("RunService")

-- GetDataStore itself errors in a place that was never published, and can in
-- Studio without API access. Called bare up here, that one error stopped this
-- whole script before anyone joined. Now a failure only leaves the store nil:
-- every load fails at once, so a Studio play test runs on in memory and saves
-- nothing, and a save (only ever after a load that succeeded) always has it.
local storeOk, storeResult = pcall(function()
	return DataStoreService:GetDataStore("PlayerData_v1")
end)
local STORE: DataStore? = if storeOk then storeResult else nil
local STORE_ERROR: string? = if storeOk then nil else tostring(storeResult)
local DEFAULT_VALUE = 0
local AUTOSAVE_INTERVAL = 60

-- Retry helper: 3 attempts with exponential backoff. pcall wraps every
-- DataStore call so transient network failures don't crash the server.
local function safe<T>(fn: (...any) -> T, ...): (T?, string?)
	local args = {...}
	for attempt = 1, 3 do
		local ok, result: any = pcall(fn, table.unpack(args))
		if ok then return result, nil end
		if attempt < 3 then task.wait(2 ^ attempt) end
		if attempt == 3 then return nil, tostring(result) end
	end
	return nil, "unreachable"
end

-- (excerpt) the save state, saving a player, and the shutdown handler:

-- In-memory cache so reads don't hit DataStore on every change
local cache: { [number]: number } = {}
local dirty: { [number]: boolean } = {}
-- true ONLY after a load that succeeded. A failed load is never saved over:
-- saving the default would replace the player's real data with it.
local loaded: { [number]: boolean } = {}
-- One write per player at a time: two writes in flight can land in either
-- order, so the retry of an older value could land last and put it back.
-- saving[userId] is the ticket of the write in flight; activeSaves counts the
-- saves still waiting or writing, so BindToClose can wait for all of them.
local saving: { [number]: number } = {}
local nextTicket = 0
local activeSaves = 0

local function savePlayer(player: Player)
	if not loaded[player.UserId] then return end -- never overwrite after a failed load
	activeSaves += 1
	-- A write already in flight for this player: wait for it (at most 10 s),
	-- then write whatever is newest.
	local waited = 0
	while saving[player.UserId] and waited < 10 do
		waited += task.wait(0.1)
	end
	local key = "u_" .. player.UserId
	local value = cache[player.UserId]
	if dirty[player.UserId] and value ~= nil then
		nextTicket += 1
		local ticket = nextTicket
		saving[player.UserId] = ticket
		local overtaken = false
		local _, err = safe(function(): string?
			-- A newer write took over while this one waited to retry (it stopped
			-- waiting after 10 s): stop here, or this older value would land last.
			if saving[player.UserId] ~= ticket then
				overtaken = true
				return nil
			end
			return (STORE :: DataStore):SetAsync(key, value)
		end)
		-- Released after a failure too, unless a newer write already owns it.
		if saving[player.UserId] == ticket then saving[player.UserId] = nil end
		if err then
			warn("[DataStore] save failed for " .. player.Name .. ": " .. err)
		elseif not overtaken and cache[player.UserId] == value then
			-- Only if nothing changed while SetAsync yielded: a newer value stays
			-- dirty, so the next autosave, the leave save or the shutdown save writes it.
			dirty[player.UserId] = false
		end
	end
	activeSaves -= 1
end

-- BindToClose runs on server shutdown so no in-flight progress is lost
game:BindToClose(function()
	for _, player in ipairs(Players:GetPlayers()) do
		task.spawn(savePlayer, player)
	end
	-- Stay until every save has finished, leave saves included, but never
	-- past Roblox's 30-second limit for shutdown.
	local waited = 0
	repeat
		waited += task.wait(0.25)
	until activeSaves == 0 or waited >= 25
end)

How to use Roblox DataStore the right way

DataStore is the code players never notice until it fails. A save that errors once and is never retried, or a server that shuts down before the last write, and a player's progress is gone. Picoo builds the save flow from a fixed recipe, datastore_save, so the same four protections are in place every time.

First, every DataStore call is wrapped in pcall and retried. DataStore calls are web requests and they can fail: each server has a per-minute request budget that grows with its player count, and writes to the same key are limited to one every 6 seconds. The recipe's safe() helper makes up to 3 attempts, waiting 2 seconds and then 4; if the last one fails, the load or save logs a warning, so a failed call never stops the script. If the load on join is what fails, that player is not saved for the rest of the session, so the default value never overwrites their stored data. Even GetDataStore, which errors in a place that was never published, runs inside pcall, so in that place the value still works in a Studio play test instead of the whole script stopping.

Second, the value is saved while the player is still playing. The script keeps each player's number in a cache and marks it as changed whenever the leaderstats value changes. An autosave loop writes every changed player every 60 seconds, so a server crash does not wipe out everything since the player joined. Players whose value has not changed are skipped, which saves DataStore requests. A change that lands while a write is still in flight stays marked, so the next autosave, the leave save or the shutdown save writes it. Writes for one player never overlap: a save waits for the one in flight, at most 10 seconds, then writes the newest value, so the retry of an older value can never land after a newer one.

Third, PlayerRemoving saves the player on the way out. Fourth, BindToClose covers server shutdown: Roblox gives BindToClose functions up to 30 seconds, and the script starts a save for every player still in the server, then waits until every save has finished, for at most 25 seconds.

The recipe saves a single number per player, which suits coins, points or wins. Saving several stats or an inventory table, versioned save data, and a session lock in the style of ProfileService, which stops two servers overwriting each other on a fast rejoin, are follow-up prompts.

See more on the Luau generator, the AI Luau script writer, the 3D model generator and the game builder, read how Picoo compares with ChatGPT, or browse the full blog.

Frequently asked

Why does my game lose player progress?+

Usually one of four things: a DataStore call that errors with no pcall around it, a failed save that is never retried, a server shutdown before the last save, or a game that only saves on leave, so a crash takes everything since the player joined. The datastore_save recipe covers each one: pcall with up to 3 attempts, a save on leave, BindToClose on shutdown, and an autosave every 60 seconds.

What exactly does the recipe save?+

One number per player, stored under u_<UserId> in a DataStore named PlayerData_v1 and shown in leaderstats as an IntValue called Coins. You can name a different stat, starting value, DataStore name and autosave interval in the prompt. Saving several stats, a table or an inventory is a follow-up prompt.

What is session locking and do I need it?+

Session locking stops two servers from overwriting each other when a player leaves one server and joins another before the first save finishes. ProfileService-style locks write a session id on load and refuse to save when another session has claimed the key. This recipe saves with a plain SetAsync; ask for session locking or an UpdateAsync-based save in a follow-up prompt if your game needs it.

What if DataStore is down (Roblox outage)?+

The recipe wraps GetAsync in pcall with up to 3 attempts, 2 and then 4 seconds apart. If all three fail, the player loads with the default value, a warning is logged, and the script saves nothing for that player for the rest of the session, so the default never overwrites the stored record. Progress made in that session is not saved. Ask Picoo to kick the player on a failed load if you would rather they rejoin than play a session that will not be saved. In a Studio play test of an unpublished place (or with Studio API access off), where every load fails, the value still works, the Output says how to turn saving on, and nothing is saved.

Can I save more than 4 MB per player?+

DataStore caps a single key at 4 MB. For more, shard across multiple keys (cash → key A, inventory → key B, settings → key C). Picoo can generate the sharded pattern in a follow-up prompt.

Related Picoo prompts