🎯

data-persistence

🎯Skill

from taozhuo/game-dev-skills

VibeIndex|
What it does

Manages reliable Roblox data persistence by implementing robust data loading, saving, caching, and error handling for player progress and game state.

data-persistence

Installation

Install skill:
npx skills add https://github.com/taozhuo/game-dev-skills --skill data-persistence
1
AddedJan 25, 2026

Skill Details

SKILL.md

Implements data persistence systems including DataStore patterns, session locking, data migration, error handling, and backup systems. Use when saving player progress, inventory, settings, or any persistent data.

Overview

# Roblox Data Persistence

When implementing data persistence, follow these patterns for reliable and secure data storage.

DataStore Basics

Data Manager Module

```lua

local DataStoreService = game:GetService("DataStoreService")

local Players = game:GetService("Players")

local DataManager = {}

DataManager.cache = {}

DataManager.dataStore = DataStoreService:GetDataStore("PlayerData_v1")

local DEFAULT_DATA = {

version = 1,

coins = 0,

gems = 0,

level = 1,

experience = 0,

inventory = {},

settings = {

musicVolume = 0.5,

sfxVolume = 0.5

},

stats = {

totalPlayTime = 0,

totalKills = 0,

totalDeaths = 0

}

}

function DataManager.getKey(player)

return "Player_" .. player.UserId

end

function DataManager.deepCopy(original)

local copy = {}

for k, v in pairs(original) do

if type(v) == "table" then

copy[k] = DataManager.deepCopy(v)

else

copy[k] = v

end

end

return copy

end

function DataManager.reconcile(data, template)

for key, value in pairs(template) do

if data[key] == nil then

if type(value) == "table" then

data[key] = DataManager.deepCopy(value)

else

data[key] = value

end

elseif type(value) == "table" and type(data[key]) == "table" then

DataManager.reconcile(data[key], value)

end

end

end

```

Loading Data with Retry

```lua

local MAX_RETRIES = 3

local RETRY_DELAY = 1

function DataManager.load(player)

local key = DataManager.getKey(player)

local data = nil

local success = false

for attempt = 1, MAX_RETRIES do

local ok, result = pcall(function()

return DataManager.dataStore:GetAsync(key)

end)

if ok then

data = result

success = true

break

else

warn("DataStore load failed (attempt " .. attempt .. "):", result)

if attempt < MAX_RETRIES then

task.wait(RETRY_DELAY * attempt) -- Exponential backoff

end

end

end

if not success then

-- Use default data but mark as failed

data = DataManager.deepCopy(DEFAULT_DATA)

data._loadFailed = true

warn("Using default data for", player.Name, "- load failed")

elseif data == nil then

-- New player

data = DataManager.deepCopy(DEFAULT_DATA)

else

-- Reconcile with defaults (add missing fields)

DataManager.reconcile(data, DEFAULT_DATA)

end

-- Migrate if needed

data = DataManager.migrate(data)

DataManager.cache[player.UserId] = data

return data

end

```

Saving Data

```lua

function DataManager.save(player, async)

local key = DataManager.getKey(player)

local data = DataManager.cache[player.UserId]

if not data then

warn("No data to save for", player.Name)

return false

end

-- Don't save if load failed (prevent data loss)

if data._loadFailed then

warn("Skipping save for", player.Name, "- original load failed")

return false

end

-- Remove internal flags before saving

local saveData = DataManager.deepCopy(data)

saveData._loadFailed = nil

local saveFunc = function()

local success, err = pcall(function()

DataManager.dataStore:SetAsync(key, saveData)

end)

if not success then

warn("DataStore save failed for", player.Name, ":", err)

end

return success

end

if async then

task.spawn(saveFunc)

return true

else

return saveFunc()

end

end

```

Auto-Save System

```lua

local AUTO_SAVE_INTERVAL = 300 -- 5 minutes

function DataManager.startAutoSave(player)

task.spawn(function()

while player.Parent do

task.wait(AUTO_SAVE_INTERVAL)

if player.Parent then

DataManager.save(player, true)

end

end

end)

end

-- Save on player leaving

Players.PlayerRemoving:Connect(function(player)

DataManager.save(player, false) -- Synchronous save

DataManager.cache[player.UserId] = nil

end)

-- Save all on server shutdown

game:BindToClose(function()

local saveThreads = {}

for _, player in ipairs(Players:GetPlayers()) do

table.insert(saveThreads, task.spawn(function()

DataManager.save(player, false)

end))

end

-- Wait for all saves (max 30 seconds)

task.wait(30)

end)

```

Session Locking

