Skip to content

japhir/DnDRaces

Repository files navigation

Simulating D&D 5e Race Heights and Weights

D&D Character height and weight

In D&D you can roll dice to determine your character’s height and weight based on it’s race.

This is the table of race size and weight in the D&D player handbook:

RaceBase HeightHeight ModifierBase WeightWeight Modifier
Human4’8”+2d10110 lb.× (2d4) lb.
Dwarf, hill3’8”+2d4115 lb.× (2d6) lb.
Dwarf, mountain4’+2d4130 lb.× (2d6) lb.
Elf, high4’6”+2d1090 lb.× (1d4) lb.
Elf, wood4’6”+2d10100 lb.× (1d4) lb.
Elf, drow4’5”+2d675 lb.× (1d6) lb.
Halfling2’7”+2d435 lb.× 1 lb.
Dragonborn5’6”+2d8175 lb.× (2d6) lb.
Gnome2’11”+2d435 lb.× 1 lb.
Half-elf4’9”+2d8110 lb.× (2d4) lb.
Half-orc4’10”+2d10140 lb.× (2d6) lb.
Tiefling4’9”+2d8110 lb.× (2d4) lb.

To calculate the height, you have to add the Base Height to, e.g. 2d10 dice rolls for the Human, which means you have to roll two 10-sided-die to determine how much to add, in inches.

Problem: I think metric

I don’t think in terms of feet, inches, and pounds, so I thought I’d convert some things to centimetres and kilogrammes in stead. Furthermore, I wanted to better understand how these dice-rolls influence the final distribution of weights and heights in the D&D universe.

And so I’ve simulated some dice-rolls to answer those questions!

Rolling Virtual Dice

Load libraries

First load the libraries we use here:

library(dplyr)
library(ggplot2)
library(purrr)
library(tidyr)

Virtual dice

Then, let’s figure out how to roll a virtual die in R. This function should do it:

roll_dice <- function(n = 1, d = 20) {
  sum(sample(seq_len(d), size = n, replace = TRUE))
}

By creating a vector of length d, and sampling from it n times, with replacement, and then taking the sum of those numbers we get simulated dice-rolls!

Test if it works

c(roll_dice(),       # 1d20
  roll_dice(1, 10),  # 1d10
  roll_dice(2, 6),   # 2d6
  roll_dice(2, 4))   # 2d4
10
7
7
3

This seems to work!

Repeated rolls

Now we need to call the roll_die function many times for the simulations.

We can do this with replicate as follows:

replicate(1e3, roll_dice(1, 20))

Histogram of roll results

We can have a very quick glance at what this results in by creating a bargraph with the resulting roll total on the x-axis, and the probability of getting that roll on the y-axis.

make_hist <- function(vec) {
  dat <- tibble(Result = vec)

  pl <- dat %>%
    ggplot(aes(Result)) +
    geom_bar(aes(y = stat(count) / sum(stat(count)))) +
    labs(y = "Probability")

  pl
}
replicate(1e5, roll_dice(1, 20)) %>%
  make_hist() + labs(title = "Histogram of 1e5 simulations of a d20 roll")

1d20hist.png

This seems ok.

What happens if we roll 2d4?

replicate(1e5, roll_dice(2, 4)) %>%
  make_hist() + labs(title = "Histogram of 1e5 simulations of 2d4 rolls")

2d4hist.png

Or 2d6?

replicate(1e5, roll_dice(2, 6)) %>%
  make_hist() + labs(title = "Histogram of 1e5 simulations of 2d6 rolls")

2d6hist.png

Tidying of the table

Here I’ve quickly (manually) tidied the table up for use in R.

RaceBase Heightbh_fbh_iHeight Modifiern_heightd_heightBase WeightWeight Modifiern_weightd_weight
Human“4'8"”48+2d10210110×(2d4) lb.24
Dwarf, hill“3'8"”38+2d424115×(2d6) lb.26
Dwarf, mountain“4'”40+2d424130×(2d6) lb.26
Elf, high“4'6"”46+2d1021090×(1d4) lb.14
Elf, wood“4'6"”46+2d10210100×(1d4) lb.14
Elf, drow“4'5"”45+2d62675×(1d6) lb.16
Halfling“2'7"”27+2d42435×1 lb.
Dragonborn“5'6"”56+2d828175×(2d6) lb.26
Gnome“2'11"”211+2d42435×1 lb.
Half-elf“4'9"”49+2d828110×(2d4) lb.24
Half-orc“4'10"”410+2d10210140×(2d6) lb.26
Tiefling“4'9"”49+2d828110×(2d4) lb.24

