---
url: /projects/data-system.md
description: Persist per-player data with caching, autosave, and versioned shapes.
---

# Player Data System

*Written: {{ $frontmatter.date }}*

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](../intermediate/state-and-data.md), [server authority](../roblox-fundamentals/server-authority.md), [validated shop protocol](client-server-protocol.md), [errors and defensive code](../luau-basics/errors-and-defensive-code.md), and [task and async](../roblox-fundamentals/task-and-async.md).

## Specification

You store per player:

* `coins` as a number starting at 0.
* `inventory` as a string array starting empty.
* `version` as 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](https://create.roblox.com/docs/en-us/cloud-services/data-stores) and [data store best practices](https://create.roblox.com/docs/en-us/cloud-services/data-stores/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.

1. Enable Studio Access to API Services in a test place.
2. Create `Shared/DataSchema` under `ReplicatedStorage`.
3. Create `ServerScriptService/Data/DataStoreWrapper` as a ModuleScript.
4. Create `ServerScriptService/Data/DataService` as a Script.
5. 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 Wrapper
```

Prose 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 DataService
```

Prose 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](https://create.roblox.com/docs/en-us/cloud-services/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](https://github.com/MadStudioRoblox/ProfileStore) and [API docs](https://madstudioroblox.github.io/ProfileStore/api/). 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

1. Enable Studio Access to API Services in a test place.
2. Press Play with 1 player.
3. Call `grantCoins` and `grantItem` from the server command bar with small values.
4. Confirm no error appears.
5. Wait 65 seconds and confirm autosave runs with no error.
6. Leave Play to trigger leave save.
7. Press Play again with the same test account in a Local Server.
8. Confirm coins and inventory match the prior session.
9. 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

1. Add `getCoins` and `getInventory` read functions that return copies.
2. Add per-player dirty flags so autosave skips clean players.
3. Add a wipe function gated to Studio only that clears one test key.
4. Connect [validated shop protocol](client-server-protocol.md) buys to `grantCoins` with negative amounts validated on the server.
5. Sketch version 3 with a `pets` array and a migrate path from 2.

::: details 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 DataReads
```

Expected behavior: readers see current values, mutating a returned inventory never leaks into the cache, and missing players read zero and empty.
:::

::: details 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 DirtyFlags
```

Expected behavior: touched players save on the next tick and clear, while clean players never appear in the drain list.
:::

::: details 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 WipeKey
```

Expected behavior: Studio clears test keys, while live servers and short keys always return false.
:::

::: details 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 ShopGrant
```

Expected behavior: positive grants credit, affordable spends debit, while NaN, strings, and overspends change nothing.
:::

::: details 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](capstones.md).
