Save your player data with ProfileService! (DataStore Module)

I am getting error “tables cannot be cyclic”, how do I fix.

Code (this is not the full code). The code is inside a module script called Data Manager which is parent of profile service module:

local function PlayerAdded(Plr)
	local PlayerProfile = ProfileStores.PlayerData:LoadProfileAsync(
		"Plr_"..Plr.UserId,
		"ForceLoad"
	)
	if (not PlayerProfile) then
		Plr:Kick("Data failed to load.")
	else
		PlayerProfile:Reconcile()
		PlayerProfile:ListenToRelease(function()
			Profiles.PlayerData[Plr] = nil
			Plr:Kick()
		end)
		if (Plr:IsDescendantOf(Players)) then
			Profiles.PlayerData[Plr] = PlayerProfile
			Bindables.OnPlayerProfileLoaded:Fire(Plr,PlayerProfile) -- Error is here
		else
			PlayerProfile:Release()
		end
	end
end

Leaderstats (a different script):

DataManager:OnPlayerProfileLoaded(function(Plr,Profile) -- This listens to the bindable event fired
	local Wins = Plr:WaitForChild("leaderstats"):WaitForChild("Wins")
	Wins.Value = Profile.Data.Wins
end)

If you want the full scripts I can private message them to you.

Edit: This only seems to be happening when I pass the profile as an argument. If I pass for e.g an empty table it works. How do I fix this?

BindableEvents can’t send live references to objects (objects are just tables with data). A BindableEvent will attempt to make a copy of a table, but, in the case of copying a Profile object, it will fail due to circular references being used in the ProfileService system.

Consider using a custom signal module (instead of BindableEvents) like MadworkSignal which can pass live references - It’s part of the ReplicaService package.

2 Likes

Does the signal module support yielding?

Edit: Replaced pointer(...) with coroutine.wrap(pointer)(...). I don’t think this will have any performance problems because the event will only fire when player joins.

Yeah! Nice job on figuring it out lol XD - This change enables yielding for the MadworkScriptSignal system

3 Likes

Just one question will this be compatible into making game leaderboards and uses API like GetOrderedDataStores() and does session locking actually prevent this type of issues, second, does it have functions like Increment() or Set() because it’s just so long to write profile.Data.Cash = profile.Data.Cash + numberHere and lastly, do you have a function that sets table’s or not or it just automatically just saves it when it is inserted? if not then I’d like an example from you

  1. Refer to my method here, although I’m sure this isn’t the best method.
  1. No it doesn’t, but you could implement your own! Also, Luau has compound operators!
  2. What do you mean by setting a table? Every time ProfileService saves, whatever data is in the Data field in the Profile is the data that is saved.

ProfileService is NOT for making leaderboards / creating ordered OrderedDaraStores. It’s not designed for it nor it would make it easy to do it. OrderedDaraStores don’t need nearly as many precautions (messing them up is rarely a tragedy) and are easy to implement on their own.

Instead of:
profile.Data.Cash = profile.Data.Cash + numberHere

Use:

local data = profile.Data
data.Cash += number_here

ProfileService keeps Profile.Data saved to the DataStore - you can put mostly any kind of values inside and it will save as long as Profile:IsActive() returns true or until the profile is released.

1 Like

Ok that is good but here is another issue isn’t Dictionaries Saveable into DataStores? because here is an issue right here:

 15:17:23.788  [ProfileService]: DataStore API error - "104: Cannot store Dictionary in data store. Data stores can only accept valid UTF-8 characters."

For the Sample Data Here it is:

