Major issue regarding player collectables & badges

I am the lead developer of a badge-hunting game and have faced a rather pertinent problem in regard to player data. I have compiled everything I’ve discovered so far:

A recent update which changed the player data format also had a bug which replaced everybody’s data with that of the first joiner in their server. This was largely fine, as I could simply add a new variable to the default player data, and roll back all data of the new format which did not contain it, to a version prior to the update, which would then be converted to the new format, containing said new variable.

However, this means this means that some COLLECTABLES are not marked as collected, despite the user owning their badge, due to having collected it during the span of the update (while having bugged internal player data).

So typically, on COLLECTABLE collect:

  1. Save COLLECTABLE to datastore (thus they should always be synced)
  2. Earn BADGE (this used to not work, so some early BADGES do not match the datastore values, which should also be addressed)

OPTION 1: So, given everything above, if data ever de-syncs with the BADGE list (as it has after the aforementioned rollback), then for each COLLECTABLE:

  1. Check if PLAYER has COLLECTABLE but not BADGE (award BADGE)
  2. Check if PLAYER has BADGE but not COLLECTABLE (award COLLECTABLE. This is due to a2)

This cannot be done without immediately running into a rate limit, as the game has nearly 100 badges.
CheckUserBadgesAsync has a limit of 10 badges per request & this is 10 requests per player. The ratelimit for CheckUserBadgesAsync is 10 + 5 * [number of players (in the server?)] per minute. The bellow code, which is supposed to act on the above instructions happens to run into this rate limit almost immediately after scanning a single player:

local function checkPlayerBadges(Player)
	local badgeIds, idToGuy = {}, {}
	for guy, info in CollectableInfo do
		local badgeId = info[9]
		if badgeId == 0 then continue end
		table.insert(badgeIds, badgeId)
		idToGuy[badgeId] = guy
	end
	
	local Data = REPLICATED_STORAGE.PlayerData:WaitForChild(Player.Name)
	local currentSave = Data:GetAttribute("currentSave")
	
	for _, batch in chunkList(badgeIds, 10) do
		local ok, owned = withRetries(function()
			return BADGE:CheckUserBadgesAsync(Player.UserId, batch)
		end, 30)

		if not ok then
			warn("Badge‐check failed for batch: " .. table.concat(batch, ", "))
			continue
		end

		for _, badgeId in batch do
			local guy = idToGuy[badgeId]
			local hasGuyInSave = Data.saves[currentSave].guys:GetAttribute(guy) ~= nil
			local hasBadge     = table.find(owned, badgeId) ~= nil

			if hasBadge and not hasGuyInSave then
				Data.saves[currentSave].guys:SetAttribute(guy, 0)
			elseif hasGuyInSave and not hasBadge then
				local success, err = pcall(function()
					BADGE:AwardBadge(Player.UserId, badgeId)
				end)
				if not success then
					warn("Failed to award badge "..badgeId..": "..tostring(err))
				end
			end
		end
	end
end

Explanatory flow chart:


^ this is bound to fail.

OPTION 2: Use external APIs.
Here are the 2 relevant APIs I could discover:

  1. https://badges.roproxy.com/v1/users/{USERID}/badges/awarded-dates?badgeIds=
    This only ever checks for the first badge in “badgeIds”. Making hundreds of calls on join is simply unfeasable.
  2. https://badges.roproxy.com/v1/users/{USERID}/badges?limit=100&sortOrder=Asc
    This checks for all of a PLAYER’s badges, not just the relevant ones, & most importantly would not work if the PLAYER’s inventory happened to be disabled.

OPTION 3: “Recount Badge” button:
The last option would be to implement a manual “Recount Badge” button for each COLLECTABLE (since this would only check for a singular badge instead of going through all of them, bypassing rate limits), which would be horrible for the user, since:

  1. PLAYERS will now have to be aware of what a BADGE is and manually check for any discrepancies. This would further clutter UI & make the game more confusing.
  2. PLAYERS can still choose to keep inaccurate data. It is entirely up to them to manually check each offending COLLECTABLE.

To summarize:


Any help on this would be greatly appreciated!

1 Like

Update to OPTION 1:
There is actually a more performant version of this possible, by simply doing redundant badge awarding for all COLLECTABLES in your save file (this still does not completely resolve the core issue of rate limits, when testing):

local function checkPlayerBadges(Player)
	local Data = REPLICATED_STORAGE.PlayerData:WaitForChild(Player.Name)
	local currentSave = Data:GetAttribute("currentSave")
	
	local badgeIds, idToGuy = {}, {}
	for guy, info in CollectableInfo do
		local badgeId = info[9]
		if badgeId == 0 then continue end
		if Data.saves[currentSave].guys:GetAttribute(guy) then
			pcall(function() BadgeService:AwardBadge(Player.UserId, badgeId) end)
			continue
		end
		table.insert(badgeIds, badgeId)
		idToGuy[badgeId] = guy
	end
	
	for _, batch in chunkList(badgeIds, 10) do
		local ok, owned = withRetries(function()
			return BADGE:CheckUserBadgesAsync(Player.UserId, batch)
		end, 30)

		if not ok then
			warn("Badge‐check failed for batch: " .. table.concat(batch, ", "))
			continue
		end

		for _, badgeId in batch do
			local guy		= idToGuy[badgeId]
			local hasBadge	= table.find(owned, badgeId) ~= nil
			if hasBadge then Data.saves[currentSave].guys:SetAttribute(guy, 0) end
		end
	end
end

Shiny, new, updated flowchart:

1 Like

As far as I know, you’re screwed!

As you’ve realized yourself, badge related APIs shouldn’t be used for checking lots of badges at the same time. If you really care about saved data matching the badges the player has collected, Option 1 is your best bet.

1 Like

It seems like you will need to use these APIs for a period of time after the player has joined. Over the span of several minutes, this will run in the background to not exceed the ratelimits.

Seems unintuitive, but it might work.

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.