Skip to main content

Dice40k - Rust Monte Carlo simulation

·1225 words·6 mins·
Table of Contents
Recently I have been playing a lot of W40k. In this game you roll a lot of dice. I wanted a tool to help me make better decisions in the game. Here is a short tutorial on how to run a Monte Carlo simulation in Rust. It is part of a series to create a small web application!

The Project
#

When playing, I want to be able to make quick decisions based on statistics. Experience has shown me that the average isn’t a good indicator when rolling dice. If you roll four dice, some people expect that at least two of the dice should be between 4 and 6 and two should be between 1 and 3, which would mean that you should expect to get two failures and two successes, assuming you roll to get 4+ (at least 4).

Calculating the average number of successful attacks on the fly is certainly feasible, even when accounting for several rolls—hit, wound, and save—for a dozen attacks. Since we’re only working with D6 dice, the math isn’t overwhelming.

However, tabletop games often hinge on decision-making, where a single bad choice can lead to a quick loss. To minimize the impact of luck and avoid blaming bad rolls, I prefer to base my decisions on robust data.

This simulation helps by identifying “safe” decisions—those where you’re comfortable with the outcome even in the worst 80% of scenarios. By planning for these worst-case scenarios, you can avoid risky strategies and ensure more consistent results, keeping the focus on tactics rather than luck.

An Example : The Lion charges into a Land Raider
#

What are my chances of smashing that big tank? Yes, despite the impressive profile of the Lion, A8, F12, AP4, D4… without taking into account the lethal hits and if your opponent uses armour of contempt..

Enter the number of attacks:
8
Enter the minimum roll to hit (2-6):
2
Enter the minimum roll to wound (2-6):
4
Enter the minimum roll to save (2-6):
5
Enter the threshold percentage (e.g., 80 for 80%):
80
Average successful attacks: 2.23
At least 1 attacks are successful in 80% of the cases.

On average, only 2.23 attacks pass, causing 8 wounds to the Land Raider. And 80% of the time, in the worst case, the Land Raider will only take 4 hits. So charging alone without a buff is not the answer. Now imagine you play Gladius as a detachment, so you can give the Lion +1W and +1AP.

Enter the number of attacks:
8
Enter the minimum roll to hit (2-6):
2
Enter the minimum roll to wound (2-6):
3
Enter the minimum roll to save (2-6):
6
Enter the threshold percentage (e.g., 80 for 80%):
80
Average successful attacks: 3.69
At least 3 attacks are successful in 80% of the cases.

Now the Lion can almost kill the big tank on its own with its 16W… but it still shows that Armor of Comtempt makes you unable to one-shot it!

Let’s Code it then
#

Import
#

The import are pretty standard for a Rust project. We use:

  1. rand::Rng for generating random dice rolls.
  2. std::io for handling user input from the terminal
use rand::Rng;
use std::io;

Rolling dice
#

Here we simply import the random function and roll a dice.

fn roll_d6() -> u8 {
    rand::thread_rng().gen_range(1..=6)
}

Doing the Monte Carlo simulation
#

Now we can move on to the interesting part. The Monte Carlo simulation in this project is designed to model a sequence of dice rolls, taking into account each step of an attack: hitting, wounding and saving. Let’s break down the thought process behind the code design:

Simulating the process
#

The function loops through num_iterations, where each iteration represents a simulated “combat sequence” scenario. In each scenario:

Roll to Hit: For each attack, a die roll determines whether it meets the minimum threshold (min_to_hit). This simulates whether the attack hits. Roll to Wound: If the attack hits, another roll determines if it wounds, based on the threshold (min_to_wound). Roll to Save: If the attack wounds, the defender has a chance to save the attack. The roll is compared to the min_to_save value, and a failure to save counts as a successful attack.

Each of these steps replicates the core mechanics of tabletop combat, where multiple layers of randomness are involved. The function stores the number of successful attacks for each iteration in a vector (successful_attacks_count). This allows us to calculate both the average number of successful attacks and the threshold for worst case scenarios (based on the provided threshold_percent).

