Save your player data with ProfileService! (DataStore Module)

@oniich_n There you go :smiley:

This is the code part in the documentation that is relevant to you:

local RunService = game:GetService("RunService")
local GameProfileStore = ProfileService.GetProfileStore("PlayerData", ProfileTemplate)
if RunService:IsStudio() == true then
  GameProfileStore = GameProfileStore.Mock
end
3 Likes

Awesome! Thank you so much :smiley:

How difficult would it be to convert my game’s current datastore setup into this without losing existing player data?

I already know this module autosaves, but how often?
Does it autosave on data change, no matter what? Or does it have a set interval that data changes get saved?
I’m going to add audio settings to my game so players don’t have to redo their preexisting settings, but I don’t want exploiters or just people repeatedly changing their settings to spam the remote to change their settings, and start to throttle my datastore.

It autosaves every 30 seconds, but every request is spread throughout the minute (it doesn’t just request data for every single player at the same time).

I’ve seen a lot of people ask about the whole concept of session locking and why UpdateAsync needs to be used to both set AND load a save. I feel like I could’ve done a better job explaining it in my video now that I understand it a bit better, but that ship has sailed. Maybe in the future. For those of you skeptical about the session locking guarantee that ProfileService offers, I’ll try to explain what happens atomically. Even the most experienced of programmers might learn something.

2 servers send an UpdateAsync request at the same time with the following code. This code loads a player’s saved data:

-- Initially data.SessionJobId will be nil (because no server has the player's data loaded)
print("Sending update async request")
local loadedData = PlayerStore:UpdateAsync(key, function(data)
    if data == nil then
        data = {}
    end
    print("Current SessionJobId: " .. tostring(data.SessionJobId))
    -- Check if the save doesn't have a session lock OR if it's locked by this server. --
    if data.SessionJobId == nil or data.SessionJobId == game.JobId then
        data.SessionJobId = game.JobId
        print("No session lock active, setting SessionJobId to " .. game.JobId)
        -- Handle any fetching or writing right here --
        
        return data
    else
        print("Session already locked")
        return nil -- Returning nil in UpdateAsync only cancels the query. IT DOES NOT SET THE DATA TO NIL.
    end
end))
print("Update async complete")
if loadedData ~= nil then
    print("Loaded data!")
else
    print("Could not load data due to active session lock")
end

It’s not possible to guarantee which server will be processed first as it’s up to Roblox’s internal network speed along with other small factors, but whichever server made the request that reaches Roblox’s datastore servers first we will label Server A. The other one is Server B.
Server A will have a JobId of efcc7b83-df93-4b44-abfd-b957d4182bba.
Server B will have a JobId of 6fd555f0-cedd-47bd-916c-73677dc8c314.

Server A’s server output will look like this:

--- SERVER A OUTPUT ---
Sending update async request
Current SessionJobId: nil
No session lock active, setting SessionJobId to efcc7b83-df93-4b44-abfd-b957d4182bba
Update async complete
Loaded data!

Server B’s server output will look like this, assuming that the 2 UpdateAsync requests were sent at the exact same time from different servers concurrently trying to modify the same key:

--- SERVER B OUTPUT ---
Sending update async request
Current SessionJobId: nil
No session lock active, setting SessionJobId to 6fd555f0-cedd-47bd-916c-73677dc8c314
Current SessionJobId: efcc7b83-df93-4b44-abfd-b957d4182bba
Session already locked
Update async complete
Could not load data due to active session lock

If you look at server B’s output, you can see that it appears the callback function ran twice (each time with different data in data), despite us only using 1 UpdateAsync request. The reason why Server B’s updateasync callback ran twice is because that’s exactly how UpdateAsync works and why you should never use SetAsync to save player data.


It’s a very rare occurance, but Roblox’s databases will internally figure out which server should be processed first and if there’s any other requests made to that same key at the same time, they will all be rejected and the game servers that made them will have to re-process their callback functions. This makes it virtually impossible for 2 servers to have the same profile loaded at once.

Of course when releasing a profile, network errors happen, server crashes happen. This is why ProfileService might take up to 90 seconds to “steal” a profile (which is a lot better than an infinite amount of time!)

28 Likes

Only problem I had with this module is that if I want to teleport people to different worlds in my game, it well have super long load times, longer then the 10 seconds. But everything else is working great. Maybe am doing something wrong tbh, any help is appreciated.

Whenever your server is about to teleport the player (or group of players), make sure to release their profile.

2 Likes

