Data and saves
Save bugs are invisible in Studio, because Studio playtests usually run memory-only. These are the rules that came out of a save-layer review of a live game; every one prevents silent progress loss.
8 min read
Save bugs are invisible in Studio, because Studio playtests usually run memory-only. These are the rules that came out of a save-layer review of a live game; every one prevents silent progress loss. A generic module implementing them is in tools/SessionStore.luau, with a plain-Luau test in tools/test_sessionstore.luau.
- Shape of a save
- Session lock with UpdateAsync and a lease
- Errors: transient is not fatal
- A failed load never starts a fresh game
- Autosave and backoff
- BindToClose waits for in-flight saves
- Studio memory-only mode
- Resets and prestige
- Developer product receipts
- Testing with a mock DataStore
- Checklist
Shape of a save
- One key per player (
p_<UserId>), one table, aversionnumber, and amigrate(old)that fills missing fields from defaults. Bump the version for renames; never delete a field in place. - Keep runtime-only fields (
session,lock, timers in flight) out of the game state, and strip them inserialise. - Sanitise on save: walk the copy and replace NaN/inf with 0, warning with the path. One NaN in
cashmakes every comparison false and soft-locks shops; some non-finite values make the write fail. - Validate client-sent tables by exact shape (array length, integer ranges, string lengths) before
storing them.
HttpService:JSONEncodesucceeding is not proof DataStore can store it.
Session lock with UpdateAsync and a lease
Without a lock: a player leaves server A mid-save, rejoins on server B within seconds, B loads the old save, A writes the new one, then B's autosave overwrites it. The last battle, purchase or conquest is silently rolled back.
The lock lives inside the same key as the data, so it is atomic with it:
-- load: an UpdateAsync that takes the lock
store:UpdateAsync(key, function(old)
if old and old.lock and old.lock.id ~= SESSION and os.time() - old.lock.t < LEASE then
state = "locked"; return nil -- another live server owns it: wait and retry
end
if old then old.lock = { id = SESSION, t = os.time() } end
return old
end)
SESSION= a GUID per server (game.JobIdis empty in Studio).- Lease (e.g. 180 s): every save refreshes
lock.t. A lock older than the lease belongs to a dead server and may be taken over. - Wait up to ~30 s for another server's lock (that is its leave save finishing), retrying every few seconds while the client shows "Loading your save...", then steal it with a warning.
- Every save checks the lock first. If another server now owns it, refuse the write, mark the session
noSave, andKickwith "Your progress was updated on another server. Please rejoin." - The leave save clears the lock, so a rejoin loads at once.
- Do not write a lock-only table for a brand-new player. A table with only
lockin it loads next time as "an existing save with nothing in it" and skips first-time setup.
Errors: transient is not fatal
The ordinary Roblox outage error is 502: API Services rejected request with error. HTTP 500. One game
treated any error containing "API" as "DataStore access is off" and flipped the whole server to
memory-only, forever. One 502 during a join wiped everyone's session.
- Only Studio's "API access is off" error means memory-only:
RunService:IsStudio()and the message contains403(orStudioAccessToApisNotAllowed). - Everything else is retried with backoff (e.g. 5 load attempts: 1, 2, 4, 8 s; 3 write attempts).
- Never cache "the store is broken" server-wide on a live server.
A failed load never starts a fresh game
If load fails after all retries, a "fresh game, don't save" fallback looks to the player like "my game got wiped", and then un-wiped on the next join, with that session's play lost.
- Kick with a friendly message ("We couldn't load your save. Your progress is safe, please rejoin in a minute!") or show a retry card with a Rejoin button. Never drop into the tutorial.
- If you do run a
noSavesession for any reason, tell the client, and make sure every code path that rebuilds the state (prestige, debug reset) carriesnoSaveover. One game's prestige copied a list of fields but notnoSave, so a throwaway session started saving and overwrote the real save.
Autosave and backoff
- Autosave every 60 s plus on big moments (conquest, tier-up, purchase), debounced to ~10 s.
- Set the next-save time before the attempt, not only on success. Otherwise a failing save is
retried every loop tick (one game: every 5 s, 3
UpdateAsynccalls each, ~36 writes a minute for one stuck player, which starves every other player's saves). - Back off failed saves: 15 s, doubling to a 5 min cap. Reset on success.
- Serialise saves per player (
saving[player] = true) and take the snapshot after acquiring that flag, so a waiting save never writes an older snapshot.
BindToClose waits for in-flight saves
When the last player leaves, PlayerRemoving fires and starts its save, then the server starts closing.
Players:GetPlayers() is already empty, so a naive BindToClose returns at once and Roblox kills the
server with the save still in flight. Most sessions end exactly this way.
local leaving = 0
Players.PlayerRemoving:Connect(function(player)
leaving += 1
pcall(runLeaveHooks, player) -- finish battles etc. first, capped in time
pcall(saveFinal, player) -- clears the session lock
leaving -= 1
end)
game:BindToClose(function()
for _, p in Players:GetPlayers() do task.spawn(pcall, saveFinal, p) end
local t0 = os.clock()
while (leaving > 0 or next(saving)) and os.clock() - t0 < 28 do task.wait(0.1) end
end)
- The close budget is 30 s total. Cap any leave-time work that runs before the save (resolving a running battle, refunding queues) at a few seconds; one game spent up to 20 s there.
Studio memory-only mode
- In Studio, read a workspace attribute (e.g.
UseDataStore) and default to memory-only so playtests never write real saves. Print one clear line when that happens. - Memory-only must still behave like the real thing (same lock, same receipt bookkeeping), or the real paths are never exercised. Test those with the mock harness instead.
- Because every playtest is memory-only, session-lock, outage and shutdown bugs never show in Studio. Review the save layer by reading it, and test it with a mock.
Resets and prestige
A reset builds a fresh state from defaults and copies over what survives. Bugs here are permanent.
- Keep an explicit carry list, in one function used by every reset path: purchases/receipts,
entitlements,
noSave, settings, lifetime stats, account-level unlocks. - A reset that drops
purchaseseither revokes paid items or makes a "receipts must not shrink" guard block every later save. - Account-level timers (daily rewards, redeemed codes) belong in the carry list too; one game's prestige silently re-armed every daily reward and code.
- Optional: write the pre-reset save to a one-slot backup key so an accidental tap can be undone.
Developer product receipts
- Exactly one
MarketplaceService.ProcessReceiptper server. A second assignment silently replaces the first and its products retry forever. OneProductsservice owns it; others callProducts.register(productId, handler). Registering id 0 is a no-op, so unconfigured products stay inert. - Idempotent by
PurchaseId. Store every grantedPurchaseIdin the save (keep them all; Roblox can retry much later). If the id is already there, returnPurchaseGrantedwithout granting again. - Return
PurchaseGrantedonly after the save with that receipt has landed. Grant, record the id, save durably (anUpdateAsyncthat merges receipts), then return. If the save fails, roll back and returnNotProcessedYet. - Player away: if
Players:GetPlayerByUserId(info.PlayerId)is nil (they left, or are on another server), returnNotProcessedYet. Roblox retries the receipt when they next join a server, where the same handler grants it. Never grant into a save you do not hold the lock for. - Wait (up to ~15 s) for the player's data to finish loading before handling a receipt that arrives during join.
PromptProductPurchaseFinishedis a UI hint only; it fires before the receipt is processed. Show "Pending" on the button, never grant from it.- Studio test purchases go through
ProcessReceiptlike real ones. If memory-only mode refuses receipts because it "can't save durably", test purchases grant nothing; let memory-only grant directly (still idempotent byPurchaseId). - Paid random items (crates, loot boxes): check
PolicyService:GetPolicyInfoForPlayerAsync(p) .ArePaidRandomItemsRestrictedand refuse on the server, not only in the UI. If the lookup fails, treat the player as restricted and retry later.
Testing with a mock DataStore
Plain luau can run the save module if you inject the Roblox globals. The pattern
(tools/mock_datastore.luau):
- a store whose
UpdateAsyncdeep-copies in and out (real DataStores never share tables) and can be told to throw a queue of errors ("502: API Services rejected request"); - a fake clock:
task.wait(n)advancesos.clock()/os.time()instantly and runs anonWaithook, so a 30 s lock wait takes no real time and "the other server releases the lock after 3 waits" is one line; - fake players with a
Kickthat records the message.
Cases worth a test each: new player; lock held then released; lock never released (steal after the
wait); transient 502 retried; persistent failure kicks with no state; another server takes over (save
refused, noSave, kick); Studio 403 goes memory-only; receipt granted once for a repeated PurchaseId.
Checklist
- Session lock in the same key, refreshed on save, cleared on leave, with a lease and a wait.
- Only Studio 403 means memory-only; live errors retry with backoff.
- A failed load kicks with a message; no silent fresh game.
- Next-save time set before the attempt; exponential backoff to 5 min.
- BindToClose waits for leave saves and running saves, under 28 s.
- One carry list for every reset; it includes purchases and
noSave. - One ProcessReceipt owner; idempotent by PurchaseId; grant only after the durable save.
- Save sanitises NaN and validates client tables.
- A mock-DataStore test covers lock, 502, failure, takeover, receipts.