Disclaimer: This tutorial is being written on February 7th, 2025. As time goes on, it will become more and more likely that this tutorial becomes out of date somehow as the ProfileStore module is likely to change. Also, this tutorial does not cover every feature of ProfileStore, just a basic working implementation.
Hello, I am a software engineer with 5 years of experience in the tech industry (although I am new to Roblox and game development).
In this tutorial, I’m going to give a step by step guide to saving and retrieving player data using Roblox’s database. Specifically, we’re going to use an open source module called ProfileStore. This module does a lot of things for us. Visit the linked forum post to get more details about ProfileStore. This tutorial assumes a basic understanding of scripting. Also, be sure to enable HTTP requests in your roblox studio settings.
To add ProfileStore to your project, go to the Toolbox and search “ProfileStore” under the “Models” category. It should be the first result by Loleris.
Understanding the steps of the process
What do we want to accomplish?
1. Define our data model
We want a typed definition of our data model. This is useful because it will serve as documentation for all of the data we’re storing for our game. It will be easy for you to reference if you ever forget something. Defining a type also gives us the benefit of autocomplete, and if you have --!strict
mode on your script, it will give you type checking as well.
2. Load player data when the player joins the game
When a player joins our game, we need to load their previously saved data.
3. Save player data while player is playing
While the player is in our game, we need to save their data so that it persists across play sessions.
4. Save when player leaves or the server shuts down
We need to make sure to save the player data when they leave the game. We also need to make sure to handle the scenario where the Roblox server gets terminated for some reason.
Here is each step broken down:
Define our Data Model
We want a typed definition of our model. How do we accomplish this? Well, ProfileStore makes use of something called Profiles
. A Profile
object is what we use to read from and write data to the database. Luckily, the ProfileStore module comes with types, and we can build off of them to define our data model.
In the ProfileStore script, if you do control + f
(command + f
on mac), it will bring up a text input we can use to search for any terms in the script. Type the following: export type Profile
and press Enter. It should take you to the definition of Profile, which looks like this:
export type Profile<T> = {
Data: T & JSONAcceptable,
LastSavedData: T & JSONAcceptable,
...
...
Go ahead and remove JSONAcceptable
as a type from Data
and LastSavedData
. Why? When it has that type, Luau’s type checker gets confused, so let’s remove it. After you remove it, the type definition should look like this:
export type Profile<T> = {
Data: T,
LastSavedData: T,
...
...
Q: Why is it
Profile<T>
instead of justProfile
? Answer:<T>
is a notation that basically means we can pass any type we want into Profile. WhenData
andLastSavedData
are set to type T, we’re saying that their type is that of the passed-in type. You will see an example of this soon.
Great! Now we can define our data model. Personally, I did this in a file called PlayerDataTemplate. I named it this because one cool feature of ProfileStore is that Player data is initialized to the default values you specify in your data model. This applies to players who are joining your game for the very first time.. Other names such as DataModel, PlayerData, etc would also work.
Make sure to add --!strict
to the top of your file so that type checking is applied.
Here’s a starting version of PlayerDataTemplate.lua
--!strict
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Assumes a Folder named ModuleScripts is a child of ServerStorage,
-- a Folder named Dependencies is a child of ModuleScripts, and
-- a ModuleScript named ProfileStore is a child of Dependencies.
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
-- Defining our own type of profile by passing in our PlayerDataTemplate type
export type Profile = ProfileStore.Profile<PlayerDataTemplate>
-- Type definition for PlayerDataTemplate, we haven't defined anything yet
export type PlayerDataTemplate = {}
-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {}
return PlayerDataTemplate
How will we define our model? Well, it’s important to know that data within the Roblox database exist as Tables. Therefore, we will define our model as a table. In this example, I’m going to show how someone might store XP, Level, and Titles. Before we do that, I want to quickly discuss limitations.
Limitations of what can be stored
First, we should know that the only types of values we can store are table
, number
, string
, buffer
, boolean
, and nil
.
Finally, all of the tables we store must have either all string indices or all number indices. This means all keys of a given table must be of the same type. Here’s some code to show examples:
-- "hi" and "property" are both strings. No problem!
local goodTable1 = {
hi = "Hello",
property = "Value",
}
-- 1 and 2 are both numbers. No problem!
local goodTable2 = {
[1] = "Hello",
[2] = "Value",
}
-- 1 is a number but "property" is a string. Bad!
local badTable = {
[1] = "Hello",
property = "Value",
}
XP and Level
This is the easiest one. XP and Level are both going to be represented as a number
.
Our default template and type definition now looks like this:
export type PlayerDataTemplate = {
XP: number,
Level: number,
}
-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {
XP = 0,
Level = 1,
}
Titles
For Titles, we’re going to store two pieces of information. In a real game you may want more than this, but I’m sticking with two for the sake of simplicity.
The Title
type will store the name of the title and the time the player earned the title. Storing the time the player earned a title could be useful for something such as giving founding players a reward.
Let’s translate that into a type.
export type Title = {
Name: string,
-- Date the title was earned
EarnedAt: number,
}
Now our definitions look like this. For your convenience, I am pasting the full script below:
--!strict
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Assumes a Folder named ModuleScripts is a child of ServerStorage,
-- a Folder named Dependencies is a child of ModuleScripts, and
-- a ModuleScript names ProfileStore is a child of Dependencies.
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
-- Defining our own type of profile by passing in our PlayerDataTemplate type
export type Profile = ProfileStore.Profile<PlayerDataTemplate>
export type Title = {
Name: string,
-- Date the title was earned
EarnedAt: number,
}
-- Type definition for PlayerDataTemplate
export type PlayerDataTemplate = {
XP: number,
Level: number,
OwnedTitles: { Title },
}
-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {
XP = 0,
Level = 1,
OwnedTitles = {
{Name = "Noob", EarnedAt = tick()},
{Name = "Weakling", EarnedAt = tick()}
}
}
return PlayerDataTemplate
Now we have a basic definition for our data model. Let’s continue on to see how we can use this definition to load player data.
Loading Player Data When Player Joins
Now we want to load a player’s data when a player joins.
To do this we should understand some things:
- We’re doing this on the Server, so this code should run by a script parented to ServerScriptService.
- There’s an event called
PlayerAdded
that we can use to perform some task when the player joins the game.
I’m going to give the starting script with comments. This script should be a Script parented to ServerScriptService.
--!strict
-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))
-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)
-- This function will be executed when a player joins our game
local function OnPlayerAdded(player: Player)
-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
local profileKey = "Test_" .. tostring(player.UserId)
-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
local profile = PlayerProfileStore:StartSessionAsync(profileKey,
-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
{Steal = false, Cancel = function()
-- ProfileStore will periodically use this function to check if the session needs to be ended.
-- Useful for some edge cases.
return player.Parent ~= Players
end}
)
if not profile then
-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues.
-- We don't know what to do with the player if their data doesn't load, so let's kick them.
player:Kick("Failed to load your data. Please try rejoining after a short wait.")
return
end
profile:AddUserId(player.UserId) -- This is for GDPR Compliance
profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
print("Player level:", profile.Data.Level)
print("Player XP:", profile.Data.XP)
for _, title in pairs(profile.Data.OwnedTitles) do
print("Player owns title:", title.Name)
end
end
-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
for _, player in pairs(game.Players:GetPlayers()) do
task.spawn(OnPlayerAdded, player)
end
end
LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event
If you did everything correctly, you can now test the experience. The output should look like this when you run the server:
04:48:52.401 [ProfileStore]: Roblox API services available - data will be saved - Server - ProfileStore:2087
04:48:53.477 Player level: 1 - Server - TeachMain:40
04:48:53.477 Player XP: 0 - Server - TeachMain:41
04:48:53.477 Player owns title: Noob - Server - TeachMain:43
04:48:53.477 Player owns title: Weakling - Server - TeachMain:43
Saving Player Data
Now we can successfully load player data. How do we go about saving player data though?
First, we will need a way to reference a previously created profile. To do this, let’s create a Table which will be used as a Map from Player.UserId
to the player’s Profile
.
Initialize this table outside the scope of the OnPlayerAdded function:
local Profiles: {[number]: PlayerDataTemplate.Profile} = {}
Now we need to update Profiles
with the Player.UserId
and Profile
. To do this, when a profile is successfully created, we do this;
Profiles[player.UserId] = profile
Tying it all together in one script:
--!strict
-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))
-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)
local Profiles: {[number]: PlayerDataTemplate.Profile} = {}
-- This function will be executed when a player joins our game
local function OnPlayerAdded(player: Player)
-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
local profileKey = "Test_" .. tostring(player.UserId)
-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
local profile = PlayerProfileStore:StartSessionAsync(profileKey,
-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
{Steal = false, Cancel = function()
-- ProfileStore will periodically use this function to check if the session needs to be ended.
-- Useful for some edge cases.
return player.Parent ~= Players
end}
)
if not profile then
-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues.
-- We don't know what to do with the player if their data doesn't load, so let's kick them.
player:Kick("Failed to load your data. Please try rejoining after a short wait.")
return
end
Profiles[player.UserId] = profile
profile:AddUserId(player.UserId) -- This is for GDPR Compliance
profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
print("Player level:", profile.Data.Level)
print("Player XP:", profile.Data.XP)
for _, title in pairs(profile.Data.OwnedTitles) do
print("Player owns title:", title.Name)
end
end
-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
for _, player in pairs(game.Players:GetPlayers()) do
task.spawn(OnPlayerAdded, player)
end
end
LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event
Now we want to actually save the data. How do we do this? Well, we modify profile.Data
directly. ProfileStore
will periodically sync the data saved in memory inside of profile.Data
to the database. This autosaving feature decreases load on our servers and helps us avoid the rate limits for DataStores.
There’s also another thing we need to do. We need to tell ProfileStore to save the current profile.Data
to the database when the profile’s session is ending.
Another issue we have to deal with is memory leaks. Our current code adds an entry to Profiles
every time a player joins, but it never removes any entries. This could be an issue if our game is getting lots of visits.
Finally, we need to account for the scenario that the player leaves the game before the profile finished loading.
To do this, we will add the following code:
if player.Parent ~= Players then
-- The player left the game before we finished loading the profile
profile:EndSession()
end
profile.OnSessionEnd:Connect(function()
-- Free up space in Profiles. Without this, we could have a memory leak.
Profiles[player.UserId] = nil
player:Kick(`Profile session end - Please rejoin`)
end)
Putting it all together:
--!strict
-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))
-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)
local Profiles: {[number]: PlayerDataTemplate.Profile} = {}
-- This function will be executed when a player joins our game
local function OnPlayerAdded(player: Player)
-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
local profileKey = "Test_" .. tostring(player.UserId)
-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
local profile = PlayerProfileStore:StartSessionAsync(profileKey,
-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
{Steal = false, Cancel = function()
-- ProfileStore will periodically use this function to check if the session needs to be ended.
-- Useful for some edge cases.
return player.Parent ~= Players
end}
)
if not profile then
-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues.
-- We don't know what to do with the player if their data doesn't load, so let's kick them.
player:Kick("Failed to load your data. Please try rejoining after a short wait.")
return
end
if player.Parent ~= Players then
-- The player left the game before we finished loading the profile
profile:EndSession()
end
profile.OnSessionEnd:Connect(function()
-- Free up space in Profiles. Without this, we could have a memory leak.
Profiles[player.UserId] = nil
player:Kick(`Profile session end - Please rejoin`)
end)
Profiles[player.UserId] = profile
profile:AddUserId(player.UserId) -- This is for GDPR Compliance
profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
print("Player level:", profile.Data.Level)
print("Player XP:", profile.Data.XP)
for _, title in pairs(profile.Data.OwnedTitles) do
print("Player owns title:", title.Name)
end
end
-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
for _, player in pairs(game.Players:GetPlayers()) do
task.spawn(OnPlayerAdded, player)
end
end
LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event
Great! Now let’s test that this is working. Let’s add a simple function that gives the user 1 XP for each second they’re in the game. That code looks like this, add it after the call to profile:Reconcile()
:
task.spawn(function()
while true do
task.wait(1)
profile.Data.XP += 1
end
end)
Now you can start your game. Let it run for a few seconds, and then leave the game. Now, in order to test that the data saved properly, let’s rejoin the game so we can see the newly printed out value of XP. Depending on how long you let the XP counter run, you will see a different result for the value of XP. Here’s what the output looked like to me:
05:35:11.394 [ProfileStore]: Roblox API services available - data will be saved - Server - ProfileStore:2087
05:35:12.427 Player level: 1 - Server - TeachMain:54
05:35:12.427 Player XP: 20 - Server - TeachMain:55
05:35:12.427 Player owns title: Noob - Server - TeachMain:57
05:35:12.427 Player owns title: Weakling - Server - TeachMain:57
Now that we have the ability to save our data to the data store, there’s one final thing we need to do, and that’s handling when the player leaves or the server shuts down.
Handle Player Leaving or Server Shutdown
How will we handle this? Let’s break it down for each case:
- Player leaving: We can hook an event up to the
game.Players.PlayerRemoving
signal to perform some task when the player leaves. - Server shutdown:
ProfileStore
handles this for us! Experienced devs might notice I’m leaving outBindToClose
. This is because ProfileStore already handles BindToClose, so we don’t need to. This means the scenario where the server shuts down is already handled for us and we don’t need any custom logic.
Now let’s look at how we can save player data when they leave:
Save Player Data When They Leave
Let’s create an OnPlayerRemoving function. In it, we will simply call profile:EndSession
. This will trigger one final save to the database before ending the session.
local function OnPlayerRemoving(player: Player)
local profile = Profiles[player.UserId]
if profile then
profile:EndSession()
end
end
game.Players.PlayerRemoving:Connect(OnPlayerRemoving) -- Connect the function to the player removing event
Please see the final section for both of the complete scripts!
Final scripts
PlayerDataTemplate (ModuleScript)
--!strict
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
-- Assumes a Folder named ModuleScripts is a child of ServerStorage,
-- a Folder named Dependencies is a child of ModuleScripts, and
-- a ModuleScript names ProfileStore is a child of Dependencies.
local moduleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = moduleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
-- Defining our own type of profile by passing in our PlayerDataTemplate type
export type Profile = ProfileStore.Profile<PlayerDataTemplate>
export type Title = {
Name: string,
-- Date the title was earned
EarnedAt: number,
}
-- Type definition for PlayerDataTemplate
export type PlayerDataTemplate = {
XP: number,
Level: number,
OwnedTitles: { Title },
}
-- Default values of PlayerDataTemplate. Players that are brand new to your game
-- will start out with this data
local PlayerDataTemplate: PlayerDataTemplate = {
XP = 0,
Level = 1,
OwnedTitles = {
{Name = "Noob", EarnedAt = tick()},
{Name = "Weakling", EarnedAt = tick()}
}
}
return PlayerDataTemplate
Main (Server Script)
--!strict
-- Services
local ServerStorage = game:GetService("ServerStorage")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
-- ModuleScipts
local ModuleScripts = ServerStorage:WaitForChild("ModuleScripts")
local Dependencies = ModuleScripts:WaitForChild("Dependencies")
local ProfileStore = require(Dependencies:WaitForChild("ProfileStore"))
local PlayerDataTemplate = require(ModuleScripts:WaitForChild("PlayerDataTemplate"))
-- Name the ProfileStore appropriately, I chose "PlayerData" here
local PlayerProfileStore = ProfileStore.New("PlayerData", PlayerDataTemplate)
local Profiles: {[number]: PlayerDataTemplate.Profile} = {}
-- This function will be executed when a player joins our game
local function OnPlayerAdded(player: Player)
-- Use a unique key to store the player data. Any string concatenated with the player's UserId should work.
local profileKey = "Test_" .. tostring(player.UserId)
-- Fetch the profile from the DB with session locking which prevents issues like item dupes.
local profile = PlayerProfileStore:StartSessionAsync(profileKey,
-- Only use Steal = True for debugging purposes. Setting Steal to true would disable item dupe protection
{Steal = false, Cancel = function()
-- ProfileStore will periodically use this function to check if the session needs to be ended.
-- Useful for some edge cases.
return player.Parent ~= Players
end}
)
if not profile then
-- If the profile doesn't exist, it's likely because Roblox itself is experiencing issues.
-- We don't know what to do with the player if their data doesn't load, so let's kick them.
player:Kick("Failed to load your data. Please try rejoining after a short wait.")
return
end
if player.Parent ~= Players then
-- The player left the game before we finished loading the profile
profile:EndSession()
end
profile.OnSessionEnd:Connect(function()
-- Free up space in Profiles. Without this, we could have a memory leak.
Profiles[player.UserId] = nil
player:Kick(`Profile session end - Please rejoin`)
end)
Profiles[player.UserId] = profile
profile:AddUserId(player.UserId) -- This is for GDPR Compliance
profile:Reconcile() -- The reconcile function applies our default template values to new profiles.
print("Player level:", profile.Data.Level)
print("Player XP:", profile.Data.XP)
for _, title in pairs(profile.Data.OwnedTitles) do
print("Player owns title:", title.Name)
end
task.spawn(function()
while true do
task.wait(1)
profile.Data.XP += 1
end
end)
end
local function OnPlayerRemoving(player: Player)
local profile = Profiles[player.UserId]
if profile then
profile:EndSession()
end
end
-- Load profiles for players that got in the game before this server script finished running
local function LoadExistingPlayers()
for _, player in pairs(game.Players:GetPlayers()) do
task.spawn(OnPlayerAdded, player)
end
end
LoadExistingPlayers()
game.Players.PlayerAdded:Connect(OnPlayerAdded) -- Connect the function to the player added event
game.Players.PlayerRemoving:Connect(OnPlayerRemoving) -- Connect the function to the player removing event
Thanks for taking the time to read my tutorial. If you liked it, please consider leaving a like or a comment, as this tutorial took me several hours to make. Also, please let me know if you are interested in more tutorials. I am considering making one on how to integrate Profiles with a more complicated server component.