A production-ready, plug-and-play Profile Service for Roblox.
Session locking Β· Auto-save Β· Corruption recovery Β· Middleware hooks Β· Zero setup
Installation Β· Quick Start Β· Full API Β· Examples Β· FAQ
CoreProfileX is a powerful, developer-friendly player data management framework for Roblox.
It handles all the hard parts β DataStore retries, session locking, data corruption, graceful shutdowns β so you can focus on building your game.
Think of it as your own ProfileService + DataStore2 combined, but cleaner, typed with Luau strict mode, and ready to drop into any game in under 5 minutes.
| Category | Features |
|---|---|
| π Safety | Session locking, checksum validation, backup DataStore recovery, corruption detection |
| πΎ Reliability | Auto-retry with exponential back-off, graceful BindToClose shutdown, versioned saves |
| β‘ Performance | Smart dirty-flag saving, caching, rate limiter, minimal DataStore calls |
| π§© Flexibility | Middleware hooks (BeforeSave, AfterLoad, BeforeRelease, OnError) |
| π― Developer UX | Clean API, Luau strict types, debug mode, full inline documentation |
| π‘ Reactive | Key-level listeners that fire on every change |
| π§ Extras | Deep reconciliation, data versioning, leaderstats sync, starter templates |
- Session Locking
- Versioned Saves
- Checksum Validation
- Automatic Backup Recovery
- Middleware Hooks
- AutoSave
- Rate Limiting
- Data Reconciliation
- Reactive Listeners
- Bulk Data Operations
CoreProfileX/
βββ ProfileService/
β βββ MainModule.lua β Core engine (require this)
β βββ Types.lua β All Luau type exports
β βββ Utils.lua β DeepCopy, Reconcile, Retry, RateLimiter
β βββ Signals.lua β Typed lightweight event emitter
β βββ Middleware.lua β Hook pipeline (BeforeSave / AfterLoadβ¦)
βββ ProfileLoader.server.lua β Bootstrap script (configure here)
βββ Examples/
βββ CoinsSystem.server.lua
βββ InventorySystem.server.lua
- Download
CoreProfileX.rbxmxfrom Releases - Open Roblox Studio
- In the Explorer, right-click on
ReplicatedStorageβ Insert from File - Select the
.rbxmxfile - The full folder structure will appear automatically
- Move
ProfileLoader.server.luaintoServerScriptService - β Done!
- Create a ModuleScript inside
ReplicatedStoragenamedProfileService - Paste the contents of
MainModule.luainto it - Inside that ModuleScript, create 4 more ModuleScripts:
Typesβ pasteTypes.luaUtilsβ pasteUtils.luaSignalsβ pasteSignals.luaMiddlewareβ pasteMiddleware.lua
- Create a Script in
ServerScriptServicenamedProfileLoader - Paste the contents of
ProfileLoader.server.luainto it - β Done!
If you use Rojo for a VS Code workflow:
your-game/
βββ src/
βββ ReplicatedStorage/
βββ ProfileService/ β drop this folder here
Then sync with Rojo as normal.
local ProfileService = require(game.ReplicatedStorage.ProfileService)
ProfileService:Configure({
DataStoreName = "PlayerData_v1", -- change the suffix to reset all data
Template = {
Coins = 0,
Gems = 0,
Level = 1,
XP = 0,
Inventory = {},
},
AutoSaveInterval = 60, -- auto-save every 60 seconds
DebugMode = false, -- set true while developing
})
ProfileService:AutoConnect() -- handles PlayerAdded / PlayerRemoving automaticallylocal ProfileService = require(game.ReplicatedStorage.ProfileService)
-- Wait for the profile to finish loading (yields up to 10 seconds)
local profile = ProfileService:WaitForProfile(player)
if profile then
print(profile:Get("Coins")) -- read a value
profile:Set("Coins", 500) -- overwrite a value
profile:Increment("Coins", 50) -- add to a number
profile:Append("Inventory", "Sword") -- add to an array
profile:Remove("Inventory", "Sword") -- remove from an array
endThat's it. CoreProfileX handles loading, saving, session locking, and shutdown for you.
Must be called once before any profiles load. Usually done in ProfileLoader.server.lua.
ProfileService:Configure({
-- Required
DataStoreName = "PlayerData_v1",
Template = { Coins = 0, Inventory = {} },
-- Optional (these are the defaults)
Scope = "global",
AutoSaveInterval = 60, -- seconds between auto-saves
MaxRetries = 5, -- DataStore retry attempts
RetryDelay = 3, -- seconds between retries (doubles each attempt)
DebugMode = false, -- verbose logging
LeaderStatsSync = false, -- auto-mirror numbers to leaderstats
BackupEnabled = false, -- write to a backup DataStore on every save
BackupStoreName = "PlayerData_v1_Backup",
SessionLockTimeout = 1800, -- seconds before a stale lock is overridden
RateLimitPerMinute = 6, -- max DataStore saves per minute
})Wires up PlayerAdded, PlayerRemoving, and BindToClose automatically.
Call this once after Configure().
ProfileService:AutoConnect()Returns the profile immediately if loaded, or nil if not yet ready.
Use this when you don't want to yield.
local profile = ProfileService:GetProfile(player)
if profile then
-- safe to use
endYields until the profile is loaded. Returns nil if timeout seconds elapse.
Default timeout: 10 seconds.
local profile = ProfileService:WaitForProfile(player, 10)
if not profile then
player:Kick("Could not load your data. Please rejoin.")
endReturns every currently-loaded profile (useful for admin commands, global events).
for userId, profile in ProfileService:GetAllProfiles() do
profile:Increment("Coins", 100) -- give everyone 100 coins
endRegister hooks that run at key points in the data pipeline. See Middleware section.
Returns true once BindToClose has fired. No new profiles will load after this.
-- Fires when a profile finishes loading
ProfileService.ProfileLoaded:Connect(function(player, profile)
print(player.Name, "loaded!")
end)
-- Fires when a profile is released (player left, data saved)
ProfileService.ProfileReleased:Connect(function(player)
print(player.Name, "left, data saved.")
end)The Profile object is returned by GetProfile / WaitForProfile.
Every method is safe to call β they log warnings instead of erroring on misuse.
local coins = profile:Get("Coins")
local inv = profile:Get("Inventory")
local vol = profile:Get("Settings").MusicVolumeReturns a deep copy of the entire data table.
local snapshot = profile:GetAll()
print(snapshot.Coins, snapshot.Level)profile:Set("Coins", 1000)
profile:Set("Level", 5)
profile:Set("Settings", { MusicVolume = 0.5, SFXVolume = 1 })key must be a number. Returns the new value.
local newCoins = profile:Increment("Coins", 50) -- add 50
local newXP = profile:Increment("XP", -100) -- subtract 100Adds a value to an array key. Creates the array if it doesn't exist.
profile:Append("Inventory", "Sword")
profile:Append("Inventory", "HealthPotion")
-- Inventory is now: { "Sword", "HealthPotion" }Removes the first occurrence of value from an array key.
profile:Remove("Inventory", "Sword")
-- Returns true if found and removed, false if not presentWrite multiple keys at once (more efficient than multiple Set calls).
profile:SetMultiple({
Coins = 500,
Level = 10,
Inventory = { "Sword", "Shield" },
})Increment multiple numeric keys at once. Returns a map of the new values.
local results = profile:IncrementMultiple({
Coins = 100,
XP = 250,
Wins = 1,
})
print(results.Coins, results.XP, results.Wins)Fires callback(newValue, oldValue) every time key changes.
local conn = profile:Listen("Coins", function(newValue, oldValue)
print(string.format("Coins: %d β %d", oldValue, newValue))
-- update the player's HUD here
end)
-- Stop listening later:
conn:Disconnect()Returns true once the profile is loaded and not yet released.
if profile:IsLoaded() then
-- safe to read and write
endRegisters a function that runs when the profile is released (player leaves).
profile:OnRelease(function()
print("Profile released β cleanup here")
end)Manually release the profile (save + unlock + fire callbacks).
You normally don't need to call this β AutoConnect handles it on PlayerRemoving.
profile:Release()Force an immediate DataStore save. Returns true on success.
local ok = profile:Save()
if not ok then
warn("Save failed!")
endResets all data back to the template defaults and immediately saves.
profile:Wipe() -- player's data is now back to defaultsReturns a deep copy of the current data. The live profile is not affected.
Use this for previews, comparisons, or passing data to other systems.
local copy = profile:Clone()
-- modify `copy` freely β the real profile is unchanged
copy.Coins = copy.Coins * 2Middleware hooks let you intercept the data pipeline without modifying the core module.
Register them before calling Configure().
ProfileService:UseMiddleware({
-- Runs just before data is written to the DataStore.
-- Return the (possibly modified) data table.
BeforeSave = function(data, player)
data.LastSeen = os.time() -- stamp every save
return data
end,
-- Runs right after data is read from the DataStore.
-- Perfect for data migrations.
AfterLoad = function(data, player)
-- Migrate a renamed field
if data.Money then
data.Coins = data.Money
data.Money = nil
end
return data
end,
-- Runs just before the profile is released (observational only).
BeforeRelease = function(data, player)
print(player.Name, "had", data.Coins, "coins when they left")
end,
-- Fires whenever an internal error occurs.
OnError = function(err, player)
warn("[DataError]", err, player and player.Name or "unknown")
end,
})Multiple UseMiddleware calls are additive β hooks stack in registration order.
local ProfileService = require(game.ReplicatedStorage.ProfileService)
local function awardCoins(player: Player, amount: number)
local profile = ProfileService:GetProfile(player)
if not profile then return end
local newTotal = profile:Increment("Coins", amount)
print(player.Name, "now has", newTotal, "coins")
endlocal function spendCoins(player: Player, amount: number): boolean
local profile = ProfileService:GetProfile(player)
if not profile then return false end
local balance = profile:Get("Coins") :: number
if balance < amount then
return false -- not enough coins
end
profile:Increment("Coins", -amount)
return true
endlocal function giveItem(player: Player, itemId: string)
local profile = ProfileService:WaitForProfile(player)
if not profile then return end
profile:Append("Inventory", itemId)
end
local function removeItem(player: Player, itemId: string): boolean
local profile = ProfileService:GetProfile(player)
if not profile then return false end
return profile:Remove("Inventory", itemId)
end
local function hasItem(player: Player, itemId: string): boolean
local profile = ProfileService:GetProfile(player)
if not profile then return false end
local inv = profile:Get("Inventory") :: { string }
return table.find(inv, itemId) ~= nil
end-- Server
profile:Listen("Coins", function(newValue)
CoinsUpdated:FireClient(player, newValue)
end)
-- Client
CoinsUpdated.OnClientEvent:Connect(function(newCoins)
script.Parent.CoinsLabel.Text = "πͺ " .. newCoins
end)ProfileService:UseMiddleware({
AfterLoad = function(data, player)
-- v1 had "Money", v2 renamed it to "Coins"
if data.Money ~= nil and data.Coins == nil then
data.Coins = data.Money
data.Money = nil
print("Migrated legacy Money β Coins for", player.Name)
end
return data
end,
})-- Give 500 coins to every online player
for _, profile in ProfileService:GetAllProfiles() do
profile:Increment("Coins", 500)
endgame.Players.PlayerAdded:Connect(function(player)
player.Chatted:Connect(function(msg)
if player.Name == "YourUsername" and msg == "/wipe" then
local profile = ProfileService:GetProfile(player)
if profile then
profile:Wipe()
print("Data wiped for", player.Name)
end
end
end)
end)| Option | Type | Default | Description |
|---|---|---|---|
DataStoreName |
string |
required | Name of the primary DataStore |
Template |
table |
required | Default data for new players |
Scope |
string |
"global" |
DataStore scope |
AutoSaveInterval |
number |
60 |
Seconds between auto-saves (when dirty) |
MaxRetries |
number |
5 |
DataStore retry attempts before giving up |
RetryDelay |
number |
3 |
Base delay in seconds (doubles each attempt) |
DebugMode |
boolean |
false |
Print verbose logs to output |
LeaderStatsSync |
boolean |
false |
Auto-mirror number keys to leaderstats |
BackupEnabled |
boolean |
false |
Write to a secondary backup DataStore |
BackupStoreName |
string |
DataStoreName .. "_Backup" |
Backup DataStore name |
SessionLockTimeout |
number |
1800 |
Seconds before a stale session lock expires |
RateLimitPerMinute |
number |
6 |
Maximum DataStore saves per minute per player. Saves attempted beyond this threshold are blocked (not queued) and a warning is printed: "Save blocked by rate limiter". This is distinct from a DataStore failure β the profile remains dirty and will be retried on the next AutoSave interval. |
Session locking prevents two servers from writing to the same player's data simultaneously, which is the primary cause of data loss in Roblox games.
- When a player joins, CoreProfileX writes a unique lock token to the player's DataStore entry using
UpdateAsync(atomic). - If a second server attempts to load the same player, it detects the active token and waits, polling until the lock is released or
SessionLockTimeoutis reached. - When the player leaves,
Release()clears the token atomically, allowing the next server to load cleanly. - If a server crashes without releasing the lock, the token expires automatically after
SessionLockTimeoutseconds (default: 1800). The stale lock is then safely overridden on the next load attempt, preventing the player from being permanently locked out.
This means session locking is resilient to both normal player departures and unexpected server failures.
CoreProfileX combines four complementary systems to protect player data:
| System | Role |
|---|---|
| Session Locking | Prevents concurrent writes from multiple servers |
| Version Tracking | Each save increments an internal version counter, allowing detection of out-of-order writes |
| Checksum Validation | Detects corrupted or tampered data at load time, before it reaches your game logic |
| Backup Recovery | Automatically falls back to a secondary DataStore when primary data fails validation |
These systems work in layers. Session locking prevents the most common cause of corruption (concurrent writes). Checksum validation catches corruption that occurs at the storage level. Backup recovery ensures that even in the worst case, players are restored to their last known good state rather than losing all progress.
Enabling all layers is recommended for games where data loss is unacceptable:
ProfileService:Configure({
DataStoreName = "PlayerData_v1",
Template = { ... },
BackupEnabled = true,
BackupStoreName = "PlayerData_v1_Backup",
})Every save and load passes through a two-stage integrity pipeline:
During Save:
- CoreProfileX serializes the data table.
- A checksum is computed from the serialized data and stored alongside it in the DataStore entry.
During Load:
- The stored data is retrieved.
- The checksum is recomputed from the retrieved data and compared against the stored checksum.
- If they match, the profile is considered valid and loading continues.
- If they don't match (corrupted or tampered data), checksum validation fails before the profile is considered loaded β the corrupted data is never surfaced to your game logic.
Backup Recovery Flow: Primary DataStore β Checksum Validation β If invalid: β Load Backup DataStore β Validate Backup Checksum β Recover automatically if valid β Fallback to template if both are invalid
Note: During recovery, the primary DataStore is not automatically overwritten. The recovered data is loaded into the live session only. A fresh save (triggered by AutoSave or the player leaving) will write clean, validated data back to the primary store.
A warning is printed to the output whenever a checksum mismatch or recovery event occurs, so you can monitor and investigate data issues.
CoreProfileX has been tested with:
- Large inventories (15,000+ items)
- AutoSave under sustained load
- Bulk operations (
SetMultiple,IncrementMultiple) - Data reconciliation across template versions
- Checksum validation and backup recovery end-to-end
- Rate limiter behaviour under rapid save attempts
Wipe(),Clone(),OnRelease(), andRelease()lifecycle flows
These scenarios have been verified to behave correctly. That said, every game has unique data patterns and load characteristics. Developers should perform testing specific to their own game before deploying to a live production environment.
Q: Do I need to call PlayerAdded manually?
No. ProfileService:AutoConnect() handles PlayerAdded, PlayerRemoving, and BindToClose for you.
Q: What happens if the DataStore is down?
CoreProfileX retries up to MaxRetries times with exponential back-off. If it still fails, the player is not loaded and your OnError middleware fires. You should kick the player or show a warning.
Q: Can I use this with existing DataStore data?
Yes. Add your existing keys to the Template β the reconciler will fill in only the keys that are missing, without touching existing data.
Q: How do I reset all player data?
Change the DataStoreName (e.g., from PlayerData_v1 to PlayerData_v2). All players will start fresh.
Q: Can I add nested data to the template?
Yes, fully. Settings = { Volume = 1, Graphics = "High" } works perfectly.
Q: Is this safe for production games?
Yes. It uses the same patterns as professional frameworks: atomic session locking via UpdateAsync, exponential back-off retries, BindToClose draining, and optional backup stores.
Q: How do I update a player's data from a different script?
Just require ProfileService from any server script and call GetProfile(player). The module is a singleton β you'll always get the same cached profile.
- Fixed
Release()final save order bug β data is now guaranteed to be saved before the session lock is cleared. - Added real checksum validation during profile loading β checksums are computed on save and verified on load before the profile is surfaced to game logic.
- Added backup DataStore recovery system β profiles that fail checksum validation are automatically recovered from the backup store, with fallback to the template if both stores are invalid.
- Improved rate limiter logging β saves blocked by the rate limiter now emit a clear
"Save blocked by rate limiter"warning, distinct from DataStore errors. - Improved save integrity validation β internal version tracking added to detect and discard out-of-order writes.
Pull requests are welcome!
If you find a bug or want to suggest a feature, open an Issue.
MIT License β free to use in personal and commercial Roblox games.
Credit is appreciated but not required.
Made with β€οΈ β CoreProfileX