Quick question about saving player data

I’m hoping to revise my game’s current method of saving data. I’m thinking about using the script from the article on it.

I’m wondering if there are any problems with the final script in this article, or if it’s out of date.

Code from the above link
-- Setup table that we will return to scripts that require the ModuleScript.
local PlayerStatManager = {}
 
-- Create variable for the DataStore.
local DataStoreService = game:GetService('DataStoreService')
local playerData = DataStoreService:GetDataStore('PlayerData')
 
-- Create variable to configure how often the game autosaves the player data.
local AUTOSAVE_INTERVAL = 60
 
-- Number of times we can retry accessing a DataStore before we give up and create
-- an error.
local DATASTORE_RETRIES = 3
 
-- Table to hold all of the player information for the current session.
local sessionData = {}
 
-- Function the other scripts in our game can call to change a player's stats. This
-- function is stored in the returned table so external scripts can use it.
function PlayerStatManager:ChangeStat(player, statName, changeValue)
	sessionData[player][statName] = sessionData[player][statName] + changeValue
end
 
-- Function to retry the passed in function several times. If the passed in function
-- is unable to be run then this function returns false and creates an error.
local function dataStoreRetry(dataStoreFunction)
	local tries = 0	
	local success = true
	local data = nil
	repeat
		tries = tries + 1
		success = pcall(function() data = dataStoreFunction() end)
		if not success then wait(1) end
	until tries == DATASTORE_RETRIES or success
	if not success then
		error('Could not access DataStore! Warn players that their data might not get saved!')
	end
	return success, data
end
 
-- Function to retrieve player's data from the DataStore.
local function getPlayerData(player)
	return dataStoreRetry(function()
		return playerData:GetAsync(player.UserId)
	end)
end
 
-- Function to save player's data to the DataStore.
local function savePlayerData(player)
	if sessionData[player] then
		return dataStoreRetry(function()
			return playerData:SetAsync(player.UserId, sessionData[player])
		end)
	end
end
 
-- Function to add player to the sessionData table. First check if the player has
-- data in the DataStore. If so, we'll use that. If not, we'll add the player to
-- the DataStore.
local function setupPlayerData(player)
	local success, data = getPlayerData(player)
	if not success then
		-- Could not access DataStore, set session data for player to false.
		sessionData[player] = false
	else
		if not data then
			-- DataStores are working, but no data for this player
			sessionData[player] = {Money = 0, Experience = 0}
			savePlayerData(player)
		else
			-- DataStores are working and we got data for this player
			sessionData[player] = data
		end
	end	
end
 
-- Function to run in the background to periodically save player's data.
local function autosave()
	while wait(AUTOSAVE_INTERVAL) do
		for player, data in pairs(sessionData) do
			savePlayerData(player)
		end
	end
end
 
-- Bind setupPlayerData to PlayerAdded to call it when player joins.
game.Players.PlayerAdded:connect(setupPlayerData)
 
-- Call savePlayerData on PlayerRemoving to save player data when they leave.
-- Also delete the player from the sessionData, as the player isn't in-game anymore.
game.Players.PlayerRemoving:connect(function(player)
	savePlayerData(player)
	sessionData[player] = nil
end)
 
-- Start running autosave function in the background.
spawn(autosave)
 
-- Return the PlayerStatManager table to external scripts can access it.
return PlayerStatManager
1 Like

I believe it’s still up-to-date.

Best way to see if it’s outdated is to test it – if you come across any issues you’d want to tell someone like CloneTrooper1019.

Everything seems good, but I’m not sure about the datastore retry. iirc it was embedded into the get and set methods a while ago, so it shouldn’t be necessary.

It’s up to date, AFAIK, but definitely check out UpdateAsync and all the wiki examples using it. UpdateAsync is the right way to save over existing data. I don’t think any of my games have any SetAsync calls in the player data saving code, because of the risk of data loss. UpdateAsync lets you compare current stored values to the ones you’re about to write, so that you can validate data. For stats that are naturally monotonically increasing, like player level, time spent in game, total current earned, etc… this gives you an easy way to make sure you’re never writing a smaller value over a larger one. It also make it easy to validate the cases where you’re accidentally writing nil over a stored value, or a small list over a larger one (e.g. a bug that would cause a player to lose a purchased item(s) or status is easily guarded against).

3 Likes

I use a slightly modified version of this module in my game, it is fully reliable if used properly. My players never lose data.