local PlayerDataSample = {
    Stats = {
        Coins = 0;
        Level = 1;
        EXP = 0;
        MaxEXP = 50;
        Class = "Mage"; -- {Mage, Warrior, Tank}
        Quests = 0;
        MaxQuests = 5; -- maximum is 5
        Rank = "Rookie";
        DoubleSword = false;
        Banned = false;
        SpellPoints = 1;
        PhysicalPoints = 1;
        Stamina = 1;
    };

    SetUp = {
        Helmet = "none";
        Armor = "none";
        Pants = "none";
        Boots = "none";
        Sword = "none";
        DualSword = "none"
    };

    Equipment = {
        Armors = {
            Chestplates = {

            };

            Helmets = {

            };

            Pants = {

            };
        };
    };

    Tools = {
        Potions = {

        };

        Weapons = {
            ["Wooden Sword"] = {
                Path = game.ServerStorage.Items.Weapons.Warrior["Wooden Sword"];
                Damage = 1;
                ClassFor = "Warrior";
                Type = "Sword";
                Rarity = "Common";
                ChanceToGet = 100;
                Physical = 1;
                Spell = 1;
                Upgrades = 0;
                MaxUpgrades = 15;
                Debounce = false;
                CanDamage = false;
                IsTradeable = false;
            }
        };
    };

    Spells = {
        --[[
        ["SPELL_NAME"] = {
            Class = "Mage";
            BaseDamage = 0;
            LevelRequired = 0;
            SellAmount = 0;
            Rarity = "Common";
            ChanceToGet = 0;
            SellAmount = 0;
            IsTradeable = false;
        }
        ]]
    };

    Quests = {
        -- for examples only
        --[[
            ["QuestName"] = {
                IsCompleted = false;
                XPWorth = 0;
                CoinsWorth = 0;
                QuestDescription = "Kill 1 Thing";
                ThingsDone = 1;
                IsFromNPC = "nil"
            }
        ]]
    }
}

and the loading code the same code you gave but edited

-- Init profile service
    local GameProfileStore = ProfileService.GetProfileStore(
        "PlayerData",
        PlayerDataSample
    )
    
    local function Debug(profile)
        if profile ~= nil then
            print("Successfully loaded data!")
        else
            print("Failed to load Data")
        end
    end

    local function KickPlayer(Player)
        local PlayerData = GameProfileStore:LoadProfileAsync(
            "Player_" .. Player.UserId,
            "ForceLoad"
        )

        if PlayerData ~= nil then
            PlayerData:Reconcile() -- Fill in missing variables from ProfileTemplate (optional)
            PlayerData:ListenToRelease(function()
                Profiles[Player] = nil
                -- The profile could've been loaded on another Roblox server:
                Player:Kick()
            end)
            if Player:IsDescendantOf(Players) == true then
                Profiles[Player] = PlayerData
                -- A profile has been successfully loaded:
                Debug(PlayerData)
            else
                -- Player left before the profile loaded:
                PlayerData:Release()
            end
        else
            -- The profile couldn't be loaded possibly due to other
            --   Roblox servers trying to load this profile at the same time:
            Player:Kick() 
        end

        spawn(function()
            if BannedPlayers[tostring(Player.Name)] then
                Player:Kick("You are permanently Banned from this game, you are not welcome.")
            elseif PlayerData.Stats.Banned == true then
                Player:Kick("You are permanently Banned from this game, you are not welcome.")
            else
                print(Player.Name.." is authorized for this game!")
            end
        end) 
    end
    
    local function PlayerAdded(Player)
        print("Loading Data for "..Player.Name)

        local PlayerData = GameProfileStore:LoadProfileAsync(
            "Player_" .. Player.UserId,
            "ForceLoad"
        )

        if PlayerData ~= nil then
            PlayerData:Reconcile() -- Fill in missing variables from ProfileTemplate (optional)
            PlayerData:ListenToRelease(function()
                Profiles[Player] = nil
                -- The profile could've been loaded on another Roblox server:
                Player:Kick()
            end)
            if Player:IsDescendantOf(Players) == true then
                Profiles[Player] = PlayerData
                -- A profile has been successfully loaded:
                Debug(PlayerData)
            else
                -- Player left before the profile loaded:
                PlayerData:Release()
            end
        else
            -- The profile couldn't be loaded possibly due to other
            --   Roblox servers trying to load this profile at the same time:
            Player:Kick() 
        end