The Return
#

  1. Average Successful attacks: By summing all successful attack counts and dividing by the number of iterations, we get a reliable estimate of how well the attack sequence typically performs, the middle case scenario.
  2. Threshold: To assess the worst case scenarios, the results are sorted and a percentile is calculated. This threshold gives an idea of what results you can reasonably expect in 80% (or some other chosen percentage) of cases.
    fn monte_carlo_simulation(
        num_attacks: usize,
        min_to_hit: u8,
        min_to_wound: u8,
        min_to_save: u8,
        threshold_percent: f64,
        num_iterations: usize,
    ) -> (f64, usize) {
        let mut successful_attacks_count = vec![0; num_iterations];
    
        for i in 0..num_iterations {
            let mut successful_attacks = 0;
    
            for _ in 0..num_attacks {
                let hit_roll = roll_d6();
                if hit_roll >= min_to_hit {
                    let wound_roll = roll_d6();
                    if wound_roll >= min_to_wound {
                        let save_roll = roll_d6();
                        if save_roll < min_to_save {
                            successful_attacks += 1;
                        }
                    }
                }
            }
    
            successful_attacks_count[i] = successful_attacks;
        }
    
        let average =
            successful_attacks_count.iter().copied().sum::<usize>() as f64 / num_iterations as f64;
    
        // Sort the results in ascending order to compute the worst-case for the given threshold percentage
        successful_attacks_count.sort_unstable();
    
        let threshold_index = ((num_iterations as f64 * (1.0 - threshold_percent / 100.0)).ceil()
            as usize)
            .saturating_sub(1);
        let threshold = successful_attacks_count[threshold_index];
    
        (average, threshold)
    }
    

Taking inputs and printing results
#

Here we simply take the input from the user and the program returns the Monte Carlo simulation.


fn main() {
    let mut input = String::new();

    println!("Enter the number of attacks:");
    io::stdin().read_line(&mut input).unwrap();
    let num_attacks: usize = input.trim().parse().expect("Please enter a valid number.");

    input.clear();
    println!("Enter the minimum roll to hit (2-6):");
    io::stdin().read_line(&mut input).unwrap();
    let min_to_hit: u8 = input
        .trim()
        .parse()
        .expect("Please enter a valid number between 2 and 6.");

    input.clear();
    println!("Enter the minimum roll to wound (2-6):");
    io::stdin().read_line(&mut input).unwrap();
    let min_to_wound: u8 = input
        .trim()
        .parse()
        .expect("Please enter a valid number between 2 and 6.");

    input.clear();
    println!("Enter the minimum roll to save (2-6):");
    io::stdin().read_line(&mut input).unwrap();
    let min_to_save: u8 = input
        .trim()
        .parse()
        .expect("Please enter a valid number between 2 and 6.");

    input.clear();
    println!("Enter the threshold percentage (e.g., 80 for 80%):");
    io::stdin().read_line(&mut input).unwrap();
    let threshold_percent: f64 = input
        .trim()
        .parse()
        .expect("Please enter a valid percentage.");

    let num_iterations = 10000;

    let (average, threshold) = monte_carlo_simulation(
        num_attacks,
        min_to_hit,
        min_to_wound,
        min_to_save,
        threshold_percent,
        num_iterations,
    );

    println!("Average successful attacks: {:.2}", average);
    println!(
        "At least {} attacks are successful in {}% of the cases.",
        threshold, threshold_percent
    );
}

Conclusion
#

That’s all. It is pretty trivial. Next I will improve this little program to make it account for more aspect of the game, and notably the rerolls.

In addition to that, I plan to do an efficient Front-End and to host this little program on a subdomain to be able to use it when playing.

the Repository
#

You can find the code detailed in this article in the following repo:

ddordain/dice40k

Rust
0
0