Seeking Expertise: Issues with Saving Player Currency Data in DataStore!

I’m seeking your expertise to review my script that saves player currency data using DataStore. I have a few concerns:

  1. API Limits: Could my script potentially hit API limits? If so, what strategies can I implement to avoid this?
  2. Data Loss: Are there any common pitfalls that could lead to data loss for players?
  3. General Suggestions: Any tips or best practices for optimizing DataStore usage would be greatly appreciated!

Here’s the relevant portion of my script:

local DataStoreService = game:GetService("DataStoreService")
local CurrencyDataStore = DataStoreService:GetDataStore("PlayerCurrencyData1")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local SaveQueue = {}
local IsSaving = {}
local SAVE_INTERVAL = 60 

local CURRENCY_1 = "Coins"
local CURRENCY_2 = "Gems"
local DEFAULT_CURRENCY_1 = 100
local DEFAULT_CURRENCY_2 = 0
local SYNC_INTERVAL = 5 

local RemoteFolder = ReplicatedStorage:FindFirstChild("RemoteEvents") or Instance.new("Folder")
RemoteFolder.Name = "RemoteEvents"
RemoteFolder.Parent = ReplicatedStorage

local CurrencyUpdateEvent = RemoteFolder:FindFirstChild("CurrencyUpdate") or Instance.new("RemoteEvent")
CurrencyUpdateEvent.Name = "CurrencyUpdate"
CurrencyUpdateEvent.Parent = RemoteFolder

local function formatNumber(number)
	if not number or type(number) ~= "number" then
		return "0"
	end

	if number >= 1000000 then
		local formatted = number / 1000000
		if formatted == math.floor(formatted) then
			return math.floor(formatted) .. "M"
		else
			return string.format("%.1fM", formatted):gsub("%.0+M$", "M")
		end
	elseif number >= 1000 then
		local formatted = number / 1000
		if formatted == math.floor(formatted) then
			return math.floor(formatted) .. "K"
		else
			return string.format("%.1fK", formatted):gsub("%.0+K$", "K")
		end
	else
		return tostring(math.floor(number))
	end
end

local function loadPlayerData(player)
	local playerKey = "ID_" .. player.UserId
	local success, data = pcall(function()
		return CurrencyDataStore:GetAsync(playerKey)
	end)

	if success and data then
		return data
	else
		return {[CURRENCY_1] = DEFAULT_CURRENCY_1, [CURRENCY_2] = DEFAULT_CURRENCY_2}
	end
end

local function savePlayerData(player, immediate)
	local playerKey = "ID_" .. player.UserId
	local playerUserId = player.UserId
	local data = {
		[CURRENCY_1] = player:GetAttribute(CURRENCY_1) or DEFAULT_CURRENCY_1,
		[CURRENCY_2] = player:GetAttribute(CURRENCY_2) or DEFAULT_CURRENCY_2
	}

	if immediate then
		if IsSaving[playerUserId] then
			warn("Already saving currency data for player: " .. player.Name)
			return
		end

		IsSaving[playerUserId] = true
		local success, err = pcall(function()
			CurrencyDataStore:SetAsync(playerKey, data)
		end)
		IsSaving[playerUserId] = nil

		if not success then
			warn("Failed to save player data for " .. player.Name .. ": " .. err)
		else
			print("Immediately saved currency data for player:", player.Name)
		end
	else
		SaveQueue[playerUserId] = {
			Key = playerKey,
			Data = data,
			LastUpdate = os.time()
		}
		print("Queued currency save for player:", player.Name)
	end
end

task.spawn(function()
	while true do
		task.wait(SAVE_INTERVAL)

		local playersToSave = {}
		for userId, saveData in pairs(SaveQueue) do
			table.insert(playersToSave, {userId = userId, saveData = saveData})
			SaveQueue[userId] = nil
		end

		for _, data in ipairs(playersToSave) do
			local userId = data.userId
			local saveData = data.saveData

			if not IsSaving[userId] then
				IsSaving[userId] = true
				local success, errorMessage = pcall(function()
					CurrencyDataStore:SetAsync(saveData.Key, saveData.Data)
				end)
				IsSaving[userId] = nil

				if not success then
					warn("Error while batch saving currency data: " .. errorMessage)
					SaveQueue[userId] = saveData
				else
					print("Batch saved currency data for player ID:", userId)
				end

				task.wait(0.2)
			else
				SaveQueue[userId] = saveData
			end
		end
	end
end)