NOTE: I’m using emacs with ess in org-mode, and this allows me to name the sheet with #+TBLNAME: so that I can pass it into the header argument of a codeblock later on with :var dat=races. If you don’t use emacs/org-mode but, e.g. RStudio with RMarkdown, it’s easier to save the table as a csv first.

Sensible units

Now it’s time to read in the data and do some simulations!

We also convert everything into sensible units.

races <- dat %>%
  mutate(base_cm = bh_f * 30.48 + bh_i * 2.54,
         base_kg = Base.Weight * 0.4535923) %>%
  as_tibble()
RaceBase.Heightbh_fbh_iHeight.Modifiern_heightd_heightBase.WeightWeight.Modifiern_weightd_weightbase_cmbase_kg
Human448+2d10210110×(2d4) lb.24142.2449.895153
Dwarf, hill338+2d424115×(2d6) lb.26111.7652.1631145
Dwarf, mountain440+2d424130×(2d6) lb.26121.9258.966999
Elf, high446+2d1021090×(1d4) lb.14137.1640.823307
Elf, wood446+2d10210100×(1d4) lb.14137.1645.35923
Elf, drow445+2d62675×(1d6) lb.16134.6234.0194225
Halfling227+2d42435×1 lb.nilnil78.7415.8757305
Dragonborn556+2d828175×(2d6) lb.26167.6479.3786525
Gnome2211+2d42435×1 lb.nilnil88.915.8757305
Half-elf449+2d828110×(2d4) lb.24144.7849.895153
Half-orc4410+2d10210140×(2d6) lb.26147.3263.502922
Tiefling449+2d828110×(2d4) lb.24144.7849.895153

Simulate weight and height dice-rolls

Now let’s simulate some dice-rolls! We’re creating some new list-columns, using purrr::map and then unnesting them for easier calculations.

First we define a new function that replicates the analysis:

rep_dice <- function(n, d, n_sim = 1e5) {
  replicate(n_sim, roll_dice(n, d))
}

And then we run it for all the Races.

races_stats  <- races %>%
  mutate(height_rolls = map2(n_height, d_height, possibly(rep_dice, otherwise = 1)),
         weight_rolls = map2(n_weight, d_weight, possibly(rep_dice, otherwise = 1))) %>%
  unnest(cols = c(height_rolls, weight_rolls)) %>%
  mutate(height = base_cm + height_rolls * 2.54,  # convert roll from inches to cm
         weight = base_kg + height_rolls * weight_rolls * 0.4535923)  # convert rolls from lbs to kg

Note the tidyr::possibly here, which allows me to ignore the weight rolls for the Halfling and Gnome and instead set their value to 1.

Averages

Then we calculate median height and weight and append them back to the original data.

We also convert Race to a factor, which is sorted by the average height.

races_sum <- races_stats %>%
  group_by(Race) %>%
  summarize(height_med = median(height),
            weight_med = median(weight)) %>%
  left_join(races, by = "Race") %>%
  arrange(height_med) %>%
  mutate(Race = factor(Race, levels = Race),
         lab_kg = paste0(Height.Modifier, Weight.Modifier))
Raceheight_medweight_medBase.Heightbh_fbh_iHeight.Modifiern_heightd_heightBase.WeightWeight.Modifiern_weightd_weightbase_cmbase_kglab_kg
Halfling91.4418.143692227+2d42435×1 lb.nilnil78.7415.8757305+2d4×1 lb.
Gnome101.618.1436922211+2d42435×1 lb.nilnil88.915.8757305+2d4×1 lb.
Dwarf, hill124.4666.6780681338+2d424115×(2d6) lb.26111.7652.1631145+2d4×(2d6) lb.
Dwarf, mountain134.6273.9355449440+2d424130×(2d6) lb.26121.9258.966999+2d4×(2d6) lb.
Elf, drow152.443.5448608445+2d62675×(1d6) lb.16134.6234.0194225+2d6×(1d6) lb.
Elf, high165.151.7095222446+2d1021090×(1d4) lb.14137.1640.823307+2d10×(1d4) lb.
Elf, wood165.156.2454452446+2d10210100×(1d4) lb.14137.1645.35923+2d10×(1d4) lb.
Half-elf167.6468.9460296449+2d828110×(2d4) lb.24144.7849.895153+2d8×(2d4) lb.
Tiefling167.6468.9460296449+2d828110×(2d4) lb.24144.7849.895153+2d8×(2d4) lb.
Human170.1873.4819526448+2d10210110×(2d4) lb.24142.2449.895153+2d10×(2d4) lb.
Half-orc175.2696.16156764410+2d10210140×(2d6) lb.26147.3263.502922+2d10×(2d6) lb.
Dragonborn190.5106.5941905556+2d828175×(2d6) lb.26167.6479.3786525+2d8×(2d6) lb.

