Player Data doesn't save on leave

Hello!

I have a problem where my RouteData in PlayerData (aka SlotData) is not getting saved on player leave. I also have another save script that saves the other data in SlotData.
Basically it keeps updated RouteData in a table called PlayerData but once the player leaves and it prints SlotData it is the stale data from when I joined meaning the data never saved. I can confirm this by joining again and seeing the stale data.
The main things you should be looking at is the PlayerRemoving function and where the PlayerData is being called.

RouteData is the data from a route in aviation. This is how it is structured:

 ["NTL-MQL"] =  ▼  {
                          ["ArrivalTime"] = 1780134921,
                          ["CurrentDestination"] = "MQL",
                          ["CurrentLeg"] = "Outbound",
                          ["CurrentOrigin"] = "NTL",
                          ["Demand"] =  ▶ {...},
                          ["DepartureTime"] = 1780129521,
                          ["Destination"] = "MQL",
                          ["Distance"] = 917,
                          ["DistanceRemaining"] = 917,
                          ["DistanceTravelled"] = 0,
                          ["Expenses"] = 8,
                          ["FlightTimeLeft"] = 2.471858113231571,
                          ["IsActive"] = true,
                          ["LastUpdateTime"] = 1782863886,
                          ["Menu"] =  ▶ {...},
                          ["Menus"] =  ▶ {...},
                          ["Origin"] = "NTL",
                          ["OriginGate"] = "Gate_001",
                          ["PhaseDurations"] =  ▶ {...},
                          ["Plane"] =  ▶ {...},
                          ["Prices"] =  ▶ {...},
                          ["Progress"] = 0,
                          ["Revenue"] = 184,
                          ["Status"] = "Parked",
                          ["TicketRevenue"] =  ▶ {...},
                          ["TotalFlightTime"] = 1.019262389949119,
                          ["_cruiseSecondsLeft"] = 52.15574339694714,
                          ["_lastAnimPhase"] = "Cruise (Outbound)"
                       },

SCRIPT:

-- RouteServerHandler.server.lua
local RouteSimulation = require(game.ReplicatedStorage.Modules.Route.Simulation.RouteSimulation)
local RouteSimManager = require(game.ReplicatedStorage.Modules.Route.Simulation.RouteSimulation.RouteSimManager)
local DefualtAirlineData = require(game.ReplicatedStorage.Modules.Data.DefualtAirlineData)
local FlightPhase = require(game.ReplicatedStorage.Modules.Route.Animation.FlightPhase)

local DataStoreService = game:GetService("DataStoreService")
local AirlineStore = DataStoreService:GetDataStore("AirlineDataStoreTest40")

local RouteAnimationRem = game.ReplicatedStorage.Events.Route.Animation.RouteAnimationRem
local GetRouteDisplay   = game.ReplicatedStorage.Functions.Route.Simulation.GetRouteDisplay
local GetSlotRem        = game.ReplicatedStorage.Events.Slot.GetSlot

-- [UserId] = { [Slot] = SlotData }  — the single source of truth for route state
local PlayerData        = {}
local PlayerRouteManagers = {} -- [UserId] = manager
local ActiveSlot          = {} -- [UserId] = slotNumber

-- Expose for RouteSimAPI (GetRouteDisplay, etc.)
_G.RouteSim_ActiveSlot    = ActiveSlot
_G.RouteSim_PlayerManagers = PlayerRouteManagers

local function deepCopy(tbl)
	local copy = {}
	for k, v in pairs(tbl) do
		copy[k] = typeof(v) == "table" and deepCopy(v) or v
	end
	return copy
end

local function deepMerge(target, source)
	for k, v in pairs(source) do
		if typeof(v) == "table" and typeof(target[k]) == "table" then
			deepMerge(target[k], v)
		else
			target[k] = v
		end
	end
end

local function loadSlot(player, slot)
	local key = player.UserId .. "_Slot" .. slot
	local data = AirlineStore:GetAsync(key)
	if data then
		return data
	else
		local newData = deepCopy(DefualtAirlineData)
		AirlineStore:SetAsync(key, newData)
		return newData
	end
end

local function saveSlot(player, slot, data)
	local key = player.UserId .. "_Slot" .. slot
	AirlineStore:SetAsync(key, data)
end


