Save your player data with ProfileService! (DataStore Module)

With ProfileService, how can I save my data through Roblox Opencloud API datastores?

1 Like

Hey @loleris! I just started using your module, it works great in fact. I was making a module to manage my data with, though I have ran into a major problem. I don’t understand how to use the global updates feature, the description is very vague on it.

Basically, I have a DataPlugin:setGlobalKey(userId: number, updateType: string, sendData: dictionary) function that uses ProfileStore:GlobalUpdateProfileAsync(), and I have a DataPlugin:getGlobal Key(player: Player, keyName: string) function. I need help with the meanings of the functions, and how to set it up mostly.

I agree that the global update system for ProfileService is very confusing - Initially I thought I would give developers full control over writing and editing global update buffers, but that spawned lots of methods to manage all of it where as removing the option to edit global updates would minimize everything to one method to handle global updates. I believe there could be an easy modification to ProfileService to emulate that write-only global update simplified interface on top of the current methods.

This video features all the methods and where you need to call them to make global updates work Global Updates: ProfileService Tutorial Part 2 (Roblox Studio) - YouTube

I’ve looked at the api more, I think I understand it now. But it would be nice if you could give profileservice’s global updates and update.

Alright, I think I have the global updates working. But for some reason, I keep getting error 104 “cannot store dictionary in datastore”, while im sending the example code: {yes = "yes"} I don’t get it.

Check this post Save your player data with ProfileService! (DataStore Module) - #986 by loleris

Curious, if I want to do a player mailing service, where they can send people mail, would this be bad? As everytime a player sends mail, I’d have to get players data (more than likely an offline player)? My question is basically, what are the limits on stuff like GlobalUpdateProfileAsync? I already have a 15 second time delay, so players or spammers cant just join a server and instantly send a million messages, as well as prevent them from always trying to make a million data get requests, but I’m not sure how well of a solution this is?
(ignore my TODO’s, more just wanting to know if what I have in regards to the load and update is good and safe)

function MailService:Send(player, mailInfo)
	if table.find(self.SentMail, player) then
		return false, "Sending mail too fast!"
	end

	table.insert(self.SentMail, player)
	task.delay(MAIL_DELAY, function()
		local PlayerIndex = table.find(self.SentMail, player)
		if PlayerIndex then
			table.remove(self.SentMail, PlayerIndex)
		end
	end)

	local Data = DataService:Get(player)
	if not Data then
		return false, "Failed to find data!"
	end

	local RecipientData = DataService.ProfileStore:LoadProfileAsync("Player_" .. mailInfo.Receipient)
	if not RecipientData then -- bad??
		return false, "Failed to find data!"
	end

	-- TODO see if ReceipientData has too many messages

	-- TODO get mailInfo.Receipient UserId and apply below as key
	local ProfileKey = "Player_" .. player.UserId
	DataService.ProfileStore:GlobalUpdateProfileAsync(ProfileKey, function(globalUpdates)
		globalUpdates:AddActiveUpdate({
			Type = "Mail",
			Sender = player.Name,
			TimeSent = os.time(),
			Message = mailInfo,
		})
	end)
end
1 Like

I’m trying to load in the player’s data once the player joins but the script that loads the data runs before the module saves the data, is there a way to fix this?

How can I view a players GlobalUpdates? I have a way to send messages to offline players and it caps at 3 messages per player, however, their data doesnt update till they rejoin the server, so until then, you can just keep sending them messages, as I’m unsure how to check the global updates for if theyve already got 3 messages from you waiting?

DataService.ProfileStore:GlobalUpdateProfileAsync(ProfileKey, function(globalUpdates)
		globalUpdates:AddActiveUpdate({
			Type = "Mail",
			Sender = SenderId,
			TimeSent = tostring(os.time()),
			Message = FilteredMessage,
			-- TODO Send extra data??
		})
	end)

You should use the GetActiveUpdate method
image

If I try putting a table inside of the profile template table the table inside of the profile template table just returns as nil, even if I put an example string in there. I haven’t found any tutorials that show how to work with tables inside of the profile template and I’m stumped.

