6
\$\begingroup\$

A buddy and I were playing around with an idea for a combat damage model using multiple d6, which are reduced by a target's armor.

So, here’s the basic idea: Player rolls Xd6, any dice that come up equal to or less than an opponent’s armor, Y, are thrown out.

To make calculations snappier at the table, we wanted damage dealt to be either:

  1. the lowest value of the remaining dice, multiplied by the number of dice remaining

  2. the highest value of the remaining dice, multiplied by the number of dice remaining

  3. the most frequent value (or mode) of the remaining dice, multiplied by the number of dice remaining

I’ve tried a few different ways to write this up in anydice, but the program is unfamiliar and my programming muscles rusty. Any help would be greatly appreciated!

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Welcome to RPG.SE! Take the tour if you haven't already and see the help center or ask us here in the comments (use @ to ping someone) if you need more guidance. Good Luck and Happy Gaming! \$\endgroup\$
    – Someone_Evil
    Apr 1, 2022 at 16:58
  • \$\begingroup\$ @bighatjustin So yo have armor (Y=1,..,6) and you want to see the damage distribution if you set the numbers of d6 (X)? \$\endgroup\$ Apr 1, 2022 at 17:16
  • \$\begingroup\$ @FedericoMatonte yes that is exactly right. Want to see how various attack values stack up against various armor values, and how taking the lowest, highest, or most numerous value effects things too. Armor can actually start at 0 too \$\endgroup\$ Apr 1, 2022 at 17:41

1 Answer 1

11
\$\begingroup\$

First of all, to model your mechanics in AnyDice, you're going to need to write a function that accepts a sequence (i.e. a parameter tagged with :s) and pass your Xd6 roll to it. That's the general way in AnyDice to implement arbitrary dice inspection mechanics.

Inside the function (which AnyDice will automatically invoke for every possible Xd6 roll) the parameter will be a normal X element sequence of numbers between 1 and 6 (sorted from highest to lowest), which you can examine in any way you like to calculate the damage.


Next, you'll need some way to select the dice that rolled above Y. AnyDice doesn't have a function for that built in, but you could write one yourself, e.g. something like:

function: remove VALUES:s from SEQUENCE:s {
  KEPT: {}
  loop N over SEQUENCE {
    if !(N = VALUES) {
      KEPT: {KEPT, N}
    }
  }
  result: KEPT
}

With this helper function, implementing the first two of your damage calculation mechanics is fairly straightforward:

function: count times highest above Y:n of ROLL:s {
  KEPT: [remove {1..Y} from ROLL]
  result: #KEPT * 1@KEPT
}

function: count times lowest above Y:n of ROLL:s {
  KEPT: [remove {1..Y} from ROLL]
  result: #KEPT * (#KEPT)@KEPT
}

The last one is a bit trickier, since we also need some way to calculate the most common value among the remaining rolls. Fortunately we can write a helper function for that too, e.g. like this:

function: most common value in SEQUENCE:s {
  MODE: 0
  MAX: 0
  loop N over SEQUENCE {
    COUNT: N = SEQUENCE
    if COUNT > MAX {
      MODE: N
      MAX: COUNT
    }
  }
  result: MODE
}

function: count times most common above Y:n of ROLL:s {
  KEPT: [remove {1..Y} from ROLL]
  result: #KEPT * [most common value in KEPT]
}

Note that if two values are tied for most common, this function prefers the first one in the sequence — i.e. the highest, using AnyDice's default sorting for dice rolls. We can invert this tie breaking roll by reversing the sequence before passing it to the helper function, like this:

function: count times most common above Y:n of ROLL:s prefer lowest {
  KEPT: [remove {1..Y} from ROLL]
  result: #KEPT * [most common value in [reverse KEPT]]
}

Now, to compare the different methods, all we need to do is call these functions, e.g. like this:

X: 4
Y: 2

D: d{1:Y, Y+1 .. 6}  \ use relabeled die for faster calculation! \ 

output [count times highest above Y of XdD] named "[X]d6 > [Y], count * highest"
output [count times lowest above Y of XdD] named "[X]d6 > [Y], count * lowest"
output [count times most common above Y of XdD] named "[X]d6 > [Y], count * most common"
output [count times most common above Y of XdD prefer lowest] named "[X]d6 > [Y], count * most common (prefer lowest)"

Note a minor speed optimization I added to this code: since none of the functions care about the specific values of rolls between 1 and Y, I defined a custom die D that looks like a d6 with all sides up to Y relabeled as 1. By using this relabeled die instead of a normal d6 we can tell AnyDice not to waste time enumerating all the possible combinations of those rolls that we're going to throw away anyway. Of course, you can replace XdD in the code above with Xd6 and confirm that it still gives the same results, just a bit slower.