btw the last function without the end isn’t the bug the loading is

Saving same as the old code

local function PlayerRemoved(Player)
        local Backpack = Player.Backpack
        local profile = Profiles[Player]

        if profile ~= nil then
            profile:Release()
        end
    end
1 Like

Would be helpful if you read through the troubleshooting page first as it seems you’re trying to store Roblox instances which are not serializable (“Path” member).

so

game.ServerStorage.PathHere

makes the bug?

For leaderboards that means we save a duplicate of the data, like Data.Cash in a separate normal data store and use that for leaderboards?

Yeah, to create a leaderboard you update a snapshot of player’s score in an ordered datastore.

1 Like

Yes. It seems like you are saving too much to the DataStore for your items. Simply save the weapon’s identifier and any metadata, and store all of the extra stuff (Path,ClassFor,Type,ChanceToGet,MaxUpgrades,Debounce,CanDamage,IsTradeable) for items in a database.

I’m quite bad at reading codes, how can we make a global leaderboard with this? sorry if this has already been answered

This module is not for making leaderboards (normal datastore works fine for it) or even currency. This module used mainly to save things like inventory for example.

so I would have to use normal datastore for that?

1 Like

Really cool! I just found out about ProfileService and ReplicaService after looking for clean open source solutions for data in new games. Why re-invent the wheel when someone else has done the work for you? I’ll definitely be using these. Thanks!

8 Likes

Is the Profile.Data saved in a single key or multiple keys? Because what if I reach the max data store limit, does this automatically distributes it over to several keys, or data fails to save?

Asking this because in my game there will be an inventory system with no limit of the number of items, each with their own properties saved, and I am not planning to add any sort of compression on the data that will be saved. Do I need to worry of it reaching the limit or is it too high that no one will reach it?

ProfileService uses a single DataStore key - the limit for storing under one profile is less than 4 megabytes. 4 megs is a heck ton of data, but you should make minimal estimates on how much data you’re going to take up. Consider stacking data that can be stacked and not make small redundant differences in objects that are still more or less the same.

Using the ProfileService, it’s giving me an error through the Service’s line of code

13:53:44.591  BindToClose can only be called on the server. -  Client -  Profile Service:1511
  13:53:44.591  Stack Begin  -  Studio
  13:53:44.591  Script 'ReplicatedStorage.Modules.Profiles.Profile Service', Line 1511  -  Studio  -  Profile Service:1511
  13:53:44.592  Stack End  -  Studio

Here’s the line of code, if needed.

game:BindToClose(function()
	if UseMockDataStore == true then
		return -- Ignores OnCloseTasks if ProfileService is running on MockDataStore
	end
	ProfileService.ServiceLocked = true
	-- 1) Release all active profiles: --
	-- Clone AutoSaveList to a new table because AutoSaveList changes when profiles are released:
	local on_close_save_job_count = 0
	local active_profiles = {}
	for index, profile in ipairs(AutoSaveList) do
		active_profiles[index] = profile
	end
	-- Release the profiles; Releasing profiles can trigger listeners that release other profiles, so check active state:
	for _, profile in ipairs(active_profiles) do
		local is_active = profile._profile_store._loaded_profiles[profile._id] == profile
		if is_active then
			on_close_save_job_count = on_close_save_job_count + 1
			coroutine.wrap(function() -- Save profile on new thread
				SaveProfileAsync(profile, true)
				on_close_save_job_count = on_close_save_job_count - 1
			end)()
		end
	end
	-- 2) Yield until all active profile jobs are finished: --
	while on_close_save_job_count > 0 or ActiveProfileLoadJobs > 0 or ActiveProfileSaveJobs > 0 do
		RunService.Heartbeat:Wait()
	end
	return -- We're done!
end)

Is it because im calling the profile service in a local script or something? Can’t seem to find out what’s the cause of this.