My data store is deleting player's inventories HELP!

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!

Have you tried letting players enter in their own data entirely? This handy fix

3 Likes

Sorry I don’t understand, what do you mean by that?

1 Like

Handy fix
That
When you let your players join they have to manually enter their inventory as they recall it or they get permanently bannnened.

3 Likes

That’s a fantastic fix I’ll implement that immediately

Great! Glad I could hel.p Don’t lie to me.

3 Likes

you do game:BindToClose() I’m pretty sure it is, and inside that loop through every player and save their data, and so like, when the server crashes or a game server shuts down, before the server closes it saves everyones data rq

use task.spawn for each player tho, to make sure it gets to them all

edit:

oops i didnt scroll down all the way mb you already do that

3 Likes

haha yeah, I am not sure why my BindToClose event is not properly saving the player’s data, I don’t think that is the problem, it has to be with how I am fetching the player’s inventory I think, sometimes I think it is coming back as an empty table

2 Likes

I have like zero experience with DataStores (never got to the point of development where I would add actual saving) so this is a long shot, but could it be something to do with:
player:WaitForChild("stats", 15) in your onPlayerAdded()?
For some users, it may take longer than 15 seconds to load, so loading times could have something to do with the inconsistency?

1 Like

you do make a good point here, but the “stats” portion of my datastore works perfectly fine every single time, the only problem is with the inventory, and it being reset to empty on crashes/server resets, nothing else seems to have issues weirdly enough

1 Like

Since this is a bug that is mainly concerning inventory and nothing else, I think the InventoryModule might be the culprit.
Could you show how invModule.RemoveAllItems() is being coded, and also how invModule.FromIdList() is being coded?

If you haven’t already, you could also try debugging with the Watch debug window + breakpoints. This allows you to keep track of the value of every variable and shows you the order that the code is executing (so you know if the code skips if-conditions or if a variable is nil or something unexpected). I won’t explain how it works because you may already know how to do it, but if you don’t know, then I can quickly summarize it for you.

function inventoryModule.FromIdList(player: Player, ids: {number})
	inventoryModule.InitPlayer(player) -- ensures table exists
	local rarityTable = require(game.ReplicatedStorage.rarityTable)
	-- build quick lookup by ID
	local byId = {}
	for _, obj in ipairs(rarityTable) do
		if obj.ID then byId[obj.ID] = obj end
	end
	for _, id in ipairs(ids or {}) do
		local item = byId[id]
		if item then
			inventoryModule.AddItem(player, item, true) -- silent during load
		end
	end
end
function inventoryModule.RemoveAllItems(player: Player, silent: boolean?): { [number]: any }

	local inventory = playerInventories[player.UserId]
	if not inventory or #inventory == 0 then return {} end
	local removed = {}
	if silent then
		for i = #inventory, 1, -1 do
			local itm = inventory[i]
			if itm.Limited then continue end
			inventory[i] = nil
			table.insert(removed, 1, itm)
		end
		--for i = 1, #inventory do removed[i] = inventory[i] end
		--table.clear(inventory)
	else
		local itemNotifModule = require(game.ReplicatedStorage.itemNotifs)
		for i = #inventory, 1, -1 do
			local itm = inventory[i]
			if itm.Limited then continue end
			inventory[i] = nil
			itemNotifModule.itemNotifs(itm, false, player)
			table.insert(removed, 1, itm)
		end
	end
	return removed
end

Yeah I am aware of debugging via breakpoints, only issue with that is this issue doesn’t happen in studio, and even then if it did, I am not sure how to simulate a crash or a server shutdown inside studio. Unless there is a way to debug live servers real time from studio, I am not sure how I would be able to use your suggestion, but I may be wrong, maybe there is a way?

You can trigger PlayerRemoving breakpoints if you delete the player object from the explorer, so I was thinking about something along the lines of doing that to simulate leaving (but now I realize the issue is probably moreso related to shutdown so you can ignore this). For shutdown, your best bet is spamming prints after hitting the leave button during testing.

Looking at both functions, it looks pretty much correct, like I don’t see how anything bad could happen here.

I think the savePlayer() function is definitely not the problem (unless you somehow messed up invModule.GetInventory(), which is unlikely), since if that stopped working, then none of the data would save at all.

What I DO know is that the potential incriminating code is:
game:BindToClose() ← since the issue is mostly happening during shutdowns, then it might be the way you handle the shutdown. Are the threads even guaranteed to finish before the game shuts down? (Are you actually giving enough wait time? Remember that there will be a lot more threads to run in a server with many people. Is there a way you can increase the speed of execution on threads?)
invModule.RemovePlayer() ← you may have saved player data incorrectly when they left, which caused .InitPlayer() to return nil inventory
invModule.InitPlayer() ← unlikely, but the way you expect saved data to look vs. what it actually looks like could mess this up

1 Like

Could we see the invModule.GetInventory(player) function? (used in the savePlayer function)
Player inventories being deleted usually means that the default data overwrites the user’s previous data, in the rare cases where loading failed (normal datastore errors), but you do have a check for that, though I want to be sure the check is actually working

If player’s inventories are getting wiped, then it can’t be a BindToClose or just datastore saving failure, as that would only cause the player to lose the progress from the unsaved session, not wipe all their data