The resulting damage distributions look pretty wonky, so it may be more instructive to just look at the average damage obtained using each method. For this, it can be convenient to run each function for all (meaningful) values of Y from 0 to 5 in a loop, e.g. like this:

X: 4

loop Y over {0..5} {
  D: d{1:Y, Y+1 .. 6}
  output [count times highest above Y of XdD] named "[X]d6 > [Y], count * highest"
}
loop Y over {0..5} {
  D: d{1:Y, Y+1 .. 6}
  output [count times lowest above Y of XdD] named "[X]d6 > [Y], count * lowest"
}
loop Y over {0..5} {
  D: d{1:Y, Y+1 .. 6}
  output [count times most common above Y of XdD] named "[X]d6 > [Y], count * most common"
}
loop Y over {0..5} {
  D: d{1:Y, Y+1 .. 6}
  output [count times most common above Y of XdD prefer lowest] named "[X]d6 > [Y], count * most common (prefer lowest)"
}

Looking at the results in summary mode, we see some interesting behavior:

AnyDice screenshot

For the rule variants using the highest retained roll, or the most common roll with a tie-breaker favoring high rolls, we see the average damage going down as the armor level Y goes up, as expected.

For the variants preferring lower rolls, however, the maximum average damage is actually attained for intermediate values of Y. In hindsight, this makes sense: with low Y, the number of kept dice may be higher, but it's multiplied by the lowest kept roll, which is typically quite low.

Thus, assuming that you in fact want higher Y to result in lower damage on average, I'd recommend going with variant 1 (number of rolls above Y × highest roll), or possibly with variant 3 (number of rolls above Y × most common roll above Y) with ties broken in favor of higher rolls.


Addendum: There's in fact a simpler and more efficient way to implement your first model variant:

function: count times highest above Y:n of ROLL:s {
  result: (ROLL > Y) * 1@ROLL
}

This works because the highest retained roll (if any) is always equal to the highest roll in the entire dice pool, i.e. 1@ROLL, and thus independent of the threshold Y. And if no rolls are retained (i.e. if ROLL > Y is zero) then the value of the highest roll doesn't matter anyway, since it will be multiplied by zero.

A similar trick can also be used for the second variant:

function: count times lowest above Y:n of ROLL:s {
  COUNT: ROLL > Y
  result: COUNT * COUNT@ROLL
}

Here, we're relying on the fact that ROLL is always sorted in descending order, so if COUNT values in ROLL are greater than Y, then COUNT@ROLL is the lowest of those. (If there are no rolls greater than Y, then 0@ROLL will return something arbitrary — actually zero — but again it doesn't matter, since that value will get multiplied by zero.)

Alas, I can't think of a similar clever trick for your third variant. It would be possible to calculate the result in one loop with no helper functions, e.g. like this:

function: count times most common above Y:n of ROLL:s {
  COUNT: ROLL > Y
  MODE: 0
  MAX: 0
  loop I over {1..COUNT} {
    N: I@ROLL = ROLL
    if N > MAX {
      MODE: I@ROLL
      MAX: N
    }
  }
  result: COUNT * MODE
}

But I don't think that's any more readable or easier to modify than the version using the helper functions, and it's probably not significantly faster, either.

\$\endgroup\$
3
  • \$\begingroup\$ Thanks so much for this amazing write-up! I noticed when I was playing around with my inferior code that the distribution was wonky--the idea was to make targets very tough as armor approaches 5, but with any damage that does slip through being significant. Sort of polarizing the damage values. \$\endgroup\$ Apr 1, 2022 at 18:15
  • \$\begingroup\$ @bighatjustin: One more damage calculation rule you could consider for that purpose would simply be taking the sum of all rolls above the armor value Y. Which, by the way, is very easy to calculate using AnyDice. :) \$\endgroup\$ Apr 1, 2022 at 18:25
  • \$\begingroup\$ Indeed, taking the sum of the remaining dice was, in fact, the first damage mechanic we arrived at. The problem, if you can even call it that--is that when dealing with large numbers of dice (10d6 for example), summing them is a bit more laborious for the human mind than searching for the highest value, counting, then multiplying. We simply thought it might be snappier for play with one of the above methods. We also toyed with the idea of variant 3 or 4 being the default method, and using variant 1 as a method for bonus damage. \$\endgroup\$ Apr 1, 2022 at 18:37

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .