π§ Data & PlayerProfile
Shard includes a robust player data system using ProfileStore for persistence and Replica for real-time client-side synchronization.
Since data and datastore management is a core part of any game, Data
is stored separate from the services folder. This allows for easy access and management of player data across all systems.
But for consistency, we will still refer to it as a service.
πΎ How It Worksβ
On player join:
ProfileStore:StartSessionAsync()
is called with a key based on the player'sUserId
.
Example Key
-- The Settings.SUFFIX is a string you can define in the data settings
`{player.UserId}{Settings.SUFFIX}`
-- Example: 1234567890_DEVELOPMENT_01
- A profile is created and reconciled with
Settings.TEMPLATE
. - A
Replica
is created and tagged to the player. - The player's data is wrapped in a
PlayerProfile
object. - That object is stored in a dictionary (
_profiles
) and accessible viaData:GetProfileAsync(player)
.
return {
DEBUG_MOCK = true,
SUFFIX = "_DEVELOPMENT_01a",
TEMPLATE = {
-- Example:
Cash = 0,
Stats = {
Level = 1,
Experience = 0,
},
},
SESSION_END_MESSAGE = `Profile session ended. Please rejoin the game to continue playing.`,
LOAD_FAILED_MESSAGE = `You've been kicked from the game. We had an issue loading your data. Please try again.`,
MESSAGE = `You've been kicked from the game. We had an issue loading your data. Please try again.`,
}
Data Service Raw Code
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Libraries = ReplicatedStorage.Libraries
local Packages = ReplicatedStorage.Packages
local PlayerProfile = require(script.Components.PlayerProfile)
local ProfileStore = require(Libraries.ProfileStore)
local Replica = require(Libraries.Replica.ReplicaServer)
local observePlayer = require(Packages.Observers.observePlayer)
local DevProducts = require(script.Components.DevProductsHandler)
local Settings = require(script.Components.Settings)
local Types = require(script.Components.Types)
local Data = {}
Data.__index = Data
local DataSingleton = setmetatable({}, Data)
function DataSingleton:initialize()
self._playerToken = Replica.Token("PlayerData")
self._playerStore = ProfileStore.New("PlayerStore", Settings.TEMPLATE)
if RunService:IsStudio() and Settings.DEBUG_MOCK then
print("[ProfileStore]: Using mock profile store")
self._playerStore = self._playerStore.Mock
end
self._profiles = {} :: { [Player]: typeof(self._playerStore:StartSessionAsync()) }
self:_observePlayers()
MarketplaceService.ProcessReceipt = function(receiptInfo)
return DevProducts:ProcessReceipt(receiptInfo, self)
end
end
function DataSingleton:GetProfileAsync(player: Player): Types.PlayerProfile?
while not self._profiles[player] and player.Parent == Players do
task.wait()
end
return self._profiles[player]
end
function DataSingleton:_playerAdded(player: Player)
local profile = nil
while player.Parent == Players and not ProfileStore.IsClosing do
profile = self._playerStore:StartSessionAsync(`{player.UserId}{Settings.SUFFIX}`, {
Cancel = function()
return player.Parent ~= Players
end,
})
if profile then
break
end
end
if not profile then
player:Kick(Settings.LOAD_FAILED_MESSAGE)
return
end
profile:AddUserId(player.UserId)
profile:Reconcile()
profile.OnSessionEnd:Connect(function()
self._profiles[player] = nil
player:Kick(Settings.SESSION_END_MESSAGE)
end)
if player.Parent ~= Players then
profile:EndSession()
end
local replica = Replica.New({
Token = self._playerToken,
Data = profile.Data,
Tags = { Player = player },
})
replica:Replicate()
self._profiles[player] = PlayerProfile.new(player, profile, replica)
end
function DataSingleton:GetPlayerStore()
return self._playerStore
end
function DataSingleton:GetProfileStore()
return self._playerStore
end
function DataSingleton:GetPlayerKey(player: Player): string
return `{player.UserId}{Settings.SUFFIX}`
end
function DataSingleton:GetKeyFromUserId(userId: number): string
return `{userId}{Settings.SUFFIX}`
end
function DataSingleton:_observePlayers()
observePlayer(function(player)
self:_playerAdded(player)
return function()
local profile = self._profiles[player] and self._profiles[player]:GetProfile()
if profile then
-- We do RunService.Heartbeat:Wait() to allow BindToClose to fire, when a shutdown happens Roblox kicks all players then does BindsToClose
-- This correctly identifies the shutdown reason in the .OnLastSave method of ProfileStore.
-- (I.e. you want to detect if a dev/owner shutdown the server in order to save their boosts or something like that)
RunService.Heartbeat:Wait()
if profile:IsActive() then
profile:EndSession()
end
end
end
end)
end
return DataSingleton
π¦ Data Service (Overview)β
The Data
service manages profile lifecycle, shutdown cleanup, dev product handling, and safe replication.
Key responsibilities:
- Session creation with
StartSessionAsync
- Reconciliation against a default
TEMPLATE
- Profile cleanup on leave/shutdown
- Replica setup and
Replicate()
call - DevProduct receipts via
MarketplaceService.ProcessReceipt
This service is a singleton pattern with an
initialize()
lifecycle.
π Methodsβ
Method | Description |
---|---|
initialize() | Initializes the data service, this is done automatically |
GetProfileAsync() | Gets the profile data asynchronously, this yields. |
GetPlayerStore() | Returns the initialized ProfileStore, named PlayerStore |
GetProfileStore() | Returns the same as GetPlayerStore() but is an alias to avoid possible confussion |
GetPlayerKey(player: Player) | Returns the profile key for a given Player |
GetKeyFromUserId(userId: string) | Returns the profile key for a given UserId |
GetPlayer() | Returns the associated player object |
π§ Accessing Player Dataβ
local ServerScriptService = game:GetService("ServerScriptService")
local Players = game:GetService("Players")
local Data = require(ServerScriptService.Data)
Players.PlayerAdded:Connect(function(player)
local profile = Data:GetProfileAsync(player)
-- Always check if profile exists
-- (sometimes a player can leave before the profile is created)
if not profile then return end
local cash = profile:Get("Cash")
print("Player joined with cash:", cash)
end)
π§ PlayerProfileβ
PlayerProfile
is a wrapper object that simplifies profile interaction.
π Methodsβ
Method | Description |
---|---|
Get(key) | Gets a value by key, supports nested keys |
Set(key, value) | Sets a value and replicates it via Replica |
Increment(key, amount) | Increments a numeric field (Replicates) |
Decrement(key, amount) | Decrements a numeric field (Replicates) |
IsActive() | Returns whether the profile is still valid |
GetProfile() | Returns the raw ProfileStore object |
GetPlayer() | Returns the associated player object |
You can define you own methods in the
PlayerProfile
class if needed. Such as:
function PlayerProfile:addCash(amount: number)
if not self:IsActive() then return end
local retrieved = self:Get("Cash")
local newAmount = retrieved + amount
if newAmount < 0 then
newAmount = 0
end
self:Set("Cash", newAmount)
end
Player Profile Raw Code
local PlayerProfile = {}
PlayerProfile.__index = PlayerProfile
function PlayerProfile.new(player, profile, replica)
local self = setmetatable({}, PlayerProfile)
self._player = player
self._profile = profile
self._replica = replica
self._profile:MessageHandler(function(message, processed)
--[[```lua
-- This is where you handle global updates, like gifting:
if message[1] == "Gift" then
local sender = message[2]
local giftType = message[3]
print(`gift received from {sender} of type {giftType}`)
end
processed()
--]]
end)
return self
end
-- Define custom player profile functions here:
--[[```lua
-- Example:
function PlayerProfile:addCash(amount: number)
if not self:IsActive() then return end -- Check if the profile is still active
local retrieved = self:Get("Cash")
local newAmount = retrieved + amount
if newAmount < 0 then
newAmount = 0
end
self:Set("Cash", newAmount)
end
```]]
-- INTERNAL API
function PlayerProfile:Get(keyToGet: string, tableToSearch: {})
tableToSearch = tableToSearch or self._profile.Data
for key, value in tableToSearch do
if key == keyToGet then
return value
end
if typeof(value) == "table" then
local result = self:Get(keyToGet, value)
if result then
return result
end
end
end
return nil
end
function PlayerProfile:Set(keyToSet: string, valueToSet: any, tableToSearch: {}, path: { string })
tableToSearch = tableToSearch or self._profile.Data
path = path or {}
for key, currentValue in tableToSearch do
local newPath = { unpack(path) }
table.insert(newPath, key)
if key == keyToSet then
-- tableToSearch[key] = valueToSet
self._replica:Set(newPath, valueToSet)
return true
end
if typeof(currentValue) == "table" then
local result = self:Set(keyToSet, valueToSet, currentValue, newPath)
if result then
return true
end
end
end
return false
end
function PlayerProfile:GetPlayer()
return self._player
end
function PlayerProfile:GetProfile()
return self._profile
end
function PlayerProfile:IsActive()
return self._profile:IsActive()
end
function PlayerProfile:Increment(currency: string, amount: number)
local retrieved = self:Get(currency)
local newAmount = retrieved + amount
if newAmount < 0 then
newAmount = 0
end
self:Set(currency, newAmount)
end
function PlayerProfile:Decrement(currency: string, amount: number)
local retrieved = self:Get(currency)
local newAmount = retrieved - amount
if newAmount < 0 then
newAmount = 0
end
self:Set(currency, newAmount)
end
return PlayerProfile
π§ Example Usageβ
local profile = Data:GetProfileAsync(player)
if not profile then return end
-- Add money
profile:Increment("Cash", 100)
-- Remove a life
profile:Decrement("Lives", 1)
-- Get nested data
local badgeLevel = profile:Get("Stats").BadgeLevel
-- Set data
profile:Set("MembershipTier", "Gold")
π¬ Global Messagingβ
ProfileStore messages allow for cross-server communication. You can use this to send things like gifts, shared boosts, etc.
Inside PlayerProfile, in the .new()
constructor you will see a MessageHandler
method that allows you to listen for messages.
Receiving Messagesβ
self._profile:MessageHandler(function(message, processed)
if message[1] == "Gift" then
local sender = message[2]
local type = message[3]
print(`{sender} sent a {type} gift!`)
end
processed()
end)
Sending Messagesβ
This uses an UpdateAsync operation so use it for important messages like paid gifts. If you are looking for something like a chat system, or if it's not cruicial messages, use MessagingService
Messages are received even if the player was offline and delivered on next login.
local ServerScriptService = game:GetService("ServerScriptService")
local Data = require(ServerScriptService.Data)
local PlayerStore = Data:GetPlayerStore()
local senderPlayer = game.Players.Player1 -- For example
local userIdToSendTo = 1234567890 -- The userId of the player you want to send a message to
local profileKey = Data:GetKeyFromUserId(userIdToSendTo)
local message = {
"Gift", -- Message type
senderPlayer.Name, -- Sender
"Coins" -- Gift type
}
PlayerStore:MessageAsync(profileKey, message) --> is_success [bool]
π¦ Developer Productsβ
The Data service sets up the MarketplaceService.ProcessReceipt
callback to handle developer product purchases.
This is a global callback and will be called for every developer product purchase in the game.
If you you set MarketplaceService.ProcessReceipt
callback multiple times in your game, they will override each other.
The DevProductsHandler
module allows you to define your developer products and their corresponding functions.
This module handles the receipt processing and product granting. It uses ProfileStore's safe cache handling of developer products.
ProductFunctions[1234566] = function(profile: Types.PlayerProfile, data: Types.Data)
-- Example for a cash pack:
profile:Increment("Cash", 100000)
local priceCheckSuccess, priceInRobux = pcall(function()
return MarketplaceService:GetProductInfo(1234566, Enum.InfoType.Product).PriceInRobux
end)
if priceCheckSuccess then
profile:Increment("RobuxSpent", priceInRobux)
end
end
Dev Products Raw Code
local Players = game:GetService("Players")
local Types = require(script.Parent.Types)
local PURCHASE_ID_CACHE_SIZE = 100
local DevProductsHandler = {}
local ProductFunctions = {}
-- Define Developer Product Rewards
ProductFunctions[1234566] = function(profile: Types.PlayerProfile, data: Types.Data)
-- Example for a cash pack:
-- profile:Increment("Cash", 100000)
-- local priceCheckSuccess, priceInRobux = pcall(function()
-- return MarketplaceService:GetProductInfo(1234566, Enum.InfoType.Product).PriceInRobux
-- end)
-- if priceCheckSuccess then
-- profile:Increment("RobuxSpent", priceInRobux)
-- end
end
-- Internal:
function DevProductsHandler:ProcessReceipt(receiptInfo, data: Types.Data)
local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
local profile = data:GetProfileAsync(player)
if profile then
return self:PurchaseIdCheckAsync(profile:GetProfile(), receiptInfo.PurchaseId, function()
if ProductFunctions[receiptInfo.ProductId] then
ProductFunctions[receiptInfo.ProductId](profile, data)
else
warn("No product function defined for ProductId:", receiptInfo.ProductId)
end
end)
end
return Enum.ProductPurchaseDecision.NotProcessedYet
end
function DevProductsHandler:PurchaseIdCheckAsync(profile, purchase_id, grant_product): Enum.ProductPurchaseDecision
if profile:IsActive() == true then
local purchase_id_cache = profile.Data.PurchaseIdCache
if purchase_id_cache == nil then
purchase_id_cache = {}
profile.Data.PurchaseIdCache = purchase_id_cache
end
if table.find(purchase_id_cache, purchase_id) == nil then
local success, result = pcall(grant_product)
if success ~= true then
warn(`Failed to process receipt:`, profile.Key, purchase_id, result)
return Enum.ProductPurchaseDecision.NotProcessedYet
end
while #purchase_id_cache >= PURCHASE_ID_CACHE_SIZE do
table.remove(purchase_id_cache, 1)
end
table.insert(purchase_id_cache, purchase_id)
end
local function is_purchase_saved()
local saved_cache = profile.LastSavedData.PurchaseIdCache
return if saved_cache ~= nil then table.find(saved_cache, purchase_id) ~= nil else false
end
if is_purchase_saved() == true then
return Enum.ProductPurchaseDecision.PurchaseGranted
end
while profile:IsActive() == true do
local last_saved_data = profile.LastSavedData
profile:Save()
if profile.LastSavedData == last_saved_data then
profile.OnAfterSave:Wait()
end
if is_purchase_saved() == true then
return Enum.ProductPurchaseDecision.PurchaseGranted
end
if profile:IsActive() == true then
task.wait(10)
end
end
end
return Enum.ProductPurchaseDecision.NotProcessedYet
end
return DevProductsHandler
Hereβs a clean way to integrate the explanation of your RunService.Heartbeat:Wait()
quirk at the end of the Data & PlayerProfile section. This explanation adds clarity on why itβs necessary without disrupting the flow of documentation:
π§ Why We Use RunService.Heartbeat:Wait()
β
In the observePlayer
teardown function of the Data Service, you'll notice this line:
RunService.Heartbeat:Wait()
This isn't just a random wait. It's a subtle quirk that fixes a common issue with Profile.OnLastSave
, where the shutdown reason is incorrectly labeled as "Manual"
instead of "RobloxShutdown"
during proper game shutdown sequences.
Profile.OnLastSave:Connect(function(reason: "Manual" | "External" | "Shutdown")
print(`Profile.Data is about to be saved to the DataStore for the last time; Reason: {reason}`)
end)
This happens because:
- Roblox kicks players before
BindToClose
is fired. - If you end the session too early (right when
PlayerRemoving
fires), the profile system thinks the player left voluntarily.
By waiting one frame (RunService.Heartbeat:Wait()
), we ensure:
BindToClose
has a chance to trigger.- ProfileStore detects the correct context and logs the shutdown as a proper Roblox server shutdown.
- This can be especially important if you handle things like offline boost refunds, last-minute saves, or developer-initiated shutdowns.
Without this wait,
profile.OnLastSave(reason)
may return"Manual"
or"NotRoblox"
even when shutting down properly.