Although this does not explain why it would happen specifically on shutdowns, and you do have the removeAllItems() function which I don’t know why it is being called on loading? I haven’t read it enough yet

If you want to test these things, you can purposefully cause loading (or saving???) to fail (by hard coding it in your code, or putting an error() inside your pcall), and see what happens then. Though that wouldn’t necessarily test issues caused by server shutdown


The bottleneck is not execution time, what actually takes time is the server reaching the roblox datastore apis. Threads in lua are also not parallel (the task scheduler resumes and handles thread in a way that seems asynchronous, but still runs on a single cpu thread). Servers having more cpu threads doesn’t affect any of this (unless you use parallel lua)
It is also unlikely that it lacks time to save player data, and I don’t really see how it would cause the inventory to be deleted (I only see it causing players to lose their most recent progress)

Here is my invModule.GetInventory() code:

function inventoryModule.GetInventory(player: Player): { [number]: any }
	return playerInventories[player.UserId]
end

Player inventories are initialized as an empty table at the top of the module script via

local playerInventories = {}

function inventoryModule.InitPlayer(player: Player)
	playerInventories[player.UserId] = {}
	playerDiscovered[player.UserId] = {}
end

which is then populated on load via the functions I am sure you have already looked at.
I can send the entire inventory module if you would like me to.

To answer your second question I believe RemoveAllItems() in loadPlayer is just a safety to avoid loading the player with duplicates, it basically just delete’s the player’s inventory:

function inventoryModule.RemoveAllItems(player: Player, silent: boolean?): { [number]: any }

	local inventory = playerInventories[player.UserId]
	if not inventory or #inventory == 0 then return {} end
	local removed = {}
	if silent then
		for i = #inventory, 1, -1 do
			local itm = inventory[i]
			if itm.Limited then continue end
			inventory[i] = nil
			table.insert(removed, 1, itm)
		end
		--for i = 1, #inventory do removed[i] = inventory[i] end
		--table.clear(inventory)
	else
		local itemNotifModule = require(game.ReplicatedStorage.itemNotifs)
		for i = #inventory, 1, -1 do
			local itm = inventory[i]
			if itm.Limited then continue end
			inventory[i] = nil
			itemNotifModule.itemNotifs(itm, false, player)
			table.insert(removed, 1, itm)
		end
	end
	return removed
end

and then obviously repopulates it with the player’s saved inventory from the datastore:

invModule.FromIdList(player, data.inventory.ids)

So far there haven’t been any more reports of people losing all their items, but I also haven’t tried resetting the servers or anything like that since making this post because I’m scared lol

Ok, so this check, from what I can see, is not effective right now

	if invModule.GetInventory(player) == nil then
		warn("Inventory memory missing for " .. player.Name .. " - Aborting Save to prevent wipe.")
		return false
	end

The reason it is ineffective is because, since invModule.InitPlayer(player) is getting called as the player joins, regardless of whether or not the data successfully loaded, then invModule.GetInventory(player) will never return nil. If you instead initialize the table after the player’s inventory was successfully loaded, then the check will work as expected, preventing saving when the data didn’t load

I cannot guarantee this will fix your issue (or that it was an issue in the first place, if you have other checks?). I’m basing off my answers on a very common issue I’ve helped people fix in their datastores in the past. Your datastore system is also big in comparason, and I didn’t do a very thorough analysis of the code (I don’t have the time for it :P)

I’ve bookmarked this thread, in which the exact issue I’m mentioning caused complete data loss. I use this thread to share explanations on how to prevent data loss (including partial data loss, by using session locking or other mechanisms)

I would recommend you try to keep it as organized as possible. If you can get something simple but powerful, then it’ll be easier to debug and fix. Emphasis on having a good idea of what your own code is doing, which can then help you rule out parts of the code (for example, in a simple datastore, BindToClose could be ruled out as causing data wipes, because if it fails, it would cause partial data loss, rather than complete data loss. In your case, idk)

Wait a minute. What happens here, if it fails after all the retires?

	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
		-- [...]
	end

From what I can see, if all the attempts fail, data remains nil, and it then set to the default, which would have the effect of erasing the player’s data on the next save?

Also I am really unsure why your issues only seem to happen during shutdowns or restarts. Perhaps it could be that the datastore limits are being stretched when players join back, causing more datastore errors?
Try adding a warn (and kicking users, while making sure savePlayer() doesn’t run, by initializing the inventory only if loading succeeds) if all the retries for loading fail. (The warnings would then be visible in the error reports part of the Developer Hub, and so you would be able to see if datastore limits are indeed hit when shuting down and restarting servers)

the inventory will be already cleared

oh yeah dang, good luck with that lol, i have no idea either

Sorry for not responding for a few days, I was hella busy.

I think the issue might be something to do with bindToClose(). From this post, I now know that PlayerRemoving fires even when players are kicked out during server shutdown, so all BindToClose() needs to do is wait for playerRemoving to do their thing:

In your code, handlePlayerExit() is called during PlayerRemoving, which will invModule.RemovePlayer(player), which I assume makes the player’s data nil for their entry. BUT THEN BindToClose() fires and calls handlePlayerExit() again, which will cause savePlayer() to be called on nil inventory. (Hopefully I’m right :crossed_fingers: )

EDIT: I just realized you have a .GetInventory check for nil in savePlayer(). Maybe .RemovePlayer() replaces inventory with an empty table instead of nil?