I’m saving a table with this Module, but I have an issue. New value added to the table won’t save to the DataStore and will be nil. For ex, I have a table: {val1 = 0}. If I run the game and later add for example val2 = false in the table: {val1 = 0, val2 = false} then it won’t save val2 to the DataStore. I don’t know if this is intentional or not, but I have to change the DS key everytime I want to add a new value to the table.

Does it only happen if it saves the value as false or does it happen with any new key with any new value added? If it only happens with false, it might be something in ProfileService that’s not explicitly checking for nil.

It also happens to number value.

Oh nevermind, sorry to bother. Seems like it worked now. :grin:

Just so there is no confusion. I don’t have to release a profile before attempting to teleport a player. I can use PlayerRemoving to release a profile right? And in most cases, profiles released with PlayerRemoving will be released before the next server tries to load the the profile?

1 Like

Technically yes, however just to be safe I would Release the profile before Teleporting so it’s Released faster.

There is no point in keeping the Data anyways if you can guarantee that the player will leave the current server for sure. (Teleport functions can fail)

Hard to say, Datastore could be unreliable at times, or your code might fail, so it’s better to be safe than sorry.

1 Like

I’m assuming that in 98.04% of cases, teleporting will succeed and profiles will be released on .PlayerRemoved before the next server gets to loads them.

If I don’t release the profile before teleporting a player then I wont have to reload their profile in the unlikely chance that the teleport fails.

And on the off chance that datastores are working slowly for whatever reason then the worst outcome is that I have to wait a little bit longer on the second server for the profile to be released on the first server. ProfileService will handle the retrying.

I didn’t think of this before but, if you follow the basic usage of kicking the player from the server when their profile gets released then you cannot call :release() before trying to teleport the player.

This is already defined in the official documentation.
Screenshot_1
ProfileService will not be angry at you for calling Profile:Release() multiple times. Also, you will need to alter the example code to not kick players on release when a teleport is imminent. Let me know if releasing before teleports help your issue.

3 Likes

I don’t actually have any issue, it’s just speculation and planning.

For now I will just accept the 5% chance of up to 15 extra seconds. ‘up to’: meaning it can be less?
If the game gets players I will reevaluate this. It might be less of a problem in my game since players can view interstitials while teleporting and loading.

I also want to know how this scales:

If every player in the server owns two profiles will it be 20-36% of the datastore call budget? And I assume this is also dependent on the autosave interval used?

Yeah it would scale linearly with increased profile count per player and not per player count itself as the available budget increases with more players. Use the upper value to be safe.

Below (ProfileStore.Mock) should be WipeProfileAsync I guess

Can someone make an example of using metatags as purchase confirmation?
I dont need the whole thing… Just how the ending would look like before returning PurchaseGranted

Like this? Havent tested it.

profile:SetMetaTag(receiptInfo.ProductId, true)
profile:Save()
Confirm that profile.MetaData.MetaTagsLatest[receiptInfo.ProductId] exists
profile.MetaData.MetaTags[receiptInfo.ProductId] = nil (Will MetaTagsLatest also update?)
return Enum.ProductPurchaseDecision.PurchaseGranted

Snippet from Madwork:

-- PURCHASE CONFIRM:
function PlayerProfile:PurchaseIdCheck(purchase_id) --> "GrantProduct" / "PurchaseConfirmed" / "Yield" / "NotProcessed"
	VerifyString(purchase_id, "purchase_id")
	if self._view_mode == true then
		error("[PlayerProfileService]: Can't confirm purchases for player profile in view mode")
	elseif self._entity == nil then
		return "NotProcessed"
	else
		local meta_data = self._profile.MetaData
		local local_purchase_ids = meta_data.MetaTags["ProfilePurchaseIds"]
		local saved_purchase_ids = meta_data.MetaTagsLatest["ProfilePurchaseIds"] or {}
		if local_purchase_ids == nil then
			local_purchase_ids = {}
			meta_data.MetaTags["ProfilePurchaseIds"] = local_purchase_ids
		end
		if table.find(local_purchase_ids, purchase_id) == nil then
			while #local_purchase_ids >= sett_PurchaseIdHistory do
				table.remove(local_purchase_ids, 1)
			end
			table.insert(local_purchase_ids, purchase_id)
			return "GrantProduct"
		elseif table.find(saved_purchase_ids, purchase_id) == nil then
			return "Yield"
		else
			return "PurchaseConfirmed"
		end
	end
end

(PlayerProfileService is an extension of ProfileService in Madwork - you should be able to remake this function for your needs though; Variable sett_PurchaseIdHistory is set to 30)
The purchase receipt callback must be yielded by a loop that calls this function every second.
Make the function return “NotProcessed” when Profile:IsActive() == false at which point you would break the loop.

6 Likes