Is SkinTone a Color3 value? This could be resulting in this issue and you might need to do something like this.

Dw, I solved this a while back by just storing the skintones in a database that is indexed using strings e.g. “Blue”, “Green”. I should’ve made a reply when I solved it.

I need to do this too, I don’t get why Roblox makes it so hard to do this stuff

What’s wrong with my code and why can’t I just save folders?

local Players = game:GetService("Players")
local cachedProfiles = {}
local ProfileService = require(script.ProfileService)
local MarketplaceService = game:GetService("MarketplaceService")

local SETTINGS = {

	ProfileTemplate = {
		
		
		Cash = 0,
		Level = 0,
		Morphs = 0,
		Exp = 0,
		
		Inventory = {},
		

		
	},

	Products = { -- developer_product_id = function(profile)
		[1334632353] = function(profile)
			profile.Data.Cash += 100
		end,
		[1334633003] = function(profile)
			profile.Data.Cash += 250
		end,
		[1334633004] = function(profile)
			profile.Data.Cash += 550
		end,
		[1334633000] = function(profile)
			profile.Data.Cash += 1750
		end,
		[1334632999] = function(profile)
			profile.Data.Cash += 2500
		end,
		[1334633002] = function(profile)
			profile.Data.Cash += 6500
		end,
		[1334633001] = function(profile)
			profile.Data.Cash += 12750
		end,
	},

	PurchaseIdLog = 50, -- Store this amount of purchase id's in MetaTags;
	-- This value must be reasonably big enough so the player would not be able
	-- to purchase products faster than individual purchases can be confirmed.
	-- Anything beyond 30 should be good enough.

}


local PlayerProfileStore = ProfileService.GetProfileStore("PlayerData", SETTINGS.ProfileTemplate)




----- Private Functions -----

local function PlayerDataLoaded(player)
	local profile = cachedProfiles[player]
	
	local folder = Instance.new("Folder")
	folder.Name = "leaderstats"
	folder.Parent = player
	
	local cash = Instance.new("IntValue")
	cash.Name = "Cash"
	cash.Value = profile.Data.Cash
	cash.Parent = folder
	
	local level = Instance.new("IntValue")
	level.Name = "Level"
	level.Value = profile.Data.Level
	level.Parent = folder
	

	
	spawn(function()
		while true do
			local profile = cachedProfiles[player]
			
			if profile ~= nil then
				cash.Value = profile.Data.Cash
				level.Value = profile.Data.Level
			else
				break
			end
			
			wait(0.1)
		end
	end)
	
	print(player.Name .. "'s data is loaded")
end


function cachedProfiles.GiveCash(profile, amount)
	-- If "Cash" was not defined in the ProfileTemplate at game launch,
	--   you will have to perform the following:
	if profile.Data.Cash == nil then
		profile.Data.Cash = 0
	end
	-- Increment the "Cash" value:
	profile.Data.Cash = profile.Data.Cash + amount
end

function cachedProfiles.AddMorphValue(profile, amount)
	if profile.Data.Morphs == nil then
		profile.Data.Morphs = 0
	end
	profile.Data.Morphs = profile.Data.Morphs + amount
end

--[[local function DoSomethingWithALoadedProfile(player, profile)
	profile.Data.LogInTimes = profile.Data.LogInTimes + 1
	print(player.Name .. " has logged in " .. tostring(profile.Data.LogInTimes)
		.. " time" .. ((profile.Data.LogInTimes > 1) and "s" or ""))
	GiveCash(profile, 100)
	print(player.Name .. " owns " .. tostring(profile.Data.Cash) .. " now!")
end ]]


local function folderToDictionary(folder, dictionaryi)
	local dictionary = dictionaryi

	for _, object in ipairs(folder:GetChildren()) do
		if object:IsA("Folder") then
			dictionary[object.Name] = folderToDictionary(object)
		elseif object:IsA("ValueBase") or object.ClassName:match("Value") then
			dictionary[object.Name] = object.Value
		end
	end

	return dictionary
end