I like the idea of using UpdateAsync, as I’m super worried about players losing data. However, the only way data could be lost is if Roblox succeeded in accessing the DataStore, but failed to find any data (as then it would assign default data for the player). However, would UpdateAsync solve that problem? If GetAsync failed to find any data, you would think UpdateAsync would fail to find any data as well…Or am I wrong?

1 Like

Sorry for the late reply, but thanks for all of the input! It seems to work reliably so far (aside from some weird troubles transferring data), and I will be testing in a public server with large amounts of players soon.

Although can anybody verify whether or not @Mighty_Loopy is right and that the datastore retry is already embedded into the get/set methods?

Also, I’m starting to add Developer Products to the game. I based it off of this tutorial. I modified it to send discord messages on errors, and to better keep track of player purchases so I can look at them again if I need to later. Could somebody look and tell me if there are any problems with it?

Modified code
local ServerStorage = game:GetService("ServerStorage")
local MarketplaceService = game:GetService("MarketplaceService")
local DataStoreService = game:GetService("DataStoreService")
local PurchaseHistory = DataStoreService:GetDataStore("PurchaseHistory")

local GetGold = ServerStorage:WaitForChild("GetGold") --bindable function
local AddGold = ServerStorage:WaitForChild("AddGold") --bindable event
local SendErrorToDiscord = require(script:WaitForChild("SendErrorToDiscord"))
--^ function that lets me know whenever an error occurs on any server

local function BuyGold(player, amount)
	local currentGold = GetGold:Invoke(player)
	if currentGold == -99999 then --valid returned if gold not found
		return false
	else
		AddGold:Fire(player, amount)
		local newGold = GetGold:Invoke(player)
		if newGold > currentGold then
			return true --gold added successfully
		else
			return false --Didn't work for some reason
		end
	end
end

local Products = {
	[260376679] = function(receipt,player)
		--100 gold
		BuyGold(player, 100)
	end;
	
	[260376737] = function(receipt, player)
		--600 gold
		BuyGold(player, 600)
	end;
	
	[260376839] = function(receipt, player)
		--3600 gold
		BuyGold(player, 3600)
	end;
}
 
function MarketplaceService.ProcessReceipt(receiptInfo)
	local playerBoughtData = PurchaseHistory:GetAsync(receiptInfo.PlayerId)
	if not playerBoughtData then
		playerBoughtData = {} --player has no purchase history, create empty table
	end
	local granted = false
	for _,v in pairs(playerBoughtData) do
		if v.PurchaseId == receiptInfo.PurchaseId then
			granted = true
		end
	end
	if granted then
		return Enum.ProductPurchaseDecision.PurchaseGranted --We already granted it.
	end
	
	local player = game:GetService("Players"):GetPlayerByUserId(receiptInfo.PlayerId)
	if not player then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
 
	local handler = Products[receiptInfo.ProductId]
	
	if not handler then
		return Enum.ProductPurchaseDecision.PurchaseGranted
	end
	
	local startGold = GetGold:Invoke(player) --keep track of starting gold
 
	local suc,err = pcall(handler,receiptInfo,player)
	if not suc then
		warn("An error occured while processing a product purchase")
		print("\t ProductId:",receiptInfo.ProductId)
		print("\t Player:",player)
		print("\t Error message:",err)
		SendErrorToDiscord(receiptInfo, player, err)
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	
	if not err then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	
	suc,err = pcall(function()
		local data = {
			ProductKey = receiptInfo.ProductKey,
			ProductId = receiptInfo.ProductId,
			Cost = receiptInfo.CurrencySpent,
			NewGoldValue = GetGold:Invoke(player),
			OldGoldValue = startGold
		}
		table.insert(playerBoughtData, data)
		PurchaseHistory:SetAsync(receiptInfo.PlayerId, playerBoughtData)
	end)
	if not suc then
		print("An error occured while saving a product purchase")
		print("\t ProductId:",receiptInfo.ProductId)
		print("\t Player:",player)
		print("\t Error message:",err) -- log it to the output
		print("\t Handler worked fine, purchase granted") -- add a small note that the actual purchase has succeed
		SendErrorToDiscord(receiptInfo, player, err, true)
	end
	
	return Enum.ProductPurchaseDecision.PurchaseGranted		
end

That said, how important is it to keep track of player transactions? Do purchase histories need to be so detailed?

1 Like
1 Like