3
\$\begingroup\$

I have been trying to create a complete probability generator for fifth edition D&D attack rolls in AnyDice, including features such as advantage/disadvantage, different crit ranges, bonus crit damage etc. I've run into an issue with abilities that only deal additional damage once per round such as Sneak Attack.

Pseudocode:

lots of variables
parse variables into dice \advantage=1 turns the attack roll into 1@2d20 etc\

function: attack ROLL:n {
 if crit fail {return:0}
 if crit hit {return: damage +crit damage}
 if normal hit {return: damage}
 if normal miss {return: 0}
}

function: multiattack NUMBER_OF_ATTACKS:n {
 TOTAL:0
 loop N over {1..NUMBER_OF_ATTACKS} {
  TOTAL:TOTAL+[attack ROLL]
 }
}

output [result of multiattack function]

What I want to do is add bonus damage to the first hit if there are any. Since AnyDice functions don't affect outside variables and end immediately on "result" and boolean functions only work with numbers and not sets or dice, I haven't found a good way of making a variable able to accurately tell if a hit landed already.

If I set a value inside the attack function, it will reset to that value on the next round of the loop. Variables inside a function don't change outside so calling the variable again won't make a difference (unless I missed something) and I haven't found a way to do it as part of the helper function. Any advice would be greatly appreciated.

\$\endgroup\$
5
\$\begingroup\$

What you need to do is write a function to "freeze" your hit die rolls, and call it recursively for each (unsuccessful) attack, something like this basic example:

ATK:  1d20  \ attack roll: use 1@2d20 for advantage or 2@2d20 for disadvantage \
HIT:    +0  \ bonus to attack roll \
AC:      8  \ target AC: attack hits if ATK + HIT >= AC \
CRIT:   20  \ minimum attack roll to crit \
DMG:   1d4  \ damage rolled on successful hit \
BONUS: 1d6  \ bonus damage on first hit \

function: attack ROLL:n {
  \ return the number of times damage is applied on an attack roll of ROLL \
  if ROLL <= 1 { result: 0 }     \ natural 1 is always a miss \
  if ROLL >= CRIT { result: 2 }  \ critical hit deals damage damage twice \
  result: ROLL + HIT >= AC
}

function: multiattack N:n times {
  result: [roll [attack ATK] to multiattack N times]
}
function: roll FIRSTHIT:n to multiattack N:n times {
  if N <= 0 {
    result: 0
  }
  if FIRSTHIT > 0 {
    DAMAGE: FIRSTHIT d (DMG + BONUS)
    loop I over {2..N} {
      DAMAGE: DAMAGE + [attack ATK] d DMG
    }
    result: DAMAGE
  } else {
    result: [roll [attack ATK] to multiattack N-1 times]
  }
}

output [multiattack 3 times]

The main [multiattack N:n times] function just calls the helper function [roll FIRSTHIT:n to multiattack N:n times]. The first argument (FIRSTHIT) to the helper function is given by [attack ATK], which returns the number of times damage is to be applied for this attack roll (i.e. 0 for miss, 1 for hit and 2 for crit).

Since this argument is marked as numeric (with :n) in the function definition, AnyDice internally calls the helper function with each possible return value of [attack ATK], and weighs its results according to the probabilities of the outcomes. This means that, even though the result of [attack ATK] is a (biased) die, inside the helper function its value is "frozen" as FIRSTHIT, which is a fixed number and thus can be used e.g. in if conditionals. (See the "Functions" section of the AnyDice documentation for more details.)

The helper function itself just checks if the attack hits (i.e. if FIRSTHIT > 0), and if so, applies the damage from this attack (plus the bonus, if any) and from any remaining attacks (without the bonus). On the other hand, if the attack misses, the helper function instead calls itself recursively with a new attack roll, and with the number of remaining attacks reduced by one.


Ps. The expression FIRSTHIT d (DMG + BONUS) may be worth discussing: it simply rolls the normal and bonus damage FIRSTHIT times, and adds up the results. (On the other hand, FIRSTHIT * (DMG + BONUS) would yield the result of rolling the normal and bonus damage once, and multiplying it by FIRSTHIT.) Thus, if FIRSTHIT equals 2, the damage (including any Sneak Attack or other bonuses) will be rolled twice, as the 5e rules specify for critical hits. Similarly, inside the loop, the expression [attack ATK] d DMG gives the result of rolling DMG either zero, one or two times, according to the probabilities given by the [attack ATK] roll.

