Randomness in Godot is rigged! Here's how we fixed it
Team Atelico

While building Bobium Brawlers, our card-and-dice arena brawler in Godot 4, we hit a bug that felt impossible at first and turned out to be hiding in plain sight inside the engine itself. This post is the story of how we found it, how to reproduce it in fifteen lines of GDScript, and what we did about it in the meantime.
The smell
Bobium Brawlers leans hard on rerolls. Players reroll dice, reroll loot, reroll opening hands. Early in playtesting we started getting the same feedback from our playtests, in slightly different words: “the rerolls feel rigged”, the dice felt “stuck”, the dice “rolled the same way twice in a row”.
We dismissed it for a while. Humans are notoriously bad at perceiving randomness; small streaks feel meaningful when they aren’t. Random walks cluster, gambler’s fallacy, etc. We’ve all read the papers.
Piero wasn’t having it though. In one of his matches he rolled a very rare face combination, and when he rerolled all the dice, he got the same combination again, in exactly the same order. The probability of two combinations like that in a row was about one in two billions. We went looking.
The minimal reproduction
The pattern in our code looked like this, variations of it appear in practically every Godot project we’ve seen:
func reroll_dice() -> Array[int]:
var rng = RandomNumberGenerator.new()
var dice: Array[int] = []
for i in 5:
dice.append(rng.randi_range(1, 6))
return dice
A fresh RandomNumberGenerator per call. It auto-randomizes in its
constructor, so we’d been treating each instance as independent. That turns
out not to be true.
Here is the experiment, distilled down. Drop it into any Godot 4 project as
the _ready() of a Node:
const TRIALS := 5000
const DICE := 5
func _ready() -> void:
var rolls: Array[PackedInt32Array] = []
for t in TRIALS:
var rng := RandomNumberGenerator.new()
var r := PackedInt32Array(); r.resize(DICE)
for d in DICE:
r[d] = rng.randi_range(1, 6)
rolls.append(r)
var ties := 0
for t in range(1, TRIALS):
if rolls[t] == rolls[t - 1]:
ties += 1
print("Identical consecutive 5d6 sequences: %d / %d" % [ties, TRIALS - 1])
print("Expected for a fair RNG: ~%.2f" % ((TRIALS - 1) / pow(6.0, DICE)))
A fair RNG should print roughly 0.64. On Godot 4.6.2 we measured 2168. That’s four orders of magnitude over expected. On a tighter, pure-C++ reproduction (no GDScript per-iteration overhead) the rate climbs to ~96%, almost every consecutive pair is a byte-for-byte tie.
What’s actually happening
The relevant lines live in core/math/random_pcg.cpp:
void RandomPCG::randomize() {
seed(((uint64_t)OS::get_singleton()->get_unix_time()
+ OS::get_singleton()->get_ticks_usec())
* pcg.state + PCG_DEFAULT_INC_64);
}
PCG is a perfectly good generator, that part isn’t the problem. The problem
is the entropy source. The only thing that varies between two calls is
gettimeofday’s microsecond field. Constructing a RandomNumberGenerator
takes far less than a microsecond on modern hardware, so consecutive
RandomNumberGenerator.new() calls regularly read the same timestamp and
end up with the same seed.
We checked: across 5000 successive construction-time samples on our hardware, only 143 unique timestamp values appeared, with up to 37 constructions sharing a single timestamp. That’s where the collisions come from, not from PCG, not from biased sampling, just from microsecond-grained timing being too coarse to distinguish back-to-back calls.
Hashing the seed before feeding it to PCG would not have helped, by the way.
A hash of an identical input is an identical output. The fix has to happen
upstream: pull from the OS CSPRNG (getrandom(2) on Linux,
BCryptGenRandom on Windows, getentropy on macOS / BSDs,
crypto.getRandomValues on Web), which is exactly what every modern
language’s standard library does.
The quickest workaround for game code today
If you need to ship and don’t want to think about it too hard, the cheapest fix is to keep one RNG around and reuse it. This is also the default recommendation in most languages anyway, you usually instantiate one generator and pull numbers from it for the lifetime of whatever needs them, so you’re not doing anything weird:
@onready var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
func reroll_dice() -> Array[int]:
var dice: Array[int] = []
for i in 5:
dice.append(_rng.randi_range(1, 6))
return dice
PCG’s stream is excellent across calls; the bug is exclusively in seeding. Reusing a single RNG sidesteps the issue entirely, and it’s also slightly faster because you skip the per-call construction.
That’s the one-line fix that gets you past the bug today, but the bug
itself is still sitting in the engine. Any code that ever creates a fresh
RandomNumberGenerator.new() in a tight loop, anywhere in your project or
in any addon you pull in, will keep hitting it. And there are real reasons
you might actually want per-instance RNGs in the first place, reasons that
this workaround quietly takes off the table. We’ll get to both in the next
section.
A second gotcha worth mentioning: the global randi() and randf()
functions you call without an RNG instance use Math::default_rand, which is
seeded with a hardcoded constant at engine init and not auto-randomized.
If you want the global functions to vary between game launches, call
randomize() once during startup. (We changed our Main.gd to do this on
the first frame.)
But why we want per-instance RNGs anyway
“Just reuse one RNG” works as a workaround, but it quietly takes a real
design off the table. Take dice in Bobium Brawlers: each die owns its own
RandomNumberGenerator, seeded from a per-die identifier. When the player
rerolls die #3, only that die’s stream advances. The dice are independent of
each other, and independent of the order other systems happen to consume
randomness during the same frame: particles, AI tie-breaks, ambient SFX, the
lot. With a single global RNG, the result of rerolling die #3 depends on
whether a particle effect drew from the stream a microsecond earlier.
Replays stop being deterministic. Network sync between two clients drifts.
Unit tests for one die pull from a stream that other dice keep advancing.
Everything gets wobbly in ways that are very hard to debug.
You can build this on top of one global RNG by manually deriving sub-seeds and passing them around, but that’s “manually do in script what the engine could do once internally” territory. Per-instance RNGs are the natural design here, and they’re what the Godot docs already describe (“multiple instances, each with their own seed and state”). The seeding bug is the only reason that natural design doesn’t already work.
The same pattern shows up everywhere games need decoupled randomness:
- Procgen done in parallel. A chunked world wants chunk (5, 7) to
produce the same content regardless of whether it’s generated before or
after chunk (5, 8). The natural design is to seed a per-chunk RNG from
world_seed XOR chunk_idand run chunks concurrently. A shared RNG forces sequential generation and order-dependent output. - Networked or replay-driven games. Each entity computes its random outcomes from a derivable seed (entity id plus run seed). Two clients agree on the same outcome without having to agree on the global order in which every other entity executed.
- Loot tables tied to position rather than time. Opening chest A always
gives the same drop regardless of which chests the player opened first.
Just create a fresh RNG per chest from
(run_seed, chest_id)and discard it.
All three are natural with per-owner RNGs and become awkward boilerplate on top of a single shared one.
A drop-in fix while waiting for an upstream patch
If you want the per-instance design back without waiting on an upstream
patch, we wrote a tiny GDExtension that’s behavior-compatible with
RandomNumberGenerator but seeds from the OS CSPRNG on every construction.
Same var rng = ...new() inside hot loops, no fuss, no correlation.
Repo: github.com/atelico/better-godot-rng
# before
var rng = RandomNumberGenerator.new()
# after
var rng = BetterRng.new()
Same method names, same return types, same meaning. Internally it uses
PCG64-MCG (same family Godot uses, double the state width) seeded via the
getrandom Rust crate. Releases include prebuilt .so / .dylib / .dll
files for Linux, macOS (universal), and Windows; you copy them into your
project’s addons/ folder and you’re done.
We re-ran the same 5000-trial experiment with BetterRng.new() per call:
0 consecutive 5d6 ties, statistically indistinguishable from a single
properly-seeded RNG reused 5000 times. The smell is gone.
Going upstream
We’ve also opened an issue and a PR against the Godot engine to fix this at
the source. A small change in random_pcg.cpp to pull seeds from the OS CSPRNG
via platform-native APIs.
- Issue: godotengine/godot#119322
- PR: godotengine/godot#119323
If it lands, the GDExtension above becomes unnecessary for projects on versions that include the fix. We’ll keep maintaining the extension in the meantime, and for projects that want to backport without bumping their Godot version.
Takeaways
A few things we’ll remember:
- Trust testers when many of them say the same vague thing. “Feels rigged” is not a useful bug report on its own, but three independent reports of the same vague thing is a strong prior for “there’s something here”.
- Seed entropy is upstream of seed quality. The PCG family has excellent statistical properties, and Godot uses it correctly at the algorithm level. All of that is wasted if the seed source is degenerate. Whitening, hashing, and clever output functions don’t fix a bad input.
- Microsecond clocks are coarse. A single-digit-nanosecond function (object construction) sampled at microsecond resolution will alias hard. This shows up in plenty of places besides RNGs, things like request IDs, log timestamps, anywhere a “unique-per-call” assumption sneaks in.
Godot is an excellent engine and the team behind it has built something we genuinely enjoy working in every day. Bugs like this are the kind that survive because everything around them works correctly. PCG is solid, the bindings are clean, the API matches what you’d expect. The only weak link is a single line in the seeder, and now that we know about it, it’s fixable.
If you’re running into the same smell in your own project, the experiment above takes a minute to run and tells you definitively whether you’re affected. And if you are, the workaround is a one-line refactor away.