---
name: roblox-player-data
description: Use when saving or loading player data in Roblox - coins, inventory, progress, settings - with ProfileStore (StartSessionAsync, profile.Data, Reconcile, EndSession, OnSessionEnd, MessageAsync) or KashTheKing's DataServiceTyped (ProfileStore plus replicated Values). Covers session locking, shutdown saving, server authority over data, and the mistakes that lose or duplicate items.
---

# Saving player data

Never save player data with raw `DataStore:SetAsync` on join/leave. Without a **session lock**, two servers can hold
the same player's data at once (teleports, fast rejoins) and the last write wins: rolled-back progress and duplicated
items. Use **ProfileStore** (loleris), directly or through **DataServiceTyped**.

## The rules

1. **Only the server loads, changes and saves data.** The client gets a read-only copy and sends requests.
2. **One session per player.** A profile is owned by one server at a time; everything else waits or kicks.
3. **Data is JSON.** Numbers, strings, booleans, and tables of those. No Instances, Vector3, CFrame or Color3: store
   ids, names or plain numbers. A key holds at most 4,194,304 characters.
4. **The store name is part of the data.** Renaming it starts every player from the template.

## ProfileStore

```toml
[server-dependencies]
ProfileStore = "lm-loleris/profilestore@1.0.3" # server realm, so it goes under server-dependencies
```

Without Wally: get the Creator Store model (asset 109379033046155) and put the `ProfileStore` ModuleScript in
`ServerScriptService`.

```luau
-- ServerScriptService/PlayerData.luau
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")
local ProfileStore = require(ServerScriptService.ServerPackages.ProfileStore) -- wherever ServerPackages maps

local TEMPLATE = {
	Coins = 0,
	Inventory = {} :: { string },
}

local PlayerStore = ProfileStore.New("PlayerData", TEMPLATE)
local profiles: { [Player]: typeof(PlayerStore:StartSessionAsync()) } = {}

local PlayerData = {}

local function onPlayerAdded(player: Player)
	local profile = PlayerStore:StartSessionAsync(`{player.UserId}`, {
		Cancel = function()
			return player.Parent ~= Players -- stop retrying if they left
		end,
	})

	if profile == nil then
		player:Kick("Data failed to load - please rejoin")
		return
	end

	profile:AddUserId(player.UserId) -- links the key to the user for GDPR erasure
	profile:Reconcile() -- adds keys added to TEMPLATE since this profile was made

	profile.OnSessionEnd:Connect(function()
		profiles[player] = nil
		player:Kick("Profile session ended - please rejoin")
	end)

	if player.Parent == Players then
		profiles[player] = profile
	else
		profile:EndSession() -- left while loading
	end
end

function PlayerData.get(player: Player): typeof(TEMPLATE)?
	local profile = profiles[player]
	return if profile and profile:IsActive() then profile.Data else nil
end

function PlayerData.start()
	for _, player in Players:GetPlayers() do
		task.spawn(onPlayerAdded, player)
	end
	Players.PlayerAdded:Connect(onPlayerAdded)
	Players.PlayerRemoving:Connect(function(player)
		local profile = profiles[player]
		if profile then
			profile:EndSession() -- final save and release the lock
		end
	end)
end

return PlayerData
```

Then change data on the server only: `local data = PlayerData.get(player); if data then data.Coins += 10 end`.

### API you will use

| Member | What it does |
|---|---|
| `ProfileStore.New(name, template?)` | the store; template is copied into new profiles |
| `store:StartSessionAsync(key, { Cancel = fn, Steal = bool }?)` | yields, returns a Profile or nil |
| `store:GetAsync(key)` | read-only snapshot, no session, no auto-save |
| `store:MessageAsync(key, message)` | queue a table for a profile, online or not |
| `store.Mock` | a throwaway store with the same API, for tests |
| `profile.Data` | the table that saves (while `profile:IsActive()`) |
| `profile:Reconcile()`, `profile:AddUserId(id)` | fill template keys, GDPR link |
| `profile:Save()` | immediate save (one UpdateAsync); use after paid rewards |
| `profile:EndSession()` | final save and release |
| `profile.OnSessionEnd` | fires when the session ends for any reason, including another server taking it |
| `profile:MessageHandler(fn(message, processed))` | messages stay queued until `processed()` is called |
| `ProfileStore.DataStoreState` | `"NotReady"`, `"NoInternet"`, `"NoAccess"` or `"Access"` |

Gifts or admin grants to players who may be offline or on another server:

```luau
PlayerStore:MessageAsync(tostring(recipientUserId), { Kind = "Gift", Coins = 100 })

-- in onPlayerAdded, after the session starts:
profile:MessageHandler(function(message, processed)
	if message.Kind == "Gift" then
		profile.Data.Coins += message.Coins
		processed()
	end
end)
```

