Skip to main content
Version: 0.4.0.0

🧠 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:

  1. ProfileStore:StartSessionAsync() is called with a key based on the player's UserId.
Example Key
-- The Settings.SUFFIX is a string you can define in the data settings
`{player.UserId}{Settings.SUFFIX}`
-- Example: 1234567890_DEVELOPMENT_01
  1. A profile is created and reconciled with Settings.TEMPLATE.
  2. A Replica is created and tagged to the player.
  3. The player's data is wrapped in a PlayerProfile object.
  4. That object is stored in a dictionary (_profiles) and accessible via Data:GetProfileAsync(player).
Data.Components.Settings.luau
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

MethodDescription
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

MethodDescription
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

danger

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.

Data.Components.DevProductsHandler.luau
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

🧠 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 "Shutdown" 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 pass incorrect reasons when shutting down properly.


Next: Networking with SimpleNet →