Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

Use this file to discover all available pages before exploring further.

Saving player progress in Roblox means reading from and writing to Roblox’s cloud key-value store (DataStoreService) on every session boundary. While the raw API is straightforward, using SetAsync directly is dangerous in production: it offers no protection against race conditions during server hops, has no built-in retry logic, and gives you no schema migration story. Production games almost universally layer ProfileService on top to get session locking, auto-save, and data reconciliation for free.
SE-1 — DataStore data loss from improper session locking. Without session locking, a player who teleports between servers can lose all progress from the first session. Server B loads stale data before Server A finishes saving, then Server B’s stale copy overwrites Server A’s write. Always use ProfileService (or manual UpdateAsync-based locking) in any game where players can be on more than one server.

Raw DataStoreService Basics

DataStoreService stores JSON-serializable values under string keys. Each key belongs to a named DataStore. The four core methods are:
MethodPurposeNotes
GetAsync(key)Read a valueReturns nil if key doesn’t exist
SetAsync(key, value)Write a value (overwrites)No conflict protection
UpdateAsync(key, callback)Atomic read-modify-writePreferred for saves
RemoveAsync(key)Delete a keyReturns the old value
UpdateAsync is preferred over SetAsync because it is atomic — it reads the current value, lets you transform it, and writes it back in a single operation, preventing two servers from overwriting each other. Every DataStore call must be wrapped in pcall because network errors are a normal operating condition:
-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")

local playerDataStore = DataStoreService:GetDataStore("PlayerData_v1")

-- Default data for new players
local DEFAULT_DATA = {
    Cash = 0,
    Level = 1,
    Experience = 0,
    Inventory = {},
}

-- In-memory cache: Player -> data table
local playerDataCache: { [Player]: { [string]: any } } = {}

local function loadPlayerData(player: Player): { [string]: any }?
    local key = "Player_" .. player.UserId

    local success, result = pcall(function()
        return playerDataStore:GetAsync(key)
    end)

    if not success then
        warn(`[DataStore] Failed to load data for {player.Name}: {result}`)
        return nil
    end

    -- Merge with defaults so new fields get default values
    local data = result or {}
    for field, default in DEFAULT_DATA do
        if data[field] == nil then
            data[field] = default
        end
    end

    return data
end

local function savePlayerData(player: Player): boolean
    local data = playerDataCache[player]
    if not data then
        return false
    end

    local key = "Player_" .. player.UserId

    local success, err = pcall(function()
        playerDataStore:UpdateAsync(key, function(_oldData)
            return data
        end)
    end)

    if not success then
        warn(`[DataStore] Failed to save data for {player.Name}: {err}`)
        return false
    end

    return true
end

-- Load on join
Players.PlayerAdded:Connect(function(player: Player)
    local data = loadPlayerData(player)

    if not data then
        -- Kick if data fails to load to prevent overwriting with defaults
        player:Kick("Failed to load your data. Please rejoin.")
        return
    end

    playerDataCache[player] = data
    -- Set up leaderstats, UI, etc. using data
end)

-- Save on leave
Players.PlayerRemoving:Connect(function(player: Player)
    savePlayerData(player)
    playerDataCache[player] = nil
end)

-- Auto-save every 5 minutes
task.spawn(function()
    while true do
        task.wait(300)
        for player, _data in playerDataCache do
            task.spawn(savePlayerData, player)
        end
    end
end)
Enable Game Settings > Security > Enable Studio Access to API Services in Roblox Studio, or every DataStore call will fail during Studio testing.

ProfileService: The Production Standard

ProfileService is the community-standard library for production-grade persistence. It solves every critical problem that raw DataStore usage leaves open.
FeatureRaw DataStoreProfileService
Session lockingManual (hard)Automatic
Auto-saveManualBuilt-in
Schema migrationManualSupported
Data corruption protectionNoneBuilt-in
Retry logicManualBuilt-in
BindToClose handlingManualAutomatic

Installation

Complete ProfileService Setup

-- ServerScript in ServerScriptService
local Players = game:GetService("Players")
local ServerScriptService = game:GetService("ServerScriptService")

local ProfileService = require(ServerScriptService.Packages.ProfileService)
-- Adjust the require path based on where you installed it

-- Define the profile template (default data for new players)
local PROFILE_TEMPLATE = {
    DataVersion = 1,
    Cash = 0,
    Level = 1,
    Experience = 0,
    Inventory = {},
    Settings = {
        MusicVolume = 0.5,
        SFXVolume = 0.8,
    },
    Statistics = {
        TotalPlayTime = 0,
        GamesPlayed = 0,
    },
}

-- Create the ProfileStore (wraps a DataStore)
local ProfileStore = ProfileService.GetProfileStore("PlayerProfiles_v1", PROFILE_TEMPLATE)

-- Active profiles cache
local Profiles: { [Player]: typeof(ProfileStore:LoadProfileAsync("")) } = {}

local function onProfileLoaded(player: Player, profile)
    profile:AddUserId(player.UserId) -- GDPR compliance
    profile:Reconcile() -- Fills in missing fields from PROFILE_TEMPLATE

    profile:ListenToRelease(function()
        Profiles[player] = nil
        player:Kick("Your data was loaded on another server. Please rejoin.")
    end)

    -- Check if player is still in game (they may have left during async load)
    if not player:IsDescendantOf(Players) then
        profile:Release()
        return
    end

    Profiles[player] = profile

    -- Set up leaderstats from profile data
    local leaderstats = Instance.new("Folder")
    leaderstats.Name = "leaderstats"
    leaderstats.Parent = player

    local cash = Instance.new("IntValue")
    cash.Name = "Cash"
    cash.Value = profile.Data.Cash
    cash.Parent = leaderstats

    local level = Instance.new("IntValue")
    level.Name = "Level"
    level.Value = profile.Data.Level
    level.Parent = leaderstats
end

Players.PlayerAdded:Connect(function(player: Player)
    local profile = ProfileStore:LoadProfileAsync(
        "Player_" .. player.UserId,
        "ForceLoad" -- Wait until the session lock is acquired
    )

    if profile == nil then
        player:Kick("Unable to load your data. Please rejoin.")
        return
    end

    onProfileLoaded(player, profile)
end)

Players.PlayerRemoving:Connect(function(player: Player)
    local profile = Profiles[player]
    if profile then
        -- Sync leaderstats back to profile before release
        local leaderstats = player:FindFirstChild("leaderstats")
        if leaderstats then
            profile.Data.Cash = leaderstats.Cash.Value
            profile.Data.Level = leaderstats.Level.Value
        end

        profile:Release()
    end
end)

Accessing Profile Data from Other Scripts

-- In another ServerScript or ModuleScript
local function addCash(player: Player, amount: number)
    local profile = Profiles[player]
    if not profile then
        return
    end

    profile.Data.Cash += amount

    -- Also update leaderstats if visible
    local leaderstats = player:FindFirstChild("leaderstats")
    if leaderstats and leaderstats:FindFirstChild("Cash") then
        leaderstats.Cash.Value = profile.Data.Cash
    end
end

Session Locking Explained

The Problem

Without session locking, data corruption occurs during server hops:
t=0   Player is on Server A, data loaded
t=1   Player teleports to Server B
t=2   Server B starts loading player data from DataStore
t=3   Server A fires PlayerRemoving, starts saving data
t=4   Server B finishes loading (gets STALE data)
t=5   Server A finishes saving (writes LATEST data)
t=6   Server B eventually saves its stale copy, OVERWRITING the latest data

Result: Player loses progress from Server A session

The Solution

Session locking ensures that only one server can own a player’s data at a time:
t=0   Server A loads profile, acquires session lock
t=1   Player teleports to Server B
t=2   Server B tries to load -- sees lock owned by Server A, WAITS
t=3   Server A fires PlayerRemoving, saves data, RELEASES lock
t=4   Server B detects lock released, acquires lock, loads LATEST data

Result: No data loss

How ProfileService Implements It

1

Acquire lock

When LoadProfileAsync is called, ProfileService writes a session lock tag (the server’s JobId) to the DataStore entry.
2

Wait or steal

If another server already holds the lock, ProfileService either waits ("ForceLoad") or gives up ("Steal").
3

Refresh lock

The locking server periodically refreshes the lock via its internal auto-save.
4

Release

On profile:Release(), the lock is cleared and the data is saved atomically.
5

Expiry

If a server crashes without releasing, the lock expires after ~30 minutes and another server can claim it.
You do not need to implement session locking manually. ProfileService handles all of this. This is the primary reason to use it over raw DataStoreService.

Data Migration Patterns

When your schema changes, migrate existing player data using a versioned migration chain.

Strategy

  1. Include a DataVersion field in every profile template.
  2. Apply migration functions sequentially (v1→v2, v2→v3, etc.) at load time.
  3. Update DataVersion to the current version after all migrations run.

Complete Migration Example

-- DataMigrations module
local DataMigrations = {}

-- Each migration transforms data from version N to version N+1
local migrations: { [number]: (data: { [string]: any }) -> { [string]: any } } = {}

-- v1 -> v2: Split "Money" into "Cash" and "Gems"
migrations[1] = function(data)
    if data.Money then
        data.Cash = data.Money
        data.Gems = 0
        data.Money = nil
    end
    return data
end

-- v2 -> v3: Move settings into nested table
migrations[2] = function(data)
    data.Settings = {
        MusicVolume = data.MusicVolume or 0.5,
        SFXVolume = data.SFXVolume or 0.8,
    }
    data.MusicVolume = nil
    data.SFXVolume = nil
    return data
end

-- v3 -> v4: Add Quests system and rename "Wins" to "Statistics.Wins"
migrations[3] = function(data)
    data.Quests = {
        Active = {},
        Completed = {},
    }
    data.Statistics = data.Statistics or {}
    data.Statistics.Wins = data.Wins or 0
    data.Wins = nil
    return data
end

local CURRENT_VERSION = 4