### Session locking and shutdown (defaults in the 1.0.3 source)

- Auto-save runs every 300 seconds, spread across profiles, plus a final save on `EndSession`.
- If another server starts a session for the same key, the owner is asked to save and release (its `OnSessionEnd`
  fires). If it doesn't answer, the new server takes over after about 40 seconds; a profile with no updates for
  630 seconds is treated as belonging to a crashed server.
- `StartSessionAsync` keeps retrying for up to 120 seconds unless `Cancel` returns true.
- ProfileStore binds its own `game:BindToClose`: it ends every active session and waits for the saves. You don't need
  your own BindToClose for saving. Roblox gives BindToClose callbacks 30 seconds in total.
- In Studio it saves only when the place is published and **Enable Studio Access to API Services** is on; otherwise it
  prints that data will not be saved and runs in mock mode.

## DataServiceTyped (KashTheKing's module)

ProfileStore underneath, plus a typed **Value** tree that replicates the owner's data to their client. Not on Wally;
it's shared as a model file (`DataServiceTyped.rbxm`, which bundles its own ProfileStore). Put it in ReplicatedStorage
and make one shared module that both sides require. `DataService` is its older version; use DataServiceTyped.

```luau
-- ReplicatedStorage/Shared/PlayerData.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataServiceTyped = require(ReplicatedStorage.Vendor.DataServiceTyped)

return DataServiceTyped({
	template = {
		Coins = 0,
		Inventory = {} :: { string },
		Settings = { Music = true },
	},
	profileStoreIndex = "PlayerData", -- the ProfileStore name; keys are "PLAYER_" .. UserId
})
```

```luau
-- Server
local PlayerData = require(ReplicatedStorage.Shared.PlayerData)

local data = PlayerData.server.Service:waitForData(player) -- or PlayerData.server[player] (nil until loaded)
print(data.Coins()) -- read: call with no arguments
data.Coins(data.Coins() + 10) -- write: saves AND replicates to that player
data.Coins(function(coins) -- or update from the current value
	return coins + 10
end)
data.Inventory.Insert("Sword") -- arrays: Insert(item, pos?), Remove(pos?)
data.Settings.Music(false)

local disconnect = data.Coins.Changed(function(newValue, oldValue)
	print("coins", oldValue, "->", newValue)
end)
```

```luau
-- Client: requiring the module YIELDS until this player's data arrives
local PlayerData = require(ReplicatedStorage.Shared.PlayerData)
local data = PlayerData.client

coinsLabel.Text = tostring(data.Coins())
data.Coins.Changed(function(coins)
	coinsLabel.Text = tostring(coins)
end)
```

- Require it on the client inside a start function, not at the top of a module your bootstrap requires, or the whole
  client waits for data. Outside a running game (UI stories) the client value is the template.
- Client writes change only that client's copy. The module gives clients no remote access, so real changes go through
  your own validated remote.
- `PlayerData.server.Service` also has `getProfile(player)`, `addPlayerRemovingCallback(fn(player, data))`,
  `sendGlobalMessage(key, userId, data)` with `addGlobalCallback(key, fn(player, data))` (MessageAsync underneath),
  `resetData(player)` (wipes and kicks), and an overridable `onPlayerInit(player, data)`.
- Testing options: `useMock`, `dontSave`, `resetData`, `viewedUserId`, `overridenUserId`, `profileStoreDataPrefix`.
  Never ship with `resetData` or `useMock` on.

## Mistakes to avoid

- Saving with `SetAsync`/`UpdateAsync` from PlayerAdded/PlayerRemoving instead of a session-locked profile.
- A remote that lets the client say "set my coins". The client asks for an action; the server checks price, cooldown
  and ownership, then writes (see `roblox-server-authority`).
- DataServiceTyped: mutating the raw table from `data.Inventory()` (it saves, but nothing replicates or fires
  `Changed`). Always write through the Value.
- Writing to `profile.Data` after `EndSession` or `OnSessionEnd`. Those changes are never saved.
- Storing Instances, Vector3, CFrame or other non-JSON values.
- Renaming the store (or `profileStoreIndex`) on a live game, or switching between raw ProfileStore and
  DataServiceTyped without a migration: the keys differ and everyone looks new.
- Requiring DataServiceTyped (or calling your data module's `start`) from the Command Bar or a Studio MCP
  `execute_luau` call during a playtest. That runs a separate copy of the module, which starts a second session for
  the same player and ends the real one (the player gets kicked).
- `Steal = true` outside debugging: it skips the lock and can duplicate items.
- Granting a paid product without `profile:Save()` and a stored `PurchaseId` (see `roblox-purchase-safety`).
