Skip to content

Latest commit

Β 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

CoreProfileX Banner Roblox License Status

CoreProfileX

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


πŸ“– What is CoreProfileX?

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.


✨ Features

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

CoreProfileX includes:

  • Session Locking
  • Versioned Saves
  • Checksum Validation
  • Automatic Backup Recovery
  • Middleware Hooks
  • AutoSave
  • Rate Limiting
  • Data Reconciliation
  • Reactive Listeners
  • Bulk Data Operations

πŸ“ Package Structure

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


πŸ“¦ Installation

Method 1 β€” Roblox Model File (Easiest) ⭐

  1. Download CoreProfileX.rbxmx from Releases
  2. Open Roblox Studio
  3. In the Explorer, right-click on ReplicatedStorage β†’ Insert from File
  4. Select the .rbxmx file
  5. The full folder structure will appear automatically
  6. Move ProfileLoader.server.lua into ServerScriptService
  7. βœ… Done!

Method 2 β€” Manual Copy-Paste

  1. Create a ModuleScript inside ReplicatedStorage named ProfileService
  2. Paste the contents of MainModule.lua into it
  3. Inside that ModuleScript, create 4 more ModuleScripts:
    • Types β†’ paste Types.lua
    • Utils β†’ paste Utils.lua
    • Signals β†’ paste Signals.lua
    • Middleware β†’ paste Middleware.lua
  4. Create a Script in ServerScriptService named ProfileLoader
  5. Paste the contents of ProfileLoader.server.lua into it
  6. βœ… Done!

Method 3 β€” Rojo / Wally (Advanced)

If you use Rojo for a VS Code workflow:

your-game/
└── src/
    └── ReplicatedStorage/
        └── ProfileService/    ← drop this folder here

Then sync with Rojo as normal.


πŸš€ Quick Start

Step 1 β€” Configure (in ProfileLoader.server.lua)

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 automatically

Step 2 β€” Use in Any Server Script

local 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
end

That's it. CoreProfileX handles loading, saving, session locking, and shutdown for you.


πŸ“š Full API Reference

ProfileService (the singleton)

ProfileService:Configure(config)

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
})

ProfileService:AutoConnect()

Wires up PlayerAdded, PlayerRemoving, and BindToClose automatically.
Call this once after Configure().

ProfileService:AutoConnect()

ProfileService:GetProfile(player) β†’ Profile?

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
end

ProfileService:WaitForProfile(player, timeout?) β†’ Profile?

Yields 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.")
end

ProfileService:GetAllProfiles() β†’ { [userId]: Profile }

Returns 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
end

ProfileService:UseMiddleware(hooks)

Register hooks that run at key points in the data pipeline. See Middleware section.


ProfileService:IsShuttingDown() β†’ boolean

Returns true once BindToClose has fired. No new profiles will load after this.


Service Signals

-- 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)

Profile Object

The Profile object is returned by GetProfile / WaitForProfile.
Every method is safe to call β€” they log warnings instead of erroring on misuse.


Reading Data

profile:Get(key) β†’ any
local coins = profile:Get("Coins")
local inv   = profile:Get("Inventory")
local vol   = profile:Get("Settings").MusicVolume
profile:GetAll() β†’ { [string]: any }

Returns a deep copy of the entire data table.

local snapshot = profile:GetAll()
print(snapshot.Coins, snapshot.Level)

Writing Data

profile:Set(key, value) β†’ boolean
profile:Set("Coins", 1000)
profile:Set("Level", 5)
profile:Set("Settings", { MusicVolume = 0.5, SFXVolume = 1 })
profile:Increment(key, amount) β†’ number?

key must be a number. Returns the new value.

local newCoins = profile:Increment("Coins", 50)   -- add 50
local newXP    = profile:Increment("XP", -100)    -- subtract 100
profile:Append(key, value) β†’ boolean

Adds 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" }
profile:Remove(key, value) β†’ boolean

Removes the first occurrence of value from an array key.

profile:Remove("Inventory", "Sword")
-- Returns true if found and removed, false if not present

Bulk Operations

profile:SetMultiple(data) β†’ boolean

Write multiple keys at once (more efficient than multiple Set calls).

profile:SetMultiple({
    Coins     = 500,
    Level     = 10,
    Inventory = { "Sword", "Shield" },
})
profile:IncrementMultiple(data) β†’ { [string]: number }

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)

Reactive Listeners

profile:Listen(key, callback) β†’ Connection

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()

Lifecycle

profile:IsLoaded() β†’ boolean

Returns true once the profile is loaded and not yet released.

if profile:IsLoaded() then
    -- safe to read and write
end
profile:OnRelease(callback) β†’ Connection

Registers a function that runs when the profile is released (player leaves).

profile:OnRelease(function()
    print("Profile released β€” cleanup here")
end)
profile:Release()

Manually release the profile (save + unlock + fire callbacks).
You normally don't need to call this β€” AutoConnect handles it on PlayerRemoving.

profile:Release()

Saving

profile:Save() β†’ boolean

Force an immediate DataStore save. Returns true on success.

local ok = profile:Save()
if not ok then
    warn("Save failed!")
end

Utilities

profile:Wipe() β†’ boolean

Resets all data back to the template defaults and immediately saves.
⚠️ This is irreversible.

profile:Wipe()  -- player's data is now back to defaults
profile:Clone() β†’ { [string]: any }

Returns 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 * 2

πŸͺ Middleware

Middleware 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.


πŸ’‘ Examples

Give Coins on Purchase

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")
end

Spend Coins (with validation)

local 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
end

Inventory Management

local 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

Live HUD Updates

-- Server
profile:Listen("Coins", function(newValue)
    CoinsUpdated:FireClient(player, newValue)
end)

-- Client
CoinsUpdated.OnClientEvent:Connect(function(newCoins)
    script.Parent.CoinsLabel.Text = "πŸͺ™ " .. newCoins
end)

Data Migration (AfterLoad middleware)

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,
})

Global Event (give reward to all players)

-- Give 500 coins to every online player
for _, profile in ProfileService:GetAllProfiles() do
    profile:Increment("Coins", 500)
end

Admin Wipe Command

game.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)

βš™οΈ Configuration Reference

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.

πŸ” How Session Locking Works

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.

  1. When a player joins, CoreProfileX writes a unique lock token to the player's DataStore entry using UpdateAsync (atomic).
  2. If a second server attempts to load the same player, it detects the active token and waits, polling until the lock is released or SessionLockTimeout is reached.
  3. When the player leaves, Release() clears the token atomically, allowing the next server to load cleanly.
  4. If a server crashes without releasing the lock, the token expires automatically after SessionLockTimeout seconds (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.


πŸ”° Data Integrity

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",
})

πŸ›‘οΈ How Corruption Protection Works

Every save and load passes through a two-stage integrity pipeline:

During Save:

  1. CoreProfileX serializes the data table.
  2. A checksum is computed from the serialized data and stored alongside it in the DataStore entry.

During Load:

  1. The stored data is retrieved.
  2. The checksum is recomputed from the retrieved data and compared against the stored checksum.
  3. If they match, the profile is considered valid and loading continues.
  4. 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.


πŸš€ Production Usage

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(), and Release() 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.


❓ FAQ

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.


πŸ“‹ Changelog

June 2026

  • 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.

🀝 Contributing

Pull requests are welcome!
If you find a bug or want to suggest a feature, open an Issue.


πŸ“„ License

MIT License β€” free to use in personal and commercial Roblox games.
Credit is appreciated but not required.


Made with ❀️ β€” CoreProfileX

About

A production-ready plug-and-play Profile Service for Roblox

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages