Why numeric keys in saved Roblox data stop matching
Data stores save number keys in tables without a list part as strings, so lookups by UserId or id miss after a rejoin. How to key saved tables with strings.
What it looks like
A saved table uses numbers as keys, such as UserIds, product ids, badge ids or level numbers, and the code looks entries up with those numbers. The table isn't a plain list, so its length is 0. Roblox saves the numeric keys as strings, and after the next load a lookup by number finds nothing.
profile.Thanked[helper.UserId] = true
What an exploiter could do
They rejoin and claim the same rewards again. A once-per-friend bonus, a one-time gift or a reward for each badge pays out again every session, because the record of every earlier claim no longer matches. The same mistake on items or unlocks makes them look missing after a rejoin, and code that then grants them again or resets them can lose or duplicate progress.
How to fix it
- Use string keys for anything saved:
t[tostring(userId)] = true, and look entries up withtostringas well. - Or keep a plain list, such as an array of ids, and search it with
table.find. - For data that's already saved, read both forms once when it loads and rewrite every key as a string.
Vulnerable example
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local store = DataStoreService:GetDataStore("BadgeBonuses") local bonusFor: { [number]: number } = { [1832457] = 500, [1832458] = 2000, } local claimed: { [Player]: { [number]: boolean } } = {} local function grantBonuses(player: Player, coins: NumberValue) local mine = claimed[player] for badgeId, bonus in bonusFor do if not mine[badgeId] then mine[badgeId] = true coins.Value += bonus end end end Players.PlayerAdded:Connect(function(player) local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId)) if not ok or player.Parent ~= Players then return end claimed[player] = if typeof(saved) == "table" then saved else {} grantBonuses(player, player:WaitForChild("Coins") :: NumberValue) end) Players.PlayerRemoving:Connect(function(player) local mine = claimed[player] claimed[player] = nil if mine then pcall(store.SetAsync, store, tostring(player.UserId), mine) end end)
Fixed example
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local store = DataStoreService:GetDataStore("BadgeBonuses") local bonusFor: { [number]: number } = { [1832457] = 500, [1832458] = 2000, } local claimed: { [Player]: { [string]: boolean } } = {} local function grantBonuses(player: Player, coins: NumberValue) local mine = claimed[player] for badgeId, bonus in bonusFor do local key = tostring(badgeId) if not mine[key] then mine[key] = true coins.Value += bonus end end end Players.PlayerAdded:Connect(function(player) local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId)) if not ok or player.Parent ~= Players then return end claimed[player] = if typeof(saved) == "table" then saved else {} grantBonuses(player, player:WaitForChild("Coins") :: NumberValue) end) Players.PlayerRemoving:Connect(function(player) local mine = claimed[player] claimed[player] = nil if mine then pcall(store.SetAsync, store, tostring(player.UserId), mine) end end)
How to spot this in your own scripts, and the cases that only look like it, are part of the Luaudit check your AI runs.