How to keep impossible scores off your global leaderboards
Writing raw stats to an ordered data store puts every exploit on show. How to check times and rates of gain before a score is saved.
What it looks like
The server writes players' scores to an OrderedDataStore that feeds a global leaderboard: fastest times, total coins, wins or levels. It writes whatever the player's stats say, with no check that the value could have been reached legitimately.
Luau
scores:SetAsync(tostring(player.UserId), profile.Coins)
How to detect it
- Find every
GetOrderedDataStoreand every write to it:SetAsync,UpdateAsyncandIncrementAsync. - Roblox's leaderboard guide says to protect score writes with server-side validation, and to review suspicious or impossible values before they affect rankings. Its server-side detection guide describes the checks to use: the fastest possible completion time, and the fastest legitimate rate of gain.
- For times, check that the server measured the time itself and refuses times below what the course allows. A time sent by the client is Server trusts values the client decides as well.
- For totals, check that the server compares the gain since the last write with what's possible in that time, and holds back or flags values that jump.
- Check that flagged scores aren't written, or are written to a separate review store, and that there's a way to remove a bad score afterwards.
- A score that can be raised through another bug, such as a duplication or an unchecked reward, will reach the leaderboard. Note this in the finding for that bug.
What an exploiter could do
Once they've found any way to raise their numbers, the global leaderboard shows it to everyone: impossible times, billions of coins, thousands of wins. Real players can't compete, and a leaderboard shown in the player list makes the game look broken.
How to fix it
- Measure times on the server from the start to the finish it saw, and refuse anything faster than the fastest possible run.
- Compare each write with the previous one and the time between them. Hold back gains beyond the fastest legitimate rate, and log them for review.
- Keep a way to remove or reset a player's leaderboard entry.
Vulnerable example
Vulnerable
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local richest = DataStoreService:GetOrderedDataStore("RichestPlayers") Players.PlayerRemoving:Connect(function(player) local profile = PlayerData.get(player) if profile then pcall(richest.SetAsync, richest, tostring(player.UserId), math.floor(profile.Coins)) end end)
Fixed example
Fixed
local DataStoreService = game:GetService("DataStoreService") local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local richest = DataStoreService:GetOrderedDataStore("RichestPlayers") local maxGainPerMinute = 5000 local joined: { [Player]: { at: number, coins: number } } = {} Players.PlayerAdded:Connect(function(player) local profile = PlayerData.waitFor(player) if profile then joined[player] = { at = os.time(), coins = profile.Coins } end end) Players.PlayerRemoving:Connect(function(player) local profile = PlayerData.get(player) local start = joined[player] joined[player] = nil if not profile or not start then return end local minutes = math.max((os.time() - start.at) / 60, 1) if profile.Coins - start.coins > maxGainPerMinute * minutes then warn(`Leaderboard score held back for review: {player.UserId}`) return end pcall(richest.SetAsync, richest, tostring(player.UserId), math.floor(profile.Coins)) end)
Common false positives
- Leaderboards that only show the current server, from
leaderstats, reset when the server closes. They're still worth checking, but impossible values there do far less harm. - Scores that only the server can change in small, fixed steps, such as wins counted by the server at the end of a round it ran, need little more checking.
- A score held back or logged for review, like the fixed example, is the fix.