function DataMigrations.migrate(data: { [string]: any }): { [string]: any }
    local version = data.DataVersion or 1

    if version > CURRENT_VERSION then
        warn(`[Migration] Data version {version} is newer than code version {CURRENT_VERSION}`)
        return data
    end

    while version < CURRENT_VERSION do
        local migrator = migrations[version]
        if migrator then
            data = migrator(data)
            print(`[Migration] Migrated data from v{version} to v{version + 1}`)
        end
        version += 1
    end

    data.DataVersion = CURRENT_VERSION
    return data
end

return DataMigrations

Using Migrations With ProfileService

-- After loading the profile, before using the data:
local profile = ProfileStore:LoadProfileAsync("Player_" .. player.UserId, "ForceLoad")
if profile then
    profile.Data = DataMigrations.migrate(profile.Data)
    profile:Reconcile() -- Fill in any remaining missing defaults
end

BindToClose Handling

game:BindToClose fires when the server is shutting down. You have 30 seconds before the process is killed. Always save in parallel using task.spawn — sequential saves with 50 players can easily exceed the time limit.
-- Complete BindToClose handler
game:BindToClose(function()
    -- In Studio, don't block long
    if game:GetService("RunService"):IsStudio() then
        task.wait(1)
        return
    end

    local finished = Instance.new("BindableEvent")
    local allPlayers = Players:GetPlayers()
    local remaining = #allPlayers

    if remaining == 0 then
        return
    end

    for _, player in allPlayers do
        task.spawn(function()
            savePlayerData(player)

            remaining -= 1
            if remaining <= 0 then
                finished:Fire()
            end
        end)
    end

    -- Wait for all saves to complete, with a 25-second buffer before hard limit
    task.delay(25, function()
        finished:Fire()
    end)

    finished.Event:Wait()
    finished:Destroy()
end)
BAD — sequential saves will time out with many players:
for _, player in Players:GetPlayers() do
    savePlayerData(player) -- Each call may take 0.5-2 seconds
end
GOOD — parallel saves complete in the time of the slowest single save:
for _, player in Players:GetPlayers() do
    task.spawn(savePlayerData, player)
end
task.wait(25)

Error Handling Patterns

Retry Failed Saves

local MAX_RETRIES = 3
local RETRY_DELAY = 2

local function saveWithRetry(player: Player): boolean
    for attempt = 1, MAX_RETRIES do
        local success = savePlayerData(player)
        if success then
            return true
        end

        if attempt < MAX_RETRIES then
            warn(`[DataStore] Retry {attempt}/{MAX_RETRIES} for {player.Name}`)
            task.wait(RETRY_DELAY * attempt) -- Exponential-ish backoff
        end
    end

    warn(`[DataStore] All retries failed for {player.Name}`)
    return false
end

Validate Before Saving

local function validateData(data: { [string]: any }): boolean
    if typeof(data) ~= "table" then
        return false
    end

    if typeof(data.Cash) ~= "number" or data.Cash < 0 then
        return false
    end

    if typeof(data.Level) ~= "number" or data.Level < 1 then
        return false
    end

    return true
end

local function safeSave(player: Player)
    local data = playerDataCache[player]
    if not data or not validateData(data) then
        warn(`[DataStore] Invalid data for {player.Name}, skipping save`)
        return
    end

    savePlayerData(player)
end

Studio vs Production DataStore Key Prefix

local RunService = game:GetService("RunService")
local PREFIX = RunService:IsStudio() and "Dev_" or ""
local dataStore = DataStoreService:GetDataStore(PREFIX .. "PlayerData_v1")

Common Pitfalls

DataStore requests are budgeted at 60 + numPlayers × 10 per minute per DataStore. With 50 players that is 560 req/min total. Saving on every coin pickup will exhaust this instantly.Wrong:
coinTouched:Connect(function(player)
    player.Data.Cash += 1
    dataStore:SetAsync("Player_" .. player.UserId, player.Data) -- rate limit hit
end)
Right: Modify in-memory data immediately, rely on periodic auto-save every 5 minutes.
DataStore serializes to JSON internally. Only number, string, boolean, and plain table values survive the round trip. Never store Instance references, Vector3, CFrame, or other Roblox types.
-- WRONG
data.Weapon = workspace.Sword
data.Character = player.Character

-- RIGHT
data.WeaponId = "IronSword"
data.EquippedSlots = { "Helmet_01", "Armor_03" }
Unwrapped DataStore calls will throw errors and break your script on any transient network failure.
-- WRONG
local data = dataStore:GetAsync(key) -- will error and break the script

-- RIGHT
local success, data = pcall(function()
    return dataStore:GetAsync(key)
end)
if not success then
    warn("DataStore error:", data)
end
If you press Stop in Studio, PlayerRemoving does not fire. Data will not save unless you also handle BindToClose in your tests. Use a Play Solo session and let the game end naturally, or add an explicit save button during development.
LimitValue
Key name length50 characters
Value size per key4 MB (4,194,304 bytes)
DataStore name length50 characters
If you approach 4 MB, split data across multiple named DataStores by category (PlayerCore, PlayerInventory, PlayerQuests).

Build docs developers (and LLMs) love