local function dictionaryToFolder(folder, dictionaryi)
	local dictionary = dictionaryi


	for i, v in pairs(dictionary) do 
		if folder:FindFirstChild(v) == nil then
			dictionary[v]:Clone().Parent = folder
		end
	end

--[[	for _, object in ipairs(folder:GetChildren()) do
		if object:IsA("Folder") then
			dictionary[object.Name] = folderToDictionary(object)
		elseif object:IsA("ValueBase") or object.ClassName:match("Value") then
			dictionary[object.Name] = object.Value
		end
	end ]]

	return dictionary
end











local function PlayerAdded(player)
	local profile = PlayerProfileStore:LoadProfileAsync("Player_" .. player.UserId, "ForceLoad")
	
	if profile ~= nil then
		profile:ListenToRelease(function()
			cachedProfiles[player] = nil
			player:Kick("Your profile has been loaded remotely. Please rejoin.")
		end)
		
		if player:IsDescendantOf(Players) then
			cachedProfiles[player] = profile
			
			
			PlayerDataLoaded(player)
			
			
		else
			profile:Release()
		end
	else
		player:Kick("Unable to load saved data. Please rejoin.")
	end
	
	
	local CharacterFolder = Instance.new("Folder")
	CharacterFolder.Name = "Inventory"
	CharacterFolder.Parent = player
	
--	dictionaryToFolder(player.Inventory, profile.Data.Inventory)
	
	
	
	--[[
	for i,v in ipairs(profile.Data.Inventory) do
--		local nv = Instance.new("ObjectValue")
--		nv.Name = i
		--		nv.Parent = CharacterFolder
		print(v.Name) --->>  bla,  bla2, bla3
	end]]
	
	if profile.Data.Inventory ~= nil then
		for i,v in pairs(profile.Data.Inventory) do
			if player.Inventory:FindFirstChild(v) == nil then
				game.ServerStorage._LeaderstatsInventoryValues[v]:Clone().Parent = player.Inventory
			end
		end
	end
	
	
	
	while true do
		wait(0.02)
		player.PlayerGui:WaitForChild("MainGui")._CashFrame.Title.Text = profile.Data.Cash
		--		contents.Cashig = profile.Data.Cash
		--		print(contents.Cashig)
	end
	
	
	
	
	
	
	
	
	
end




----- DevProduct Functions -----


function PurchaseIdCheckAsync(profile, purchase_id, grant_product_callback) --> Enum.ProductPurchaseDecision
	-- Yields until the purchase_id is confirmed to be saved to the profile or the profile is released

	if profile:IsActive() ~= true then

		return Enum.ProductPurchaseDecision.NotProcessedYet

	else

		local meta_data = profile.MetaData

		local local_purchase_ids = meta_data.MetaTags.ProfilePurchaseIds
		if local_purchase_ids == nil then
			local_purchase_ids = {}
			meta_data.MetaTags.ProfilePurchaseIds = local_purchase_ids
		end

		-- Granting product if not received:

		if table.find(local_purchase_ids, purchase_id) == nil then
			while #local_purchase_ids >= SETTINGS.PurchaseIdLog do
				table.remove(local_purchase_ids, 1)
			end
			table.insert(local_purchase_ids, purchase_id)
			task.spawn(grant_product_callback)
		end

		-- Waiting until the purchase is confirmed to be saved:

		local result = nil

		local function check_latest_meta_tags()
			local saved_purchase_ids = meta_data.MetaTagsLatest.ProfilePurchaseIds
			if saved_purchase_ids ~= nil and table.find(saved_purchase_ids, purchase_id) ~= nil then
				result = Enum.ProductPurchaseDecision.PurchaseGranted
			end
		end

		check_latest_meta_tags()

		local meta_tags_connection = profile.MetaTagsUpdated:Connect(function()
			check_latest_meta_tags()
			-- When MetaTagsUpdated fires after profile release:
			if profile:IsActive() == false and result == nil then
				result = Enum.ProductPurchaseDecision.NotProcessedYet
			end
		end)

		while result == nil do
			task.wait()
		end

		meta_tags_connection:Disconnect()

		return result

	end

