Appearance
Player Data System
Written: 2026-10-14
You persist coins and inventory per player, guarding saves with cache and sessions, with autosave and save on leave.
Learning objectives
You wrap DataStores with pcall and cache, guard sessions per server, autosave on a timer, save on leave, and version data shapes.
Prerequisites
Read state and data, server authority, validated shop protocol, errors and defensive code, and task and async.
Specification
You store per player:
coinsas a number starting at 0.inventoryas a string array starting empty.versionas a number starting at 1.
Acceptance:
- Every DataStore call sits in pcall.
- Memory cache holds the live copy.
- Session guard drops late saves after leave.
- Autosave runs every 60 seconds.
- Leave save runs on
PlayerRemoving. - BindToClose flushes remaining saves on server shutdown (it does not run in Studio solo runs, so guard test-only paths with
RunService:IsStudio()). - Shape migrate handles version 1 to 2.
Studio needs API access on. Open File then Experience Settings then Security. Turn on Enable Studio Access to API Services. Use a test place, not your live game. DataStore access from the server is required. The client must never touch DataStores. See data stores and data store best practices.
Architecture
Keep two modules. Wrapper owns Get and Set with retries. Service owns players, cache, and timers. No other module touches DataStoreService. Other services ask DataService through functions. Cache is the fast truth during Play. DataStore is the durable truth between sessions. Session ids stop one server from writing after another owns the player.
Implementation
Follow these steps in order.
- Enable Studio Access to API Services in a test place.
- Create
Shared/DataSchemaunderReplicatedStorage. - Create
ServerScriptService/Data/DataStoreWrapperas a ModuleScript. - Create
ServerScriptService/Data/DataServiceas a Script. - Press Play, grant coins, leave, rejoin, confirm persistence.
Container: ReplicatedStorage/Shared/DataSchema as a ModuleScript. Behavior: you define version 1 shape and migrate to version 2. Expect old saves with no version to load as version 1 then upgrade.
luau
--!strict
export type PlayerData = {
version: number,
coins: number,
inventory: { string },
}
local DataSchema = {}
DataSchema.currentVersion = 2
function DataSchema.default(): PlayerData
return { version = 2, coins = 0, inventory = {} }
end
function DataSchema.migrate(raw: unknown): PlayerData
if typeof(raw) ~= "table" then
return DataSchema.default()
end
local data: any = raw
local coins: number = 0
if typeof(data.coins) == "number" then
coins = data.coins
end
if coins < 0 then
coins = 0
end
local inventory: { string } = {}
if typeof(data.inventory) == "table" then
for _, id in data.inventory :: { unknown } do
if typeof(id) == "string" then
table.insert(inventory, id :: string)
end
end
end
local version: number = 1
if typeof(data.version) == "number" then
version = data.version
end
if version < 2 then
version = 2
end
return { version = version, coins = math.floor(coins), inventory = inventory }
end
return table.freeze(DataSchema)Prose walkthrough: keep one shape in one place. Accept unknown load data. Coerce coins and inventory. Bump old versions forward. Freeze the module.
Container: ServerScriptService/Data/DataStoreWrapper as a ModuleScript. Behavior: you load and save one key with pcall and retry. Expect failure to return false instead of throwing.
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local store: DataStore = DataStoreService:GetDataStore("PlayerDataV1")
local Wrapper = {}
function Wrapper.load(key: string): (boolean, unknown)
local tries: number = 0
local last: unknown = nil
while tries < 3 do
tries += 1
local ok: boolean, value: unknown = pcall(function(): unknown
return store:GetAsync(key)
end)
if ok then
return true, value
end
last = value
task.wait(1)
end
return false, last
end
function Wrapper.save(key: string, data: { version: number, coins: number, inventory: { string } }): boolean
local tries: number = 0
while tries < 3 do
tries += 1
local ok: boolean = pcall(function(): ()
store:SetAsync(key, data)
end)
if ok then
return true
end
task.wait(1)
end
return false
end
return WrapperProse walkthrough: fetch the store once. Wrap Get and Set in pcall. Retry transient failures up to three times. Return status so the caller decides.
Container: ServerScriptService/Data/DataService as a ModuleScript that returns the service table, plus a bootstrap Script beside it that wires PlayerAdded, PlayerRemoving, and autosave. Behavior: you cache live data, you autosave, you save on leave. Expect rejoin to restore coins and inventory. Expect leave during save to skip stale writes. Only a ModuleScript can be required, so the Script holds no service table itself.
The bootstrap Script named DataServiceBootstrap sits next to the module. It requires the table and calls start, so wiring runs once while other Services can still require DataService:
luau
--!strict
local DataService = require(script.Parent:WaitForChild("DataService")) :: any
DataService.start()luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataSchema = require(ReplicatedStorage:WaitForChild("Shared"):WaitForChild("DataSchema"))
local Wrapper = require(script.Parent:WaitForChild("DataStoreWrapper"))
local cache: { [number]: any } = {}
local active: { [number]: boolean } = {}
local session: { [number]: number } = {}
local function keyFor(userId: number): string
return `Player_{userId}`
end
local function loadPlayer(player: Player): ()
active[player.UserId] = true
session[player.UserId] = math.floor(os.clock() * 1000) + player.UserId
local mySession: number = session[player.UserId] :: number
local ok: boolean, raw: unknown = Wrapper.load(keyFor(player.UserId))
if session[player.UserId] ~= mySession then
return
end
if not ok then
cache[player.UserId] = (DataSchema :: any).default()
return
end
if raw == nil then
cache[player.UserId] = (DataSchema :: any).default()
return
end
cache[player.UserId] = (DataSchema :: any).migrate(raw)
end
local function savePlayer(player: Player): boolean
local data: any = cache[player.UserId]
if data == nil then
return false
end
if not (active[player.UserId] :: boolean) then
return false
end
local mySession: number? = session[player.UserId]
local snapshot = { version = data.version, coins = data.coins, inventory = data.inventory }
local wrote: boolean = Wrapper.save(keyFor(player.UserId), snapshot)
if session[player.UserId] ~= mySession then
return false
end
return wrote
end
local function grantCoins(player: Player, amount: unknown): boolean
if typeof(amount) ~= "number" then
return false
end
local value: number = amount :: number
if value <= 0 or value > 1000 then
return false
end
local data: any = cache[player.UserId]
if data == nil then
return false
end
data.coins = math.floor(data.coins + value)
return true
end
local function grantItem(player: Player, itemId: unknown): boolean
if typeof(itemId) ~= "string" then
return false
end
local data: any = cache[player.UserId]
if data == nil then
return false
end
table.insert(data.inventory, itemId :: string)
return true
end
local DataService = {}
DataService.grantCoins = grantCoins
DataService.grantItem = grantItem
DataService.savePlayer = savePlayer
function DataService.start(): ()
Players.PlayerAdded:Connect(loadPlayer)
Players.PlayerRemoving:Connect(function(player: Player): ()
savePlayer(player)
active[player.UserId] = false
cache[player.UserId] = nil
session[player.UserId] = nil
end)
local RunService = game:GetService("RunService")
if RunService:IsStudio() == false then
game:BindToClose(function(): ()
for _, player in Players:GetPlayers() do
savePlayer(player)
end
end)
end
task.spawn(function(): ()
while true do
task.wait(60)
for _, player in Players:GetPlayers() do
if active[player.UserId] then
savePlayer(player)
end
end
end
end)
for _, existing in Players:GetPlayers() do
task.spawn(loadPlayer, existing)
end
end
return DataServiceProse walkthrough: load on join through the wrapper. Migrate unknown shapes. Snapshot before save so live edits stay safe. Check the session before and after the cloud call. Return the service table and start wiring from the bootstrap. Clear cache on leave. Autosave every 60 seconds. Reads and writes follow data stores with pcall handling.
What ProfileStore would own, conceptual only: one owner module for profiles, session locks across servers, and reconcile hooks, per the ProfileStore source and API docs. You already mirror the ownership idea here with one DataService and one wrapper. You do not copy ProfileStore call shapes from memory. You verify exact ProfileStore function names against its live docs before use.
Testing it
- Enable Studio Access to API Services in a test place.
- Press Play with 1 player.
- Call
grantCoinsandgrantItemfrom the server command bar with small values. - Confirm no error appears.
- Wait 65 seconds and confirm autosave runs with no error.
- Leave Play to trigger leave save.
- Press Play again with the same test account in a Local Server.
- Confirm coins and inventory match the prior session.
- Publish to a separate test place and repeat with API access on.
Common mistakes and failures
You call DataStores on the client. Keep all calls on the server. You skip pcall and a throttled call throws. Wrap every call. You save after leave and overwrite new data. Check session and active flags. You mutate cache during save. Snapshot first. You change shape without migrate and old saves break. Version and coerce. You test in your live game. Use a test place.
Exercises and extensions
- Add
getCoinsandgetInventoryread functions that return copies. - Add per-player dirty flags so autosave skips clean players.
- Add a wipe function gated to Studio only that clears one test key.
- Connect validated shop protocol buys to
grantCoinswith negative amounts validated on the server. - Sketch version 3 with a
petsarray and a migrate path from 2.
Solution 1
Return copies so callers can never mutate the cache by alias.
ModuleScript in ServerScriptService/Data named DataReads
luau
--!strict
export type PlayerData = {
version: number,
coins: number,
inventory: { string },
}
local DataReads = {}
local cache: { [number]: PlayerData } = {}
function DataReads.seed(userId: number, data: PlayerData): ()
cache[userId] = { version = data.version, coins = data.coins, inventory = table.clone(data.inventory) }
end
function DataReads.getCoins(userId: number): number
local row: PlayerData? = cache[userId]
if row == nil then
return 0
end
if not math.isfinite(row.coins) then
return 0
end
return math.floor(row.coins)
end
function DataReads.getInventory(userId: number): { string }
local row: PlayerData? = cache[userId]
if row == nil then
return {}
end
return table.clone(row.inventory)
end
return DataReadsExpected behavior: readers see current values, mutating a returned inventory never leaks into the cache, and missing players read zero and empty.
Solution 2
Mark writers dirty and let autosave skip everyone else.
ModuleScript in ServerScriptService/Data named DirtyFlags
luau
--!strict
local DirtyFlags = {}
local dirty: { [number]: boolean } = {}
function DirtyFlags.touch(userId: number): ()
dirty[userId] = true
end
function DirtyFlags.clean(userId: number): ()
dirty[userId] = nil
end
function DirtyFlags.drain(active: { number }): { number }
local owed: { number } = {}
for _, userId in active do
if dirty[userId] then
table.insert(owed, userId)
dirty[userId] = nil
end
end
return owed
end
return DirtyFlagsExpected behavior: touched players save on the next tick and clear, while clean players never appear in the drain list.
Solution 3
Gate wipes behind Studio so live data is unreachable.
Script in ServerScriptService/Data named WipeKey
luau
--!strict
local RunService = game:GetService("RunService")
local WipeKey = {}
local rows: { [string]: boolean } = {}
function WipeKey.clear(key: unknown): boolean
if not RunService:IsStudio() then
return false
end
if typeof(key) ~= "string" or #key < 4 then
return false
end
rows[key] = nil
return true
end
return WipeKeyExpected behavior: Studio clears test keys, while live servers and short keys always return false.
Solution 4
Accept negative deltas only as spends the balance covers.
Script in ServerScriptService/Data named ShopGrant
luau
--!strict
local ShopGrant = {}
local coins: { [number]: number } = {}
function ShopGrant.grant(userId: number, delta: unknown): boolean
if typeof(delta) ~= "number" then
return false
end
if not math.isfinite(delta) then
return false
end
local amount: number = math.floor(delta)
local owned: number = coins[userId] or 0
if amount < 0 and owned + amount < 0 then
return false
end
coins[userId] = owned + amount
return true
end
return ShopGrantExpected behavior: positive grants credit, affordable spends debit, while NaN, strings, and overspends change nothing.
Solution 5
Add pets at version 3 and upgrade 2s by filling the default.
ModuleScript in ReplicatedStorage/Shared named DataSchemaV3
luau
--!strict
export type PlayerDataV3 = {
version: number,
coins: number,
inventory: { string },
pets: { string },
}
local DataSchemaV3 = {}
function DataSchemaV3.migrate(raw: unknown): PlayerDataV3
local coins: number = 0
local inventory: { string } = {}
local pets: { string } = {}
if typeof(raw) == "table" then
local data: any = raw
if typeof(data.coins) == "number" and data.coins > 0 then
coins = math.floor(data.coins)
end
if typeof(data.inventory) == "table" then
for _, id in data.inventory :: { unknown } do
if typeof(id) == "string" then
table.insert(inventory, id :: string)
end
end
end
if typeof(data.pets) == "table" then
for _, id in data.pets :: { unknown } do
if typeof(id) == "string" then
table.insert(pets, id :: string)
end
end
end
end
return { version = 3, coins = coins, inventory = inventory, pets = pets }
end
return table.freeze(DataSchemaV3)Expected behavior: version 2 saves keep coins and inventory and gain an empty pets list, while corrupt saves load as fresh version 3 rows.
Summary
You cached live data and saved through pcall. Next try capstones.