First time using the Rand crate, for determinism and weighted indices for a tilemap. What can I improve?

Hi there! This is my first time really dipping into the Rand crate beyond some surface level stuff during AoC. I am making a little game that relies on some tilemaps, and my plan is to support some randomness when defining what possible tile index to choose when drawing a particular position.
My requirements boil down to:

  • Weighted values - I want to be able to define the probability of a tile being chosen with a value, rather than inserting it more than once
  • Determinism - I want the result to be reproducible across sessions, given some seed.

I think I have achieved both of those in a minimal example (I haven't integrated it into the game yet) but since I only just read the rand book and have never done anything like this before, I'd appreciate any feedback on my current implementation. Especially when it comes to how I am combining the seed and the tile position so that all the tiles aren't the same.
I'd also appreciate any tips regarding performance (I am going to use Pcg64Mcg in the final version), with the context that the random selection will only happen once when each level is loaded and that the game is built on Bevy.

Rust Playground Code

use rand::distributions::WeightedIndex;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::collections::HashMap;

#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
pub struct TilePos {
    x: u32,
    y: u32,
}

impl TilePos {
    pub fn new(x: u32, y: u32) -> Self {
        TilePos { x, y }
    }
}

#[derive(Debug)]
pub struct TileMap(Vec<TilePos>);

impl TileMap {
    pub fn get_random_value_per_position(
        &self,
        random_tiles: &WeightedRandomTiles,
        seed: u64,
    ) -> Vec<(TilePos, u32)> {
        let mut results = Vec::with_capacity(self.0.len());
        for position in &self.0 {
            let position_seed = combine_seed_and_tile_pos(seed, position);
            let mut rng = ChaCha8Rng::seed_from_u64(position_seed);

            let dist = WeightedIndex::new(random_tiles.0.iter().map(|item| item.weight)).unwrap();
            let random_index = random_tiles.0[dist.sample(&mut rng)].tile_index;

            results.push((*position, random_index));
        }
        results
    }
}

#[derive(Debug)]
pub struct WeightedRandomTiles(Vec<WeightedTileIndex>);

#[derive(Debug, PartialEq)]
pub struct WeightedTileIndex {
    tile_index: u32,
    weight: u32,
}

impl WeightedTileIndex {
    pub fn new(tile_index: u32, weight: u32) -> Self {
        WeightedTileIndex { tile_index, weight }
    }
}

fn combine_seed_and_tile_pos(seed: u64, position: &TilePos) -> u64 {
    let mut hasher = DefaultHasher::new();
    seed.hash(&mut hasher);
    position.hash(&mut hasher);
    hasher.finish()
}

fn main() {
    // Represents a 3x3 tilemap
    let tile_map = TileMap(vec![
        TilePos::new(0, 0),
        TilePos::new(1, 0),
        TilePos::new(2, 0),
        TilePos::new(0, 1),
        TilePos::new(1, 1),
        TilePos::new(2, 1),
        TilePos::new(0, 2),
        TilePos::new(1, 2),
        TilePos::new(2, 2),
    ]);

    // An abritrary set of random indices where all indices are equally likely
    // to be selected, except index 0 which is twice as likely to occur
    let random_tiles = WeightedRandomTiles(vec![
        WeightedTileIndex::new(0, 2),
        WeightedTileIndex::new(1, 1),
        WeightedTileIndex::new(2, 1),
        WeightedTileIndex::new(3, 1),
    ]);

    // Numeric seed, will probably want to change out for a string later
    let seed: u64 = 123456789;

    // Get a random weighted random index back from the tilemap
    let random_indices = tile_map.get_random_value_per_position(&random_tiles, seed);

    // Print all the results of the function out
    for (position, selected_index) in &random_indices {
        println!(
            "Position: ({},{}), Index: {}",
            position.x, position.y, selected_index
        );
    }
    
    // Turn the results into a hashmap, for the sake of indexing
    let random_indices: HashMap<TilePos, u32> = random_indices.into_iter().collect();
    
    // Try generating the results 100 times and compare them to the original attempt
    for _ in 0..99 {
        let random = tile_map.get_random_value_per_position(&random_tiles, seed);
        
        for (position, selected_index) in random {
            assert_eq!(*random_indices.get(&position).unwrap(), selected_index);
        }
    }
}

1 Like

I don't see what you gain by doing that.

I think this will give you what you want...

        let mut results = Vec::with_capacity(self.0.len());
        let mut rng = ChaCha8Rng::seed_from_u64(seed);
        for position in &self.0 {
            let dist = WeightedIndex::new(random_tiles.0.iter().map(|item| item.weight)).unwrap();
            let random_index = random_tiles.0[dist.sample(&mut rng)].tile_index;

            results.push((*position, random_index));
        }
1 Like

Unfortunately I need to combine the seed with position, or else the result will be exactly the same for each position. Resulting in a random but uniform tilemap:
1 1 1
1 1 1
1 1 1
While I am trying to achieve a tilemap where each position is randomized:
0 1 2
2 1 1
0 0 2

Instead of passing a seed around, how about passing a completely initialized RNG into the function?

impl TileMap {
    pub fn get_random_value_per_position(
        &self,
        random_tiles: &WeightedRandomTiles,
        mut rng: impl Rng,
    ) -> Vec<(TilePos, u32)> {
        let mut results = Vec::with_capacity(self.0.len());
        for position in &self.0 {
            let dist = WeightedIndex::new(random_tiles.0.iter().map(|item| item.weight)).unwrap();
            let random_index = random_tiles.0[dist.sample(&mut rng)].tile_index;

            results.push((*position, random_index));
        }
        results
    }
}

and

    // Try generating the results 100 times and compare them to the original attempt
    for _ in 0..99 {
        let mut rng = ChaCha8Rng::seed_from_u64(seed);
        let random = tile_map.get_random_value_per_position(&random_tiles, &mut rng);

        for (position, selected_index) in random {
            assert_eq!(*random_indices.get(&position).unwrap(), selected_index);
        }
    }

should give you the gist of how this works - but instead of creating a fresh seeded RNG for each tile, I create one seeded RNG for the entire tile map, and use it to get a random index for each position.

This should, incidentally, be slightly faster to run, since you're able to amortize the cost of creating and seeding the RNG state across the entire tile map, instead of paying it for every tile.

Fantastic thank you, both this and the reply from Coding-Badly work great without the extra hashing, now that I have had time to test them both. Looks like I had misunderstood how seeded RNG worked and always expected it to give the same result. I've gone back to the rand book and played around some more and see that the combine_seed_and_tile_pos method was indeed redundant.
Passing in an Rng object from outside looks like a good way to go, plus I think it has shown me that I can also initialize the WeightedIndex outside the loop, and save myself from repeating it. Which makes the function look like:
Rust Playground

pub fn get_random_value_per_position(
    &self,
    random_tiles: &WeightedRandomTiles,
    mut rng: impl Rng,
) -> Vec<(TilePos, u32)> {
    let mut results = Vec::with_capacity(self.0.len());
    let dist = WeightedIndex::new(random_tiles.0.iter().map(|item| item.weight)).unwrap();
    for position in &self.0 {
        let random_index = random_tiles.0[dist.sample(&mut rng)].tile_index;

        results.push((*position, random_index));
    }
    results
}
2 Likes

When you seed an RNG, you guarantee that this RNG will output the same sequence of results every time, not the same result every time. So, for example, this program will always output the same sequence of numbers regardless of where you run it, or how often you run it, or what other code you add. You can see that changing the seed or the algorithm changes the numbers, but using the same seed and algorithm is guaranteed to get you the same numbers as last time.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.