Plot of Heights

Great! Now let’s create a plot of the average height by race, with a violin plot to illustrate the distribution.

I further annotate the plot with base height points and which modifier was used to get the distribution of heights.

pl_h <- races_sum %>%
  ggplot(aes(x = Race, y = height_med)) +
  geom_bar(stat="identity", alpha = .5) +
  geom_violin(aes(y = height), bw = 2.54, scale= "width", colour = NA, fill = "cornflowerblue", alpha = .8, data = races_stats) +
  geom_text(aes(y = base_cm + 2, hjust = 0, label = paste0(Height.Modifier, "'")), angle = 90) +
  geom_point(aes(y = base_cm)) +
  ylim(c(0, NA)) +
  labs(y = "Height (cm)") # +
  ## coord_flip()
pl_h

raceheights.png

Notice the bw argument to geom_violin: this is used to adjust the smoothing kernel a bit. I’ve used the value to convert my units in cm back to inches, because with lower values we get artificial jittering.

Plot of Weights

Now we do the same for weight:

pl_w <- races_sum %>%
  ggplot(aes(x = Race, y = weight_med)) +
  geom_bar(stat="identity", alpha = .5) +
  geom_violin(aes(y = weight), bw = 1 / 0.4535923, scale= "width", colour = NA, fill = "cornflowerblue", alpha = .8, data = races_stats) +
  geom_point(aes(y = base_kg)) +
  geom_text(aes(y = base_kg, label = lab_kg), hjust = -.05, angle = 90) +
  ylim(c(0, NA)) +
  labs(y = "Weight (kg)") # +
pl_w

raceweights.png

(Again, we set bw to the value to convert kg to lbs.)

Combined Plot

To ultimately combine the two into one figure using patchwork.

library(patchwork)
pl <- (pl_h + labs(title = "D&D 5e Race size and weight distributions based on rolls") &
       theme(axis.title.x = element_blank(),
             axis.text.x = element_blank(),
             axis.ticks.x = element_blank())) /
  (pl_w & theme(axis.text.x = element_text(size = 10, angle = 30, hjust = 1, face = "bold")))
pl

races_stats.png

Body Mass Index

Okay now for some more mental picturing, let’s calculate the average BMI for these races. BMI is a troublesome indicator for humans alone already, and will certainly be wrong for the heavy-boned dwarfs, but it’s nice to give us a little bit more of a mental picture.

I found these BMI categories on the WikiPedia article on BMI.

categoryfromto
Very severely underweight15
Severely underweight1516
Underweight1618.5
Normal (healthy weight)18.525
Overweight2530
Obese Class I (Moderately obese)3035
Obese Class II (Severely obese)3540
Obese Class III (Very severely obese)40
# clean up the categories
cat <- categories %>%
  mutate(from = ifelse(is.na(from), -Inf, from),
         to = ifelse(is.na(to), Inf, to),
         category = factor(category, levels = rev(category), ordered = TRUE))

# calculate average bmi
bmi_avg <- races_sum %>% mutate(bmi = weight_med / (height_med/100)^2)

# calculate all bmi's
bmi <- races_stats %>%
  mutate(bmi = weight / (height / 100)^2)

# plot them
bmi_avg %>%
  ggplot(aes(x = Race, y = bmi)) +
  # annotate the categories
  geom_point() + # It looks like this is necessary to keep the factor levels in
                 # the right order
  geom_rect(aes(xmin = -Inf, xmax = Inf,
                ymin = from,
                ymax = to,
                fill = category),
            inherit.aes = FALSE, data = cat) +
  scale_fill_brewer(palette = "RdBu") +
  geom_violin(data = bmi, bw = .8, fill = "gray", draw_quantiles = c(.25, .5, .75)) +
  labs(fill = "BMI category\nif they would have been human", y = "BMI (kg /"~m^2*")") +
  geom_point() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1, face = "bold"))