Prevent Double-Loading

```lua

local MemoryStoreService = game:GetService("MemoryStoreService")

local sessionMap = MemoryStoreService:GetSortedMap("SessionLocks")

local LOCK_DURATION = 3600 -- 1 hour

local SESSION_ID = game.JobId

function DataManager.acquireLock(player)

local key = "Lock_" .. player.UserId

local success, currentLock = pcall(function()

return sessionMap:GetAsync(key)

end)

if success and currentLock and currentLock ~= SESSION_ID then

-- Another session has the lock

return false, "Data is being used in another server"

end

-- Try to acquire lock

local acquired, err = pcall(function()

sessionMap:SetAsync(key, SESSION_ID, LOCK_DURATION)

end)

return acquired, err

end

function DataManager.releaseLock(player)

local key = "Lock_" .. player.UserId

pcall(function()

sessionMap:RemoveAsync(key)

end)

end

function DataManager.refreshLock(player)

local key = "Lock_" .. player.UserId

pcall(function()

sessionMap:SetAsync(key, SESSION_ID, LOCK_DURATION)

end)

end

-- Modified load with session locking

function DataManager.loadWithLock(player)

local locked, lockErr = DataManager.acquireLock(player)

if not locked then

player:Kick("Your data is being used in another server. Please wait a moment.")

return nil

end

local data = DataManager.load(player)

-- Refresh lock periodically

task.spawn(function()

while player.Parent and DataManager.cache[player.UserId] do

task.wait(300) -- Refresh every 5 minutes

DataManager.refreshLock(player)

end

end)

return data

end

```

Data Migration

Version-Based Migration

```lua

local CURRENT_VERSION = 3

local migrations = {

[2] = function(data)

-- v1 -> v2: Convert inventory from array to dictionary

if data.inventory and type(data.inventory[1]) == "string" then

local newInventory = {}

for _, itemId in ipairs(data.inventory) do

newInventory[itemId] = (newInventory[itemId] or 0) + 1

end

data.inventory = newInventory

end

return data

end,

[3] = function(data)

-- v2 -> v3: Add new stats field

data.stats = data.stats or {}

data.stats.achievementsUnlocked = data.stats.achievementsUnlocked or {}

return data

end

}

function DataManager.migrate(data)

local version = data.version or 1

while version < CURRENT_VERSION do

local nextVersion = version + 1

local migrationFunc = migrations[nextVersion]

if migrationFunc then

print("Migrating data from v" .. version .. " to v" .. nextVersion)

data = migrationFunc(data)

data.version = nextVersion

end

version = nextVersion

end

return data

end

```

UpdateAsync for Atomic Operations

Safe Currency Operations

```lua

function DataManager.addCurrency(player, currencyType, amount)

local key = DataManager.getKey(player)

local success, newValue = pcall(function()

return DataManager.dataStore:UpdateAsync(key, function(oldData)

if not oldData then

oldData = DataManager.deepCopy(DEFAULT_DATA)

end

oldData[currencyType] = (oldData[currencyType] or 0) + amount

-- Update cache

if DataManager.cache[player.UserId] then

DataManager.cache[player.UserId][currencyType] = oldData[currencyType]

end

return oldData

end)

end)

return success, newValue and newValue[currencyType]

end

function DataManager.removeCurrency(player, currencyType, amount)

local key = DataManager.getKey(player)

local success, newValue, sufficient = false, nil, false

success = pcall(function()

newValue = DataManager.dataStore:UpdateAsync(key, function(oldData)

if not oldData then

return nil -- Abort

end

local current = oldData[currencyType] or 0

if current < amount then

sufficient = false

return nil -- Abort, not enough currency

end

sufficient = true

oldData[currencyType] = current - amount

if DataManager.cache[player.UserId] then

DataManager.cache[player.UserId][currencyType] = oldData[currencyType]

end

return oldData

end)

end)

return success and sufficient, newValue and newValue[currencyType]

end

```

OrderedDataStore for Leaderboards

Global Leaderboard

