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:
- Save COLLECTABLE to datastore (thus they should always be synced)
- 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:
- Check if PLAYER has COLLECTABLE but not BADGE (award BADGE)
- 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:
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.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:
- 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.
- 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!