races_bmi.png

So most dwarves are, according to the human BMI, very severely obese 😉.

And that’s it! A quick dive into some simulations with R! Any feedback on how to improve this workflow is welcome.

Other Dice roll simulations

Rolling With Advantage

In D&D-land you sometimes get to roll with advantage. This means that you roll a d20 twice, and take the higher of the two. I also wanted to study what happens when we do that, so we add a new function!

roll_with_advantage <- function(d = 20) {
  max(sample(seq_len(d), size = 2, replace = TRUE))
}
replicate(1e5, roll_with_advantage(20)) %>%
  make_hist() + labs(title = "Histogram of 1e5 simulations of d20 rolls with advantage")

d20_advantage.png

Rolling With Disadvantage

When you’re particularly unskilled at something, your DM may ask you to roll with disadvantage. This means: roll 2d20 and take the lower.

roll_with_disadvantage <- function(d = 20) {
  min(sample(seq_len(d), size = 2, replace = TRUE))
}
replicate(1e5, roll_with_disadvantage(20)) %>%
  make_hist() + labs(title = "Histogram of 1e5 simulations of d20 rolls with disadvantage")

d20_disadvantage.png

Rolling for Stats

Some DM’s let you roll for stats. The common way of doing so is by rolling 4d6 and dropping the lower. Then repeating this 6 times.

When I did this the first time, I got some pretty high rolls and I wondered what the odds are. So again, time to simulate!

stat_roll <- function() {
  # roll 4d6, drop the lowest
  rolls <- sample(1:6, 4, replace = TRUE)
  # use the highest two values only
  sum(sort(rolls)[-1])
}

So if you want to roll for stats without rolling any dice (BOOOOO!):

replicate(6, stat_roll())

comparison to pointbuy and standard array

comparison <- tribble( ~ name, ~ array,
                      "standard", c(8, 10, 12, 13, 14, 15),
                      "pointbuy 3 high 3 low", c(15, 15, 15, 8, 8, 8),
                      "pointbuy all medium", c(13, 13, 13, 12, 12, 12),
                      ) %>%
  mutate(sum = map_dbl(array, sum))
comparison %>% select(-array)
namesum
standard72
pointbuy 3 high 3 low69
pointbuy all medium75

Let’s visualize the likelihood of all the total values:

replicate(1e5, stat_roll()) %>%
  make_hist() + labs(title = "Rolling for stats using the roll 4d6 drop lowest method") +
  geom_bar(aes(x = array, y = stat(count) / sum(stat(count)), group = name, fill = name),
           data = comparison %>% unnest(array),
           width = .5,
           alpha = .6)

stat_rolls.png

sr <- replicate(1e5, stat_roll()) %>%
  as_tibble() %>%
  mutate(name = "roll for stats") %>%
  bind_rows(comparison %>% unnest(array) %>% rename(value=array)) %>%
  group_by(name) %>%
  summarize(min = min(value),
            mean = mean(value),
            median = median(value),
            max = max(value))
nameminmeanmedianmax
pointbuy 3 high 3 low811.511.515
pointbuy all medium1212.512.513
roll for stats312.245061218
standard81212.515

let’s calculate when the sum of ability scores is highest

simulate 1e5 sets of 6 stats, take the sum

sr <- replicate(1e5, sum(replicate(6, stat_roll()))) %>%
  as_tibble()
make_hist(sr$value) +
 geom_vline(aes(xintercept = sum, colour = name), data = comparison, size = 2, alpha = .5)

stat_sum_vs_stdarray.png do some calculations

sb <- sr %>%
  mutate(
    rbs = value > comparison$sum[[1]],
    rbp1 = value > comparison$sum[[2]],
    rbp2 = value > comparison$sum[[3]],
  )

how often is rolling better than pointbuy or standard array?

tribble(~ name, ~ `P roll better than X`,
  "standardarray", sum(sb$rbs) / nrow(sb),
  "pointbuy 3 high 3 low", sum(sb$rbp1) / nrow(sb),
  "pointbuy all medium", sum(sb$rbp2) / nrow(sb)
)
nameP roll better than X
standardarray0.56215
pointbuy 3 high 3 low0.71725
pointbuy all medium0.39306

Releases

No releases published

Packages

No packages published