#rng #hasher #deterministic #gamedev

pure_rng

rand-compatible RNG library for generating repeatable, controlled random values, designed primarily for use in games

2 unstable releases

0.9.0 Mar 14, 2025
0.8.0 Nov 18, 2024

#321 in Game dev

Download history 3/week @ 2024-12-04 2/week @ 2024-12-11 123/week @ 2025-03-12

123 downloads per month

MIT license

33KB
383 lines

PureRng is a rand-compatible RNG library for generating repeatable, controlled random values, designed primarily for use in games. It uses a hash function to generate exactly one random value per seed. A convenient API is provided to distribute seed values throughout your program in a hierarchical fashion.

Usage

use pure_rng::prelude::*;

// Create the root rng using an initial seed, typically from an external source.
let rng = PureRng::new(1234);

// Seed two more RNGs with arbitrary labels.
// These effectively have their seed appended to that of the parent.
let rng_a = rng.seed("a convenient label");
let rng_b = rng.seed("a different label");

// Generate a value from the first RNG, consuming it.
let value_a: u32 = rng_a.random();

// Create two more forks of the second RNG, and generate values from them inline.
let value_b1: i64 = rng_b.seed(1).random();
let value_b2: f64 = rng_b.seed(2).random();

// Use your custom types
#[derive(Hash)]
struct Point { x: i32, y: i32 }

let value_from_point: u64 = rng
    .seed(Point { x: 10, y: 12 })
    .random();

// Use the all the usual `rand` API features
let character = rng
    .seed("character")
    .sample(rand::distributions::Alphanumeric) as char;

Motivation

In games driven by procedural generation it's typically required that all generated content is uniquely determined by the initial "world seed" value. If two players input the same seed and subsequently experience different content, this is called "divergence" and is considered a bug.

Divergence typically arises when a traditional RNG is called a varying number of times. For example:

use rand::{Rng, SeedableRng, rngs::StdRng};
let rng = StdRng::seed_from_u64(1234);

let mut value: u32 = rng.gen_range(0..10);

for _ in 0..get_int_from_player() {
    value += rng.gen_range(0..10);
}

let subsequent_value: bool = rng.random();

Here, the RNG will be called a different number of times depending on user input. This will cause the subsequent value to differ depending on how the user behaves, causing divergence. This also happens when you as the developer modify your code, adding or removing RNG calls and again causing divergence, breaking your test cases in a frustrating manner.

PureRng lets us do this instead:

use pure_rng::PureRng;
let rng = PureRng::new(1234);

let mut value: u32 = rng.seed("initial value").random();

for i in 0..get_int_from_player() {
    value += rng.seed(("increment", i)).gen_range(0..0);
}

let subsequent_value: bool = rng.seed("subsequent value").random();

With PureRng, every value generated is a pure function of the chain of seeds used to generate it. There is no state shared between calls like in a typical RNG, and so they cannot interfere with each other.

Implementation

Each PureRng contains a std::hash::Hasher. Calling fork() clones the generator, hashes the value passed in, and returns the clone. When generating a value, we finish() the hasher and take the resulting u64 as the random data.

Many rand functions that return single values still take multiple samples in order to guarantee statistical quality. Therefore PureRng still needs the ability to generate many values from a single seed, even if this is never exposed to the user. It does this by writing generated values back to the hasher, advancing the state.

For an extended discussion, please see the accompanying blog post.

Is that cryptographically sound?

No.

Are the numbers at least random?

Yes. PureRng passes PractRand in its default configuration, to 4 terabytes and beyond.

Is it fast?

It's only very slightly slower than the default rand RNGs.

rand-compatible API

PureRng wraps all the functions you know and love from the Rng, IndexedRandom, IndexedMutRandom, SliceRandom traits, the difference being that they consume self. This is what makes PureRNG pure, stopping you from reusing a given instance and so helping to prevent divergence bugs. The method bodies are delegated directly to the original traits.

Note that while the Distribution trait is supported as shown in the examples, being as it depends merely on Rng, there is currently no PureDistribution wrapper that would allow implementators to call seed() on the passed rng.

Versioning

The major and minor components of PureRng version numbers track the rand versions they are compatible with. Patch versions are reserved for local fixes and improvements.

Using a different Hasher

Everyone has their own favourite hash function. To use yours, disable the default feature and define PureRng:

type PureRng = pure_rng::PureRandomGenerator<MyHasher>;

Note that PureRng does nothing to ensure consitent byte order (endianness) in the hashing algorithm across platforms. The default RapidHasher implementation handles this for you, but if your preferred one doesn't then you can wrap it using the deterministic-hash crate.

Serde support

Simply enable the serde feature.

Nix

The repo includes a flake.nix file which provides a rust dev environment, as well as a number of randomness-testing tools, including the popular PractRand.

License

MIT.

Contributing

Go nuts.

Dependencies

~1.3–1.8MB
~29K SLoC