Best-fit D&D damage to dice calculator
I was recently creating a new homebrew monster for a 5th edition D&D campaign I'm running, and I was horrified to discover how complex the monster creation rules in the Dungeon Master's Guide were. My interesting idea for a horrific aberration to give my players weeks of nightmares was immediately bogged down in the minutae of CR balance and stat calculation.
The quick rules in the guide were a bit of an improvement on the main set, but after asking for advice on Mastodon, I was pointed towards Blog of Holding's superb post on simplified monster generation. This was a huge improvement on the original rules, but in an ideal world, I'd be able to generate a monster in under 5 minutes once I have a clear idea in mind.
What slows me down is that for a given CR of monster, both rules produce damage output as flat numbers, not as die rolls. I don't want to be telling my players 'take 16 damage' every round - I want the joy and fear that can only come with rolling dice. But I also didn't want to spend time doing the maths to find out what amount of d4s, d6s, d8s, d10s or d12s would, on average, give me a result of 16. This sounded like a perfect problem for code!
First, let's clearly articulate the problem: we want a 'best fit' calculation. Furthermore, we only want to roll one type of die for a single attack - it's traditional, and less messy. If my intended average damage output is 16, I want to find out what number of one type of die I need to roll to get an average value as close as possible to 16 - whether that's a little below or a little above.
The Dungeon Master's Guide gives us a formula for the average of a given die type: sides / 2 + 0.5
. If we divide our total damage by this average, we find out how many dice we need to roll to make the total. If we round this value to the nearest integer (as we can't roll fractions of dice, at least not very successfully), we can also find out what percentage of the total this 'best roll' comes to:
// damage = 16, die average roll = 3.5
const numberOfDice = Math.round(damage / die.averageRoll); // 5
const damageWithDice = numberOfDice * die.averageRoll; // 17.5
const percentage = Math.round((damageWithDice / damage) * 100); // 109%
With d6s, we overshoot our average damage by 109% - not bad as a best fit!
The rest of the code will simply run through the entire set of dice from d4 to d20, comparing results until we find the best fit of all the best fits. We also add in an optional modifier
value, because we want to be able to calculate dice numbers for attack descriptions where we know the damage value and an attack modifier.
const diceMap = [
{ die: "d20", averageRoll: 10.5, sides: 20 },
{ die: "d12", averageRoll: 6.5, sides: 12 },
{ die: "d10", averageRoll: 5.5, sides: 10 },
{ die: "d8", averageRoll: 4.5, sides: 8 },
{ die: "d6", averageRoll: 3.5, sides: 6 },
{ die: "d4", averageRoll: 2.5, sides: 4 },
];
const calculateBestFit = (damage, modifier = 0) => {
// Remove the modifier from the damage - we can't roll the modifier damage,
// only the unmodified part of it.
if (modifier > 0) {
damage -= modifier;
}
return diceMap.reduce(
(lowest, die) => {
const numberOfDice = Math.round(damage / die.averageRoll);
const damageWithDice = numberOfDice * die.averageRoll + modifier;
const percentage = Math.round(
(damageWithDice / (damage + modifier)) * 100
);
// If the percentage is closer to 100% than the previous best fit,
// use this one. Math.abs() turns a negative value positive.
if (Math.abs(percentage - 100) < Math.abs(lowest.percentage - 100)) {
// Assign the new low value to the reducer's accumulator
return {
die,
numberOfDice,
damage: damageWithDice,
percentage,
};
}
// Don't change the accumulator
return lowest;
},
{ percentage: Infinity }
);
};
This function will give us a single best fit value (for 16, it's 3d10, incidentally, giving us an average value of 16.5 and a 103% overshoot). But what if we want to see the whole range of best fits, to compare them with each other? For instance, we might prefer to use 6d4s instead, because they deal 15 damage (a 94% undershoot) but have a higher minimum damage value of 6 than the 3 we get from the d10s.
We can do this by running .map()
instead of .reduce()
over the list of dice.
const calculateAllPossibleRolls = (damage, modifier = 0) => {
if (modifier > 0) {
damage -= modifier;
}
return diceMap
.map((die) => {
const numberOfDice = Math.round(damage / die.averageRoll);
// Some die types are too small to provide the target value.
if (numberOfDice === 0) return null;
const damageWithDice = numberOfDice * die.averageRoll + modifier;
const percentage = Math.round(
(damageWithDice / (damage + modifier)) * 100
);
return {
numberOfDice,
die,
damage: damageWithDice,
percentage,
};
})
.filter(Boolean);
};
This code uses the neat filter(Boolean)
trick, which removes null values from the final array before it's returned. I prefer it to .filter(v => v)
because to my mind, filtering against a Boolean constructor makes more sense than just assuming that .filter()
will return a truthy value for an element.
And that's it! With this second function, we can get a full array of all possible best fit die rolls between d4 and d20. I've created an NPM package which wraps these functions in a command line interface. You can install it and run it from your command line, and you too will finally get the full lowdown on all the different ways you can approximate an average of 16 damage with a die roll:
$ npm install --global damage-to-dice
$ damage-to-dice 16
Best fit: 3d10+0 = 16.5
----------------------------------------
All possible rolls:
2d20+0 = 21 (131% of target damage); min: 2, max: 40
2d12+0 = 13 (81% of target damage); min: 2, max: 24
3d10+0 = 16.5 (103% of target damage); min: 3, max: 30
4d8+0 = 18 (113% of target damage); min: 4, max: 32
5d6+0 = 17.5 (109% of target damage); min: 5, max: 30
6d4+0 = 15 (94% of target damage); min: 6, max: 24