Why You Should Never Use math.random for RNG-based rewards
Nice to see you folks again. This topic will be extremely valuable to you if you plan on adding RNG-based lootboxes, or anything that rewards based on math.random
or Random
.
Background
I got the idea to write this topic because my friend @XoifailTheGod sent me this YouTube video of a seemingly skilled developer using Roblox’s pseudo random number generation to their advantage.
The experience affected by this vulnerability was the popular experience Rogue Lineage.
NOTE: I DO NOT condone the usage of any software or the actions showcased in the video linked above.
Why This Matters
Many developers assume that math.random()
or even Random.new()
provides “good enough” randomness. Unfortunately, these functions are deterministic, and when not used carefully, their output can be predicted or even replayed by attackers, especially when used for:
- Lootboxes / Rare item drops
- Combat crits or proc chances
- Randomized enemy behavior
In the video I mentioned above, the attacker was able to crack the PRNG state and simulated the drop table until a rare item was obtained. Once found, they replicated the same actions in-game and consistently farmed rare drops. This is a direct consequence of deterministic RNG combined with predictable or static usage patterns.
The Root Problem
Roblox uses a variant of the PCG32 XSH-RR algorithm. While its fast and statistically solid for general use, it is not cryptographically secure, and its internal state can be reverse-engineered if:
- The seed is guessable (e.g.,
tick()
oros.clock()
) - Too few random calls are made between sensitive actions
- The PRNG usage is tightly tied to game events (e.g., one roll per player action)
In such conditions, a motivated attacker can brute-force the seed or track enough RNG outputs to synchronize with the game’s internal state.
Source: https://www.pcg-random.org/
Don’t Do This
Here’s a naive example of drop logic that looks safe, but isn’t:
math.randomseed(tick())
local function rollLoot()
local roll = math.random(1, 100)
if roll == 100 then
return "Ultra Rare Sword"
else
return "Common Item"
end
end
Problems:
- Using
tick()
as a seed is guessable (the attacker only has to try a range of recent timestamps). - If the number of calls between seeding and loot is known, the attacker can fully reconstruct the RNG state.
Even worse, if the game never re-seeds, but uses math.random()
repeatedly in a predictable pattern, an attacker only needs to match the call count to get in sync.
A Better Approach
To somewhat mitigate predictable RNG behavior, here’s an efficient strategy that works without needing a heavy CSPRNG:
Use
Random.new()
Objects (Notmath.random
)
This gives you per-instance PRNGs instead of relying on the global state.
local rng = Random.new()
local function rollLoot()
local roll = rng:NextInteger(1, 100)
if roll == 100 then
return "Ultra Rare Sword"
else
return "Common Item"
end
end
Add Entropy: Random Advancement
Still, using a single Random.new()
predictably can still lead to abuse. So we introduce entropy via random advancement:
local RunService = game:GetService("RunService")
local rng = Random.new()
RunService.Heartbeat:Connect(function()
for _ = 1, math.random(1, 5) do
rng:NextInteger(1, 10)
end
end)
Now the number of PRNG calls between loot rolls becomes harder to predict, which makes it much harder to sync with or reverse.
Vary the Number of PRNG Calls
Instead of calling Random::NextInteger()
once per action, try this instead:
for _ = 1, rng:NextInteger(1, 3) do
rng:NextNumber()
end
Randomizing the number of RNG calls between events forces attackers to guess exponentially many possible PRNG states, making prediction or syncing practically infeasible.
Should I Use a CSPRNG?
In case you’re unfamiliar, a CSPRNG is a cryptographically-safe pseudo random number generator. It’s used in security-sensitive applications, where security is a top priority.
To get back to the question, from a technical standpoint, yes, but practically, probably not.
Using true cryptographically secure randomness like SHA-based output or ChaCha20 would make prediction computationally infeasible, and close to impossible by today’s standards, but with a cost:
- Slower performance, especially on client devices
- No native support in Roblox; you’d need custom hashing libs (like rbx-cryptography, hello @daily3014TheGod
)
- Increased implementation complexity
Instead, you can get “good enough” unpredictability using Random.new()
by amplifying its entropy.
Bonus
If you are still lost, you can take a look at this template of a “good enough” setup of a Random
pool with random advancements (we essentially use lag to our advantage, which is a low entropy amplifier).
local RunService = game:GetService("RunService")
local rngs = {Random.new(), Random.new(), Random.new()}
RunService.Heartbeat:Connect(function()
for _, rng in rngs do
if math.random() < 0.5 then
rng:NextInteger(1, 100)
end
end
end)
local function rollLoot()
local rng = rngs[math.random(1, #rngs)]
local roll = rng:NextInteger(1, 100)
return (roll == 100) and "Ultra Rare Sword" or "Common Item"
end
Final Thoughts
This topic might seem obscure now, but as games become more competitive and cheaters more sophisticated, RNG manipulation will become a bigger threat. Fixing this today protects your game tomorrow, and your players will thank you for it! (good economy = players happy)
If you’re developing anticheat or secure game logic, feel free to reach out. This kind of unpredictability is something we take seriously in projects like OneAnticheat .
See you in the next post!
Credits to @Unlimited_Objects for existing, and @daily3014TheGod for making rbx-cryptography!