GetRouteDisplay.OnServerInvoke = function(player, routeId)
	-- not important
end


local function LoadSlotRoutes(manager, SlotData)
	for routeId, routeData in pairs(SlotData.Routes or {}) do
		if routeId == "RouteLines" then continue end
		routeData.IsActive = true
		manager:Register(routeId, routeData)
	end
end


GetSlotRem.OnServerEvent:Connect(function(player, Slot)
	print("[RouteSim] LOADED SLOT:", Slot, player.Name)

	local UserId = player.UserId
	PlayerData[UserId]          = PlayerData[UserId] or {}
	PlayerRouteManagers[UserId] = PlayerRouteManagers[UserId] or {}
	ActiveSlot[UserId]          = Slot

	PlayerData[UserId][Slot] = loadSlot(player, Slot)
	local SlotData = PlayerData[UserId][Slot]

	local manager = RouteSimManager.new()
	PlayerRouteManagers[UserId][Slot] = manager
	LoadSlotRoutes(manager, SlotData)

	local PausedRoutes = {}

	local function NextPhaseAfterAnimation(phase, routeData)
		-- not important 
	end

	task.spawn(function() -- Might be important  you can have a look if you need
		while player.Parent do
			for routeId, routeData in pairs(SlotData.Routes) do
				if routeId == "RouteLines" then continue end
				if PausedRoutes[routeId] then continue end

				local ok, phaseName = pcall(function()
					return routeData.PhaseOverride or FlightPhase.GetPhase(routeData)
				end)
				if not ok then
					warn("GetPhase error for", routeId, ":", phaseName)
					continue
				end
				
				print("SIMULATING ROUTE: ", phaseName, routeData.Progress)
				print(PlayerData)
			
				-- CRUISE: tick progress directly
				if phaseName == "Cruise (Outbound)" or phaseName == "Cruise (Return)" then
					local isOutbound = phaseName == "Cruise (Outbound)"

					if routeData._lastAnimPhase ~= phaseName then
						routeData.PhaseDurations = routeData.PhaseDurations or {}
						routeData.PhaseDurations.EnRoute = routeData.PhaseDurations.EnRoute or (routeData.TotalFlightTime * 60)
						local enRoute    = routeData.PhaseDurations.EnRoute
						local cStart     = isOutbound and 0.025 or 0.525
						local cEnd       = isOutbound and 0.5   or 1.0
						local localProg  = math.clamp((routeData.Progress - cStart) / (cEnd - cStart), 0, 1)
						routeData._cruiseSecondsLeft = enRoute * (1 - localProg)
						routeData.LastUpdateTime     = os.time()
						routeData._lastAnimPhase     = phaseName
						RouteAnimationRem:FireClient(player, routeData, nil, phaseName)
					end

					routeData.PhaseOverride = nil
					local now   = os.time()
					local delta = math.max(0, now - (routeData.LastUpdateTime or now))
					routeData.LastUpdateTime      = now
					routeData._cruiseSecondsLeft  = math.max(0, (routeData._cruiseSecondsLeft or 0) - delta)

					local enRoute   = routeData.PhaseDurations.EnRoute
					local cStart    = isOutbound and 0.025 or 0.525
					local cEnd      = isOutbound and 0.5   or 1.0
					local localProg = enRoute > 0 and (1 - routeData._cruiseSecondsLeft / enRoute) or 1
					routeData.Progress = cStart + math.clamp(localProg, 0, 1) * (cEnd - cStart)
					routeData.Status   = "En-Route"

					if routeData._cruiseSecondsLeft <= 0 then
						local nextPhase = isOutbound and "Landing (Destination)" or "Landing (Origin)"
						routeData.Progress        = cEnd
						routeData.PhaseOverride   = nextPhase
						routeData._lastAnimPhase  = nil
						routeData._cruiseSecondsLeft = nil
					end
					continue
				end

				-- AT GATE: fire animation to client; client handles boarding via BoardPassengersFunc
				if phaseName == "At Gate (Destination)" or phaseName == "At Gate (Origin)" then
					PausedRoutes[routeId] = true
					RouteAnimationRem:FireClient(player, routeData, nil, phaseName)
					task.spawn(function()
						task.wait(routeData.TurnaroundTime or 20)
						if phaseName == "At Gate (Destination)" then
							routeData.PhaseOverride = "Take Off (Destination)"
							routeData.Progress      = 0.5
						else
							routeData.PhaseOverride = "Take Off (Origin)"
							routeData.Progress      = 0
						end
						PausedRoutes[routeId] = nil
					end)
					continue
				end

				-- ANIMATED PHASES (Taxi, Take Off, Landing)
				local PHASE_DURATIONS = {
					["Taxi (Origin)"]        = 30, ["Taxi (Destination)"]        = 30,
					["Push Back (Origin)"]   = 8,  ["Push Back (Destination)"]   = 8,
					["Take Off (Origin)"]    = 60, ["Take Off (Destination)"]    = 60,
					["Landing (Destination)"] = 60,["Landing (Origin)"]          = 60,
				}
				local TotalDuration = PHASE_DURATIONS[phaseName]
				if TotalDuration then
					PausedRoutes[routeId] = true
					local taxidata = FlightPhase.CalcTaxiPoints(player, phaseName, routeData)
					RouteAnimationRem:FireClient(player, routeData, nil, phaseName)
					task.spawn(function()
						task.wait(taxidata.TotalDuration or  TotalDuration)
						local nextPhase = NextPhaseAfterAnimation(phaseName, routeData)
						if nextPhase ~= phaseName then
							routeData.PhaseOverride = nextPhase
						end
						PausedRoutes[routeId] = nil
					end)
					continue
				end

				warn("[RouteSim] unhandled phase '", phaseName, "' for", routeId)
				routeData.PhaseOverride = nil
				routeData.Progress = 0
			end
			task.wait(1)
		end
		warn("[RouteSim] loop stopped for", player.Name)
	end)
end)