```lua

local LeaderboardService = {}

local leaderboardStore = DataStoreService:GetOrderedDataStore("GlobalLeaderboard_v1")

function LeaderboardService.submitScore(player, score)

local key = tostring(player.UserId)

local success, err = pcall(function()

leaderboardStore:SetAsync(key, score)

end)

return success

end

function LeaderboardService.getTopScores(limit)

limit = limit or 100

local success, pages = pcall(function()

return leaderboardStore:GetSortedAsync(false, limit) -- false = descending

end)

if not success then

return nil

end

local scores = {}

local page = pages:GetCurrentPage()

for rank, entry in ipairs(page) do

table.insert(scores, {

rank = rank,

userId = tonumber(entry.key),

score = entry.value

})

end

return scores

end

function LeaderboardService.getPlayerRank(player)

local key = tostring(player.UserId)

-- Get player's score

local success, score = pcall(function()

return leaderboardStore:GetAsync(key)

end)

if not success or not score then

return nil

end

-- Count how many scores are higher

local rank = 1

local pages = leaderboardStore:GetSortedAsync(false, 100)

while true do

local page = pages:GetCurrentPage()

for _, entry in ipairs(page) do

if entry.value > score then

rank = rank + 1

elseif entry.key == key then

return rank

end

end

if pages.IsFinished then

break

end

pages:AdvanceToNextPageAsync()

end

return rank

end

```

MemoryStore for Temporary Data

Cross-Server Communication

```lua

local MemoryStoreService = game:GetService("MemoryStoreService")

local globalQueue = MemoryStoreService:GetQueue("GlobalAnnouncements")

local serverStatus = MemoryStoreService:GetSortedMap("ServerStatus")

-- Publish announcement to all servers

function publishAnnouncement(message)

pcall(function()

globalQueue:AddAsync(message, 300) -- 5 minute expiration

end)

end

-- Process announcements

task.spawn(function()

while true do

local success, items = pcall(function()

return globalQueue:ReadAsync(10, false, 5)

end)

if success and items then

for _, item in ipairs(items) do

-- Show announcement to all players

showAnnouncement(item)

end

-- Remove processed items

pcall(function()

globalQueue:RemoveAsync(items[#items].id)

end)

end

task.wait(5)

end

end)

-- Register server status

function updateServerStatus()

pcall(function()

serverStatus:SetAsync(game.JobId, {

playerCount = #Players:GetPlayers(),

maxPlayers = Players.MaxPlayers,

mapName = getCurrentMap(),

lastUpdate = os.time()

}, 120) -- 2 minute expiration

end)

end

task.spawn(function()

while true do

updateServerStatus()

task.wait(60)

end

end)

```

Backup & Recovery

Periodic Backups

```lua

local BackupService = {}

local backupStore = DataStoreService:GetDataStore("PlayerBackups")

local MAX_BACKUPS = 5

function BackupService.createBackup(player)

local data = DataManager.cache[player.UserId]

if not data then return false end

local backupKey = "Backup_" .. player.UserId

local timestamp = os.time()

local success = pcall(function()

backupStore:UpdateAsync(backupKey, function(backups)

backups = backups or {}

-- Add new backup

table.insert(backups, {

timestamp = timestamp,

data = DataManager.deepCopy(data)

})

-- Keep only recent backups

while #backups > MAX_BACKUPS do

table.remove(backups, 1)

end

return backups

end)

end)

return success

end

function BackupService.listBackups(userId)

local backupKey = "Backup_" .. userId

local success, backups = pcall(function()

return backupStore:GetAsync(backupKey)

end)

if success and backups then

local list = {}

for i, backup in ipairs(backups) do

table.insert(list, {

index = i,

timestamp = backup.timestamp,

date = os.date("%Y-%m-%d %H:%M:%S", backup.timestamp)

})

end

return list

end

return {}

end

function BackupService.restoreBackup(userId, backupIndex)

local backupKey = "Backup_" .. userId

local success, backup = pcall(function()

local backups = backupStore:GetAsync(backupKey)

return backups and backups[backupIndex]

end)

if success and backup then

local mainKey = "Player_" .. userId

return pcall(function()

DataManager.dataStore:SetAsync(mainKey, backup.data)

end)

end

return false

end

```

Error Handling Best Practices

Comprehensive Error Handling

```lua

local function safeDataOperation(operation, ...)

local args = {...}

local attempts = 0

local maxAttempts = 3

while attempts < maxAttempts do

attempts = attempts + 1

local success, result = pcall(operation, unpack(args))

if success then

return true, result

end

-- Check error type

local errorMsg = tostring(result)

if errorMsg:find("502") or errorMsg:find("503") then

-- Service unavailable, retry with backoff

task.wait(2 ^ attempts)

elseif errorMsg:find("Request was throttled") then

-- Rate limited, wait longer

task.wait(6)

elseif errorMsg:find("Key not found") then

-- Key doesn't exist (not an error for GetAsync)

return true, nil

else

-- Unknown error, log and retry

warn("DataStore error:", errorMsg)

task.wait(1)

end

end

return false, "Max retries exceeded"

end

```

More from this repository10