end

local function GetPlayerProfileAsync(player) --> [Profile] / nil
	-- Yields until a Profile linked to a player is loaded or the player leaves
	local profile = cachedProfiles[player]
	while profile == nil and player:IsDescendantOf(Players) == true do
		task.wait()
		profile = cachedProfiles[player]
	end
	return profile
end

local function GrantProduct(player, product_id)
	-- We shouldn't yield during the product granting process!
	local profile = cachedProfiles[player]
	local product_function = SETTINGS.Products[product_id]
	if product_function ~= nil then
		product_function(profile)
	else
		warn("ProductId " .. tostring(product_id) .. " has not been defined in Products table")
	end
end

local function ProcessReceipt(receipt_info)

	local player = Players:GetPlayerByUserId(receipt_info.PlayerId)

	if player == nil then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

	local profile = GetPlayerProfileAsync(player)

	if profile ~= nil then

		return PurchaseIdCheckAsync(
			profile,
			receipt_info.PurchaseId,
			function()
				GrantProduct(player, receipt_info.ProductId)
			end
		)

	else
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end

end











----- Initialize -----





for _, player in ipairs(Players:GetPlayers()) do
	spawn(function()
		PlayerAdded(player)
	end)
end

Players.PlayerAdded:Connect(PlayerAdded)
Players.PlayerRemoving:Connect(function(player)
	local profile = cachedProfiles[player]
	if profile ~= nil then
		
		
		for i, v in pairs(player.Inventory:GetChildren()) do
			table.insert(profile.Data.Inventory, v.Name)
		end
			
			
		
		
		profile:Release()
	end
end)

return cachedProfiles

I’ve tried LITERALLY everything on the internet from normal datastore folder saving tutorials to tool saving tutorials and nothing works! I can’t find any tutorials related to ProfileService to do this stuff. The error now says that the script can’t find “Cash” inside of the folder provided in line 216, but cash isn’t really being stored in that table OR folder so what does “Cash” even have anything to do with the dang script?!

Hello , i have a quick technical question about GlobalUpdate.
Imagine i send an Update to a profile that is in the same server , will it perform a datastore request or no ?

Hey there @loleris, I’m in a bit of a profile pickle…
I am using ProfileService for a trading/transaction related system - a piece of data has to be removed from one Player1’s data and transfered to Player2’s data.
The issue is that I need this data exchange to be possible even after Player1 leaves. This would usually take place only a few seconds after the player leaves, but either way I would have to yield before releasing the profile after the player leaves, which is bad practice according to the documentation (Even specifically called “Mistake example #3”).
Is it really that bad to hold the profile for a bit? Do you have any tips on what I could do to securely handle a trade even if one player leaves?

If you want safe trades you need to employ an all or nothing mentality. Both players need to accept, be present in the game and with profiles not released - then immediately perform the trade exchange with all these conditions met without yielding between condition check and item transaction between active profiles. If a player leaves then it should better be treated as a decline condition.

2 Likes

Thanks for the reply! My system is a purchase system, where Player1 buys Player2’s item by purchasing an asset/gamepass that Player2 has connected to the in-game item.
The problem is that you can only (afaik, please let me know otherwise) detect a purchase being finished using PromptPurchaseFinished. Unfortunately, this fires not when the player purchases, but when they click “Close” on their prompt after purchasing!
The issue arises if Player1 buys the asset, and before they click “Close” on the prompt, Player2 leaves. I still need the exchange to register. Any ideas?

There are so many problems that can happen in your case (seller takes item off sale before product purchase registers, PS profile released in the middle, two people buy the same thing at the same time (good luck solving that perfectly) etc.) and some of them can’t even be reliably ensured (or work fast enough) on Roblox with the API we have today, that I can’t really guide you here.

I am aware that games already pull this off, so maybe just focus on making it work 99% of the time AND if it doesn’t bother you too much (maybe players in your game don’t really have a strong reason to exploit that to a point where it would hurt your game), then don’t worry about item duplication where the seller wouldn’t lose their copy of the item cause that way you would take a lot of work off your shoulders.