I want to preface by saying I am completely new to data stores, and this bug im experiencing has been an ongoing thing for a few weeks now. I have re-written my player data script atleast 10 times now, using a variety of help from the forums, from ChatGPT, from youtube, just playing around myself, and from google gemini to no avail.
The issue:
When I shut down my game’s servers to push an update, some people that are currently playing just have their entire inventories set to empty, this also happens if a player’s game crashes unexpectedly. On one occasion a player left and rejoined normally and their entire inventory was reset.
The main problem with this is it seems so inconsistent and I can’t seem to replicate this bug for the life of me. I will create a backup of my game and publish it, and then sit there and repeatedly restart the servers over and over again for like 5 minutes while I am in the game and I don’t experience any losses, but then the moment I do the same on my main game file I get multiple complaints about players losing all of their progress. This issue only happens to the “inventory” data table that I save with the player, not to the “stats”, “leaderboard”, or “flags” tables that I also have for the player.
Here is the relevant data store code (in a server script):
local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local RS = game:GetService("ReplicatedStorage")
local equippedState = require(RS:WaitForChild("equippedState"))
local RunService = game:GetService("RunService")
local PlayerStore = DataStoreService:GetDataStore("PlayerData_v1")
local invModule = require(RS:WaitForChild("inventoryModule"))
local rarityTable = require(RS:WaitForChild("rarityTable"))
local inventoryRequestEvent = RS:WaitForChild("inventoryRequestEvent")
-- store equipped-from-datastore per player for the first sync
local initialEquippedFromStore = {}
local SLOTS = { "Helmet", "Weapon", "Legs", "Tag" }
local MAX_RETRIES = 3
local RETRY_WAIT = 2
Saving the player:
local function savePlayer(player)
local stats = player:FindFirstChild("stats")
if not stats then return end
if invModule.GetInventory(player) == nil then
warn("Inventory memory missing for " .. player.Name .. " - Aborting Save to prevent wipe.")
return false
end
local equippedSnapshot = snapshotEquipped(player)
equippedState.UnequipAllToInventory(player, true)
local key = "player_" .. player.UserId
local statsTable = statsToTable(stats)
local invIds = invModule.ToIdList(player)
local leaderTable = folderNumbersToTable(player:FindFirstChild("leaderstats"))
local flagsTable = computeFlagsForSave(player, invIds)
local toSave = {
v = 2,
stats = statsTable,
leaderstats = leaderTable,
inventory = { ids = invIds },
flags = flagsTable,
equipped = equippedSnapshot,
}
for i = 1, MAX_RETRIES do
local ok, err = pcall(function()
PlayerStore:SetAsync(key, toSave)
end)
if ok then
print(("[SAVE] %s OK (items: %d)"):format(player.Name, #invIds))
return true
else
warn(("[SAVE] %s failed (%s)"):format(player.Name, tostring(err)))
end
if i < MAX_RETRIES then task.wait(RETRY_WAIT * i) end
end
return false
end
Loading the player:
local function loadPlayer(player)
invModule.InitPlayer(player)
local key = "player_" .. player.UserId
local data
for i = 1, MAX_RETRIES do
local ok, result = pcall(function()
return PlayerStore:GetAsync(key)
end)
if ok then data = result break end
if i < MAX_RETRIES then task.wait(RETRY_WAIT * i) end
end
if not data then
data = {
stats = {},
leaderstats = {},
inventory = { ids = {} },
flags = {
tutorialCompleted = false,
starterGranted = false,
partyOpened = false,
diedOnce = false,
groupItemAwarded = false
},
equipped = {
Helmet = 0,
Weapon = 0,
Legs = 0,
Tag = 0,
},
}
else
-- existing safety defaults
data.stats = data.stats or {}
data.leaderstats = data.leaderstats or {}
data.inventory = data.inventory or { ids = {} }
data.flags = data.flags or {}
if data.flags.tutorialCompleted == nil then data.flags.tutorialCompleted = false end
if data.flags.starterGranted == nil then data.flags.starterGranted = false end
if data.flags.partyOpened == nil then data.flags.partyOpened = false end
if data.flags.diedOnce == nil then data.flags.diedOnce = false end
if data.flags.groupItemAwarded == nil then data.flags.groupItemAwarded = false end
-- NEW: default for old saves that don’t have equipped yet
data.equipped = data.equipped or {
Helmet = 0,
Weapon = 0,
Legs = 0,
Tag = 0,
}
end
initialEquippedFromStore[player.UserId] = data.equipped
local statsFolder = player:WaitForChild("stats")
local inTutorial = statsFolder:FindFirstChild("inTutorial")
if inTutorial then
inTutorial.Value = not data.flags.tutorialCompleted
end
local partyFlag = statsFolder:FindFirstChild("partyOpened")
if not partyFlag then
partyFlag = Instance.new("BoolValue")
partyFlag.Name = "partyOpened"
partyFlag.Parent = statsFolder
end
local diedFlag = statsFolder:FindFirstChild("diedOnce")
if not diedFlag then
diedFlag = Instance.new("BoolValue")
diedFlag.Name = "diedOnce"
diedFlag.Parent = statsFolder
end
local groupFlag = statsFolder:FindFirstChild("groupRewardGiven")
if not groupFlag then
groupFlag = Instance.new("BoolValue")
groupFlag.Name = "groupRewardGiven"
groupFlag.Parent = statsFolder
end
groupFlag.Value = data.flags.groupItemAwarded
diedFlag.Value = data.flags.diedOnce
if data.stats then
applyStats(player, data.stats)
end
if data.leaderstats then
applyFolderNumbers(player:FindFirstChild("leaderstats"), data.leaderstats)
end
if data.inventory and data.inventory.ids then
if invModule.RemoveAllItems then invModule.RemoveAllItems(player, true) end
invModule.FromIdList(player, data.inventory.ids)
end
if not data.flags.tutorialCompleted then
afkLocalEvent:FireClient(player, true)
tutEvent:FireClient(player, "intro")
end
end
Player added/removed/bind to close events:
local function onPlayerAdded(player)
-- Make sure your stats folder exists before load (if you create it elsewhere)
player.DevTouchMovementMode = "Thumbstick"
player.CameraMaxZoomDistance = 50
player:WaitForChild("stats", 15)
loadPlayer(player)
enforceInfiniteInv(player)
end
local playersSaving = {}
local function handlePlayerExit(player)
if playersSaving[player.UserId] then return end
playersSaving[player.UserId] = true
print(("[LEAVE] %s before save -> inv count = %d"):format(player.Name, invCount(player)))
local ok = savePlayer(player)
print(("[SAVE] %s %s"):format(player.Name, ok and "OK" or "FAILED"))
initialEquippedFromStore[player.UserId] = nil
invModule.RemovePlayer(player)
playersSaving[player.UserId] = nil
end
Players.PlayerRemoving:Connect(handlePlayerExit)
game:BindToClose(function()
local threads = {}
for _, player in ipairs(Players:GetPlayers()) do
table.insert(threads, task.spawn(function()
handlePlayerExit(player)
end))
end
-- Wait safely until the specific threads are done, or time runs out
local startTime = os.clock()
while #threads > 0 and (os.clock() - startTime) < 25 do
-- Clean up finished threads
for i = #threads, 1, -1 do
if coroutine.status(threads[i]) == "dead" then
table.remove(threads, i)
end
end
task.wait(0.1)
end
print("Server shutdown save complete.")
end)
Players.PlayerAdded:Connect(onPlayerAdded)
and in case you wanted to know, this is the ToIdList function in a module script that is called by the save player function:
function inventoryModule.ToIdList(player: Player): {number}
local inventory = playerInventories[player.UserId]
-- Return empty list ONLY if inventory exists but is empty.
if inventory == nil then return {} end
local ids = {}
for i = 1, #inventory do
local itm = inventory[i]
if type(itm) == "table" and itm.ID ~= nil then
ids[#ids+1] = itm.ID
end
end
return ids
end
Excuse the spaghetti code, all these parts have been re done like 10 times at this point. I read everywhere that player’s data wasn’t saving because they didn’t have bindtoclose, but I have that, is it the fact that I am calling a modulescript in the save player function? I just don’t understand. Or do I simply not know how to safely publish changes in my game? Am I not supposed to be shutting servers down? Right now I make changes, publish then restart the servers via game settings > other to ensure the update propogates to all servers and that no servers are left with an “outdated” version, is this wrong? I am new to game development on Roblox so.
In the past I have tried auto saves (just ended in an item duplication glitch so I think I did that wrong), I have tried explicitly not allowing player’s inventories to save with 0 items in it (this was a problem because rebirths or deleting items make it so you have 0 items), I don’t know what else to do at this point.
Any help would be GREATLY appreciated. This bug has been costing me sanity for almost a month now. Thank you!