local function updatePlayerCurrency(player, currency, amount)
	local oldValue = player:GetAttribute(currency) or 0
	player:SetAttribute(currency, amount)

	print(player.Name .. "'s " .. currency .. " updated from " .. oldValue .. " to " .. amount)

	CurrencyUpdateEvent:FireClient(player, currency, amount, formatNumber(amount))
end

local function syncPlayerData(player)
	local data = loadPlayerData(player)
	local currentCoins = player:GetAttribute(CURRENCY_1)
	local currentGems = player:GetAttribute(CURRENCY_2)

	if data[CURRENCY_1] ~= currentCoins then
		updatePlayerCurrency(player, CURRENCY_1, data[CURRENCY_1])
	end

	if data[CURRENCY_2] ~= currentGems then
		updatePlayerCurrency(player, CURRENCY_2, data[CURRENCY_2])
	end
end

local function forceSyncPlayer(player)
	task.spawn(function()
		syncPlayerData(player)
	end)
end

game.Players.PlayerAdded:Connect(function(player)
	local data = loadPlayerData(player)

	player:SetAttribute(CURRENCY_1, data[CURRENCY_1])
	player:SetAttribute(CURRENCY_2, data[CURRENCY_2])

	CurrencyUpdateEvent:FireClient(player, CURRENCY_1, data[CURRENCY_1], formatNumber(data[CURRENCY_1]))
	CurrencyUpdateEvent:FireClient(player, CURRENCY_2, data[CURRENCY_2], formatNumber(data[CURRENCY_2]))

	task.spawn(function()
		while player and player.Parent do
			task.wait(SYNC_INTERVAL)
			syncPlayerData(player)
		end
	end)
end)

game.Players.PlayerRemoving:Connect(function(player)
	savePlayerData(player, true)
end)

game:BindToClose(function()
	for _, player in pairs(game.Players:GetPlayers()) do
		savePlayerData(player, true)
	end
end)

local CurrencySystem = {}

function CurrencySystem.AddCurrency(player, currency, amount, immediate)
	if currency ~= CURRENCY_1 and currency ~= CURRENCY_2 then
		warn("Invalid currency type: " .. currency)
		return false
	end

	local currentAmount = player:GetAttribute(currency) or 0
	local newAmount = currentAmount + amount
	updatePlayerCurrency(player, currency, newAmount)

	task.spawn(function()
		savePlayerData(player, immediate)
	end)

	return true
end

function CurrencySystem.SetCurrency(player, currency, amount, immediate)
	if currency ~= CURRENCY_1 and currency ~= CURRENCY_2 then
		warn("Invalid currency type: " .. currency)
		return false
	end

	updatePlayerCurrency(player, currency, amount)

	task.spawn(function()
		savePlayerData(player, immediate)
	end)

	return true
end

function CurrencySystem.GetCurrency(player, currency)
	if currency ~= CURRENCY_1 and currency ~= CURRENCY_2 then
		warn("Invalid currency type: " .. currency)
		return 0
	end

	return player:GetAttribute(currency) or 0
end

function CurrencySystem.GetFormattedCurrency(player, currency)
	local value = CurrencySystem.GetCurrency(player, currency)
	return formatNumber(value)
end

function CurrencySystem.SyncAllPlayers()
	for _, player in pairs(game.Players:GetPlayers()) do
		task.spawn(function()
			syncPlayerData(player)
		end)
	end
end

function CurrencySystem.SyncPlayer(player)
	if player and player.Parent then
		forceSyncPlayer(player)
		return true
	end
	return false
end

function CurrencySystem.FormatNumber(number)
	return formatNumber(number)
end

return CurrencySystem```

Hello, I have one concern and that is about if the datastores are unusally slow that day and the player leaves fairly quickly (e.g. due to a network error so around half a second) they would lose all their currency & get it overwritten by default data.

You should check to see if their data is being loaded and exit the saving function early if not :derp:

p.s. Back in 2022 we used to have the same issue in Pvp Sword Fighting, it caused a lot of losses because the game was apparently using a generalized “obby” datastore script & a kick due to “network error” triggered it because close to instant leaving. No biggie though! Most of the losses were reported to us and rollbacked quickly via datastore versions.

From skimming the code it looks good to me. If you want to make the code cleaner you could put the CurrencySystem inside a module.