How to handle paid random items and PolicyService
Crates, eggs and spins bought with Robux or Robux-bought currency must respect ArePaidRandomItemsRestricted. How to check it on the server and show odds.
What it looks like
Players can spend Robux, or a currency that can be bought with Robux, on a random outcome: a crate, an egg, a spin or a luck boost. The server never checks PolicyService to see whether that player is allowed to buy random items.
Luau
OpenEgg.OnServerEvent:Connect(function(player) profile.Gems -= eggPrice hatch(player) end)
How to detect it
- Find every random reward and what it costs. Roblox counts it as a paid random item if it costs Robux directly or costs a currency the game sells for Robux. Follow the currency back to
ProcessReceiptto see whether any developer product grants it. - Items that change the odds of a paid random item, such as luck boosts, pity systems or rate-up scrolls, count as paid random items too.
- Look for a server-side call to
PolicyService:GetPolicyInfoForPlayerAsyncand a check ofArePaidRandomItemsRestrictedbefore the server takes payment and rolls. When it'strue, that player must not be able to open the paid random item. - A check that only hides the button on the client doesn't count. The server handler has to refuse.
GetPolicyInfoForPlayerAsyncis a web call and can error, so it needspcall. Check what happens when it fails: treating an unknown result as restricted is the safe choice.- Roblox also requires the odds of every outcome to be shown as percentages before purchase. That usually lives in menu code. If the menu isn't part of what you're auditing, say the odds display needs checking rather than reporting it.
What an exploiter could do
No exploit is needed. This is a Roblox policy requirement driven by laws in some countries. Players whose policy says paid random items are restricted can still buy them, which breaks Roblox's rules for the experience.
How to fix it
- When a player joins, call
GetPolicyInfoForPlayerAsyncon the server insidepcall, retry on failure, and keep the result in a server table. - In every handler that sells a paid random item, refuse unless the stored result says paid random items aren't restricted for that player.
- For restricted players, use one of the alternatives Roblox lists, such as selling the item directly at a fixed price, earning it through play, or showing that it's unavailable in their region.
- Keep the odds in data the menu can read, and show every outcome's chance before purchase.
Vulnerable example
Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local eggPrice = 300 local pets = { "Cat", "Dog", "Dragon" } local weights = { 70, 25, 5 } local rng = Random.new() local function hatch(): string local pick = rng:NextInteger(1, 100) for index, weight in weights do pick -= weight if pick <= 0 then return pets[index] end end return pets[#pets] end ReplicatedStorage.Remotes.OpenEgg.OnServerEvent:Connect(function(player: Player) local profile = PlayerData.get(player) if not profile or profile.Coins < eggPrice then return end profile.Coins -= eggPrice table.insert(profile.Inventory, hatch()) end)
Fixed example
Fixed
local Players = game:GetService("Players") local PolicyService = game:GetService("PolicyService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local eggPrice = 300 local pets = { "Cat", "Dog", "Dragon" } local weights = { 70, 25, 5 } local rng = Random.new() local allowed: { [Player]: boolean } = {} local function hatch(): string local pick = rng:NextInteger(1, 100) for index, weight in weights do pick -= weight if pick <= 0 then return pets[index] end end return pets[#pets] end Players.PlayerAdded:Connect(function(player) for attempt = 1, 3 do local ok, policy = pcall(PolicyService.GetPolicyInfoForPlayerAsync, PolicyService, player) if ok then if player.Parent == Players then allowed[player] = policy.ArePaidRandomItemsRestricted == false end return end task.wait(attempt * 2) end end) Players.PlayerRemoving:Connect(function(player) allowed[player] = nil end) ReplicatedStorage.Remotes.OpenEgg.OnServerEvent:Connect(function(player: Player) local profile = PlayerData.get(player) if not profile or not allowed[player] or profile.Coins < eggPrice then return end profile.Coins -= eggPrice table.insert(profile.Inventory, hatch()) end)
Common false positives
- Random rewards that can't be paid for, directly or through a currency sold for Robux, aren't paid random items. A chest opened with a key found in the world doesn't need the check.
- A currency that can only be earned in play, never bought, doesn't make what it buys a paid random item. Check that no product, pass or bundle grants it.
- A fixed, disclosed sequence of rewards, where the player knows each outcome before paying, isn't random.