In fact, all the code inside the if FIRSTHIT > 0 conditional block could be replaced with the following expression:

result: FIRSTHIT d (DMG + BONUS) + (N-1) d ([attack ATK] d DMG)

Here, (N-1) d ([attack ATK] d DMG) gives the result of performing N-1 attack rolls, where each attack deals DMG damage [attack ATK] times.


Also, it's worth noting that AnyDice normally allows function calls to be nested only 10 levels deep (one of which is used up by the wrapper function in this example, and one by the [attack ATK] call). If you'd like to support multiattacks with more than 8 successive attack rolls, you should increase the recursion limit e.g. like this:

set "maximum function depth" to 999

However, to make such deep recursion run in a reasonable time, it's essential to make sure that the helper function call itself only on one specific value of its non-fixed parameters (e.g. in this case, when FIRSTHIT is zero). If it can recurse on two or more possible inputs, then the number of different possible sequences of recursive calls that AnyDice needs to check will grow exponentially with the recursion depth.

This is the reason why the code above uses the result of [attack ATK] (which can only be 0, 1 or 2) as the parameter for the helper function, instead of passing the attack roll ATK (which could be anything from 1 to 20) directly as a parameter. While doing it that way will work OK for a small number of attacks, it gets very slow very quickly as the recursion depth is increased.

In fact, we could optimize the code a bit more by precalculating the results of [attack ATK] and assigning them to a global custom die. This would speed up the code slightly, and also save one function nesting level.


Also note that the code above assumes that the bonus damage will always be applied on the first successful hit, regardless of whether it's a crit or not. This is probably optimal in most practical situations, but in principle, a player with a sufficiently large number of attacks remaining and a sufficiently high hit and/or crit change might be better off saving their Sneak Attack for later if they roll an early non-critical hit, on the assumption they'll probably roll a crit (or at least another normal hit) later. Modeling such advanced strategies is possible in AnyDice (generally also via recursion), but more complicated.

\$\endgroup\$
5
  • \$\begingroup\$ I like the idea, but there seems to be something wrong with your example function allowing the damage that should only be dealt once occur on multiple recursions. this becomes clear when you set the bonus damage to an arbitrarily high number like 1000. and values higher than 2000 appear on the list. \$\endgroup\$ – Daniel Jan 19 '18 at 9:23
  • \$\begingroup\$ @Daniel: That's a deliberate feature: in 5e, extra damage is doubled on a critical hit. If you don't want that, just replace FIRSTHIT d (DMG + BONUS) with (FIRSTHIT d DMG) + BONUS. Or you could even have something like FIRSTHIT d (DMG + BONUS_A) + BONUS_B, so that BONUS_A is doubled on a crit but BONUS_B isn't. \$\endgroup\$ – Ilmari Karonen Jan 19 '18 at 11:29
  • \$\begingroup\$ crits don't double damage. they double damage dice. but I was able to adjust your setup to one that works. thank you for your time \$\endgroup\$ – Daniel Feb 19 '18 at 17:55
  • \$\begingroup\$ @Daniel: That's exactly what FIRSTHIT d (DMG + BONUS) does: if FIRSTHIT = 2, then it rolls the DMG and BONUS dice (which can be anything Anydice calls "dice", i.e. arbitrary probability distributions over the integers) twice and adds up the results. If you wanted to double the damage after rolling the dice, you'd have to replace the d with *. \$\endgroup\$ – Ilmari Karonen Feb 19 '18 at 19:27
  • \$\begingroup\$ BTW, I just realized that that part of the code will not work as intended if DMG and BONUS are both fixed numbers. The simplest way to fix that would probably be to replace it with FIRSTHIT d (DMG + BONUS + d0) (and the DMG two lines below it with (DMG + d0)). But the current code works just fine as long as DMG is a die. \$\endgroup\$ – Ilmari Karonen Feb 19 '18 at 19:35

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.