game.Players.PlayerRemoving:Connect(function(player) -- LOOK HERE
	local userId = player.UserId
	local slots  = PlayerData[userId]
	print(PlayerData, slots)
	if not slots then return end

	for slot = 1, 5 do
		local slotData = slots[slot]
		if not slotData then continue end
		
		print(slotData)

		local ok, err = pcall(function()
			saveSlot(player, slot, slotData)
		end)

		
		if ok then
			print("[RouteSim] Saved slot", slot, "for", player.Name, slotData)
		else
			warn("[RouteSim] Failed to save slot", slot, ":", err)
		end
	end

	-- Wait a bit for SetAsync to complete before clearing
	task.defer(function()
		PlayerData[userId] = nil
		PlayerRouteManagers[userId] = nil
		ActiveSlot[userId] = nil
	end)	

	
end)

2 Likes

Have you confirmed whether the PlayerRemoving event fires every time you leave or not? From my epxerience the event is very unreliable in studio testing because your device doesn’t act like a roblox server. Normal roblox server’s automatically stay active for a while before starting to shut down, but in studio tests your device will instantly terminate the session, which may result in the event not firing, try adding a manual wait at the end by using game:BindToClose() and see if the event then fires

3 Likes

test data saving on a roblox live server not on roblox studio because in roblox studio’s playtest player removing event might not fire at that moment

1 Like

Just from skim-reading this it looks like you aren’t saving the data upon Server closure. Try also saving the data during game:BindToClose().

3 Likes

In my other Datastoring script I added a bind to close for 5 seconds. Unless I need another, that didn’t fix my issue.
I can confirm that the playerremoving event fired because I added print statements:

print(PlayerData, slots)

	print(slotData)

These print statements printed the updated data but the data did not actually save.

Is the data you are trying to save all one type? Either a dictionary, with strings as keys, or an array, with numbers as keys? I could be wrong, but I think mixing and matching can cause issues.

1 Like

I don’t really see you wrapping SetAsync in a pcall. I would add one so you can see if there is any warnings with your data saving

one thing I do wanna mention, iif you’re not checking whether the player selected the same slot again, you could keep starting new task.spawn loops. In my terms, if the player picks slot 1, then slot 2 AND THEN slot 1 again, you may have multiple while loops running at the same time for that player so I would keep that in mind

id also recommend adding game:BindToClose() and save all of the players data before the server shuts down in case if thats the main issue

2 Likes

The data i am saving is all a dictionary.

Turns out there was a simple issue with the main data store script which simulated the route with the stale data. I simply removed that line and it works!
Thank you for all the help and support with your ideas!

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.