Skip to main content
< Back

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

Team Atelico

Godot vs Randomness

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:

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.

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:

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.