Getting Started#

In this tutorial, we’ll learn how to simulate probabilities with pathfinder2e_stats.

To follow this tutorial, you’ll need to have at least a basic understanding of

If you don’t have your Jupyter Notebook development environment ready yet, go back to Installation.

Rolling some dice#

Let’s start simple - let’s import the module and roll a d6.

We’re going to roll it one hundred thousand times.

import pathfinder2e_stats as pf2

oned6 = pf2.roll(1, 6)
oned6
<xarray.DataArray (roll: 100000)> Size: 800kB
array([6, 4, 4, ..., 1, 1, 2], shape=(100000,))
Dimensions without coordinates: roll

pathfinder2e_stats functions return standard xarray.DataArray and xarray.Dataset objects, which can be analyzed with standard data science techniques. We can start immediately answering some questions - for example, what is the mean roll?

oned6.mean()
<xarray.DataArray ()> Size: 8B
array(3.49798)

Note that the result above is a numerical approximation: the mean of rolling 1d6 an infinite amount of times is exactly 3.5. If we roll it less times than that, however, there’s going to be some error.

If we roll it again, we are going to get a different sequence. This is because pathfinder2e_stats uses a global random number generator, which by default is reset to a fixed seed every time you restart your notebook. See seed().

pf2.roll(1, 6)
<xarray.DataArray (roll: 100000)> Size: 800kB
array([3, 4, 1, ..., 4, 3, 4], shape=(100000,))
Dimensions without coordinates: roll

For the sake of convenience, we can quickly visualize arbitrary DataArrays and Dataset with the custom accessor .display(), available for all xarray objects after importing pathfinder2e_stats:

pf2.roll(1, 6).display("1d6")
variable 1d6
count 100000.000000
mean 3.500710
std 1.703475
min 1.000000
25% 2.000000
50% 3.000000
75% 5.000000
max 6.000000

Well, that was easy, but we could have figured out the answer by doing the maths on the back of an envelope! Let’s move on to something that is more complicated. A timeless classic: a 6d6 Fireball!

fireball = pf2.roll(6, 6)
fireball
<xarray.DataArray (roll: 100000)> Size: 800kB
array([17, 22, 24, ..., 24, 17, 21], shape=(100000,))
Dimensions without coordinates: roll

What is the damage distribution? First we’re going to calculate it numerically; then we’ll visualize it with matplotlib (but we could use any other library, like plotly or hvplot).

fireball.value_counts("roll").to_pandas().to_frame("count")
count
unique_value
6 4
7 13
8 48
9 121
10 264
11 521
12 1036
13 1615
14 2400
15 3476
16 4853
17 6230
18 7440
19 8258
20 8969
21 9282
22 9135
23 8317
24 7272
25 6132
26 4859
27 3578
28 2477
29 1670
30 1005
31 548
32 292
33 123
34 46
35 16
_ = fireball.to_pandas().hist(bins=30)
../_images/6004877e1b5f3cbe9aa59097afcec5becf4eeef4bc66951fb3feecfa68e25ef4.png

But wait - that’s just the base damage! The actual damage of a fireball depends on the target’s reflex saving throw, as well as their resistances, immunities and weaknesses. pathfinder2e_stats makes dealing with all this very easy.

Rolling checks#

In Pathfinder, a check is whenever one rolls a d20+bonus against a DC; this includes attack rolls against AC.

For example, a paladin with +8 Diplomacy tries to convince a guard to let them pass. The DC is 15. To simulate that, we call check().

request = pf2.check(8, DC=15)
request
<xarray.Dataset> Size: 2MB
Dimensions:  (roll: 100000)
Dimensions without coordinates: roll
Data variables:
    bonus    int64 8B 8
    DC       int64 8B 15
    natural  (roll) int64 800kB 4 2 12 19 3 9 7 3 10 8 ... 19 2 12 2 3 7 11 4 1
    outcome  (roll) int64 800kB 0 0 1 2 0 1 1 0 1 1 1 ... 2 0 2 0 1 0 0 1 1 0 -1
Attributes:
    legend:   {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'Succe...

The output of check() is a Dataset, which contains several variables. We normally only care about the last one, outcome. However, there are several other variables before it that explain how we reached that outcome, allowing us to fully trace its logic:

  • natural is the bare d20 roll, 100,000 times

  • outcome is the roll’s degree of success, taking into account critical success/failure rules, natural 1s and 20s.

outcome is an integer (sadly there are no categorical dtypes in xarray yet), whose meaning is mapped in the legend attribute of the dataset, as shown above. It is also available in the DoS enum. For the sake of robustness and readability, when you express an outcome (we’ll see later when and how) you should always use DoS and never its numerical value.

Have a look at the API documentation for additional parameters, such as fortune/misfortune effects to roll twice and take highest/lowest, conditionally using hero points depending on initial outcome and special rules like the Keen rune.

You can aggregate the result by using handy helpers such as outcome_counts():

# Probability to get each outcome
pf2.outcome_counts(request).to_pandas().to_frame("%") * 100.0
%
outcome
Critical success 20.106
Success 49.969
Failure 24.981
Critical failure 4.944

It’s also common to compare against DoS. Operators >, >=, <, and <= are supported:

# Probability to get at least a success
(request.outcome >= pf2.DoS.success).mean().item()
0.70075
# Probability to get a critical success
(request.outcome == pf2.DoS.critical_success).mean().item()
0.20106

Attack rolls, saving throws, counteract checks, flat checks, etc. work exactly in the same way as skill checks. For example, the party rogue can Strike a bandit (AC22) with his +14 rapier:

strike = pf2.check(14, DC=22)
pf2.outcome_counts(strike).to_pandas().to_frame("%") * 100.0
%
outcome
Critical success 14.846
Success 50.079
Failure 29.981
Critical failure 5.094

Or a wizard can blast the bandit, who has +10 reflex, with his DC21 fireball:

reflex_save = pf2.check(10, DC=21)
pf2.outcome_counts(reflex_save).to_pandas().to_frame("%") * 100.0
%
outcome
Critical success 5.083
Success 44.984
Failure 44.972
Critical failure 4.961

Finally, with map_outcome() you can post-process the check outcome, for example to define the Evasion class feature or similar (if you roll a success, you get a critical success instead):

save_with_evasion = pf2.map_outcome(reflex_save, evasion=True)
pf2.outcome_counts(save_with_evasion).to_pandas().to_frame("%") * 100.0
%
outcome
Critical success 50.067
Failure 44.972
Critical failure 4.961

Damage profiles#

Previously, we saw how to roll raw 6d6. However, let’s refine that - let’s define the damage profile of a fireball, which we’re going to roll in the next section.

fireball = pf2.Damage("fire", 6, 6, basic_save=True)
fireball
Damage 6d6 fire, with a basic saving throw

Damage offers many keyword arguments and supports addition. Let’s have a rogue’s 2d8+3 deadly d8 rapier, with 1d6 sneak attack:

rapier = pf2.Damage("piercing", 2, 6, 3, deadly=8)
sneak_attack = pf2.Damage("precision", 1, 6)
rapier + sneak_attack
Damage 2d6+3 piercing deadly d8 plus 1d6 precision

When rolling damage in the next chapter, we’ll see that pathfinder2e_stats automatically manages the deadly, fatal, etc. traits. To preview the breakdown of what’s going to be rolled for each degree of success, we can call the expand() method:

(rapier + sneak_attack).expand()
Critical success (2d6+3)x2 piercing plus 1d8 piercing plus (1d6)x2 precision
Success 2d6+3 piercing plus 1d6 precision

Note how the basic_save=True flag on the fireball damage profile means it expands differently from a weapon:

fireball.expand()
Success (6d6)/2 fire
Failure 6d6 fire
Critical failure (6d6)x2 fire

If basic save/basic attack damage rules, deadly, fatal, etc. are not enough, it’s possible to hand-craft more sophisticated damage profiles with ExpandedDamage - which is what you get when you call expand(). You can define an ExpandedDamage by initialising the class directly or by adding it to Damage. For example, let’s define a Flaming rune:

flaming_rune = pf2.Damage("fire", 1, 6) + {
    pf2.DoS.critical_success: [pf2.Damage("fire", 1, 10, persistent=True)]
}
flaming_rune
Critical success (1d6)x2 fire plus 1d10 persistent fire
Success 1d6 fire

Our rogue is getting an upgrade! Note that adding Damage + ExpandedDamage always expands the Damage first, so you’ll no longer read deadly d8 but the full success/critical success outcome for it.

flaming_rapier = rapier + sneak_attack + flaming_rune
flaming_rapier
Critical success (2d6+3)x2 piercing plus 1d8 piercing plus (1d6)x2 precision plus (1d6)x2 fire plus 1d10 persistent fire
Success 2d6+3 piercing plus 1d6 precision plus 1d6 fire

An ExpandedDamage is just a fancy mapping of lists of Damage, so usual mapping conversion techniques work:

dict(flaming_rapier)
{<DoS.critical_success: 2>: [**Damage** (2d6+3)x2 piercing,
  **Damage** 1d8 piercing,
  **Damage** (1d6)x2 precision,
  **Damage** (1d6)x2 fire,
  **Damage** 1d10 persistent fire],
 <DoS.success: 1>: [**Damage** 2d6+3 piercing,
  **Damage** 1d6 precision,
  **Damage** 1d6 fire]}

Rolling damage#

Now that we have the output of a check(), like an attack roll or a saving throw, and the Damage profile, we can finally roll some damage().

Let’s reuse the strike outcome from above to roll damage for the flaming rapier:

flaming_rapier_damage = pf2.damage(strike, flaming_rapier)
flaming_rapier_damage
<xarray.Dataset> Size: 20MB
Dimensions:                  (roll: 100000, damage_type: 3, persistent_round: 3)
Coordinates:
  * damage_type              (damage_type) <U9 108B 'fire' ... 'precision'
Dimensions without coordinates: roll, persistent_round
Data variables:
    bonus                    int64 8B 14
    DC                       int64 8B 22
    natural                  (roll) int64 800kB 18 16 19 15 19 ... 10 16 4 18 10
    outcome                  (roll) int64 800kB 2 1 2 1 2 0 1 ... 0 0 1 1 0 2 1
    direct_damage            (roll, damage_type) int64 2MB 10 21 2 1 ... 1 15 2
    persistent_damage        (roll, damage_type, persistent_round) int64 7MB ...
    persistent_damage_DC     (damage_type) int64 24B 15 15 15
    persistent_damage_check  (roll, damage_type, persistent_round) int64 7MB ...
    apply_persistent_damage  (roll, damage_type, persistent_round) bool 900kB ...
    total_damage             (roll) int64 800kB 43 20 59 20 36 ... 12 21 0 46 18
Attributes:
    legend:       {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
    damage_spec:  {'Critical success': '(2d6+3)x2 piercing plus 1d8 piercing ...

damage() makes a copy of the dataset from the check outcome and adds variables to it. Again, most times we’re going to care only about total_damage, but it can be interesting to understand how we got there:

  • direct_damage how much immediate, simple damage we got on each of the 100,000 attacks. This is broken down by damage_type between piercing, precision and fire.

  • persistent_damage persistent fire damage caused by the Flaming rune on critical hits. This is rolled by default for 3 rounds, after which we assume either that the target expired, the combat ended, or the persistent damage ended on its own.

  • persistent_damage_DC DC for persistent damage to end on its own each round.

  • persistent_damage_check the outcome of the flat check at the end of each of the 3 rounds to end the persistent damage from continuing into the next round.

  • apply_persistent_damage whether the persistent_damage is still ongoing in this round or it already ended thanks to a successful save on a previous round.

  • total_damage the sum of direct damage, persistent damage over all the rounds, and splash damage over multiple targets, with the damage type squashed.

damage() also supports defining weaknesses, resistances, and immunities.

We can invoke .display here too for a quick overview:

flaming_rapier_damage.display(transpose=True)
variable
bonus 14
DC 22
count mean std min 25% 50% 75% max
variable damage_type persistent_round
natural 100000.0 10.46946 5.765175 1.0 5.0 10.0 15.0 20.0
outcome 100000.0 0.74677 0.766753 -1.0 0.0 1.0 1.0 2.0
direct_damage fire 100000.0 2.78861 2.971520 0.0 0.0 2.0 5.0 12.0
piercing 100000.0 8.66395 8.483261 0.0 0.0 9.0 12.0 38.0
precision 100000.0 2.78481 2.965267 0.0 0.0 2.0 5.0 12.0
persistent_damage fire 0 100000.0 0.81526 2.244661 0.0 0.0 0.0 0.0 10.0
1 100000.0 0.81188 2.240255 0.0 0.0 0.0 0.0 10.0
2 100000.0 0.81176 2.236411 0.0 0.0 0.0 0.0 10.0
piercing 0 100000.0 0.00000 0.000000 0.0 0.0 0.0 0.0 0.0
1 100000.0 0.00000 0.000000 0.0 0.0 0.0 0.0 0.0
2 100000.0 0.00000 0.000000 0.0 0.0 0.0 0.0 0.0
precision 0 100000.0 0.00000 0.000000 0.0 0.0 0.0 0.0 0.0
1 100000.0 0.00000 0.000000 0.0 0.0 0.0 0.0 0.0
2 100000.0 0.00000 0.000000 0.0 0.0 0.0 0.0 0.0
persistent_damage_check fire 0 100000.0 0.30043 0.458447 0.0 0.0 0.0 1.0 1.0
1 100000.0 0.29883 0.457747 0.0 0.0 0.0 1.0 1.0
2 100000.0 0.30197 0.459115 0.0 0.0 0.0 1.0 1.0
piercing 0 100000.0 0.29923 0.457923 0.0 0.0 0.0 1.0 1.0
1 100000.0 0.30108 0.458730 0.0 0.0 0.0 1.0 1.0
2 100000.0 0.30115 0.458760 0.0 0.0 0.0 1.0 1.0
precision 0 100000.0 0.29979 0.458168 0.0 0.0 0.0 1.0 1.0
1 100000.0 0.30074 0.458582 0.0 0.0 0.0 1.0 1.0
2 100000.0 0.29799 0.457377 0.0 0.0 0.0 1.0 1.0
apply_persistent_damage fire 0 100000.0 1.00000 0.000000 1.0 1.0 1.0 1.0 1.0
1 100000.0 0.69957 0.458447 0.0 0.0 1.0 1.0 1.0
2 100000.0 0.49059 0.499914 0.0 0.0 0.0 1.0 1.0
piercing 0 100000.0 1.00000 0.000000 1.0 1.0 1.0 1.0 1.0
1 100000.0 0.70077 0.457923 0.0 0.0 1.0 1.0 1.0
2 100000.0 0.48907 0.499883 0.0 0.0 0.0 1.0 1.0
precision 0 100000.0 1.00000 0.000000 1.0 1.0 1.0 1.0 1.0
1 100000.0 0.70021 0.458168 0.0 0.0 1.0 1.0 1.0
2 100000.0 0.48940 0.499890 0.0 0.0 0.0 1.0 1.0
total_damage 100000.0 16.02024 16.954767 0.0 0.0 15.0 20.0 84.0
damage_type fire piercing precision
variable
persistent_damage_DC 15 15 15

From here we can start dicing and slicing with standard data science techniques. For example, let’s plot the damage distribution:

flaming_rapier_damage.total_damage.to_pandas().hist(
    bins=flaming_rapier_damage.total_damage.max().item() + 1
)
<Axes: >
../_images/c0f7d2b9fc209164a34b45608f5b8de5f8fe9dc805def48018b401b40939fead.png

The above clearly shows the three distributions depending on the outcome of the attack roll:

  • Miss and Critical Miss no damage

  • Hit 4d6+3

  • Critical Hit (4d6+3)x2 + 1d8 + 1d10 persistent over up to 3 rounds

Let’s exclude misses:

rapier_hit_dmg = flaming_rapier_damage.total_damage[
    flaming_rapier_damage.outcome >= pf2.DoS.success
]
rapier_hit_dmg.min().item()
7
rapier_hit_dmg.to_pandas().hist(bins=rapier_hit_dmg.max().item())
<Axes: >
../_images/29ecc7514770c801e2996a29227f87a6e03dbab848653c04788aa67cab1273cd.png

Let’s do the same for the fireball and let’s observe the 4 intersecting distributions for the different saving throw outcomes:

  • Critical Success no damage

  • Success (6d6)/2

  • Failure 6d6

  • Critical Failure (6d6)x2

fireball_damage = pf2.damage(reflex_save, fireball)
fireball_damage.total_damage.to_pandas().hist(
    bins=fireball_damage.total_damage.max().item() + 1
)
<Axes: >
../_images/95d815027f08a38df4d02fb7cfe88b0c2169f6b248ad08aa2c59c1c789ec0d56.png

Multiple targets and what-if analysis#

You may want to hit multiple targets with the same fireball, each rolling a separate saving throw.

In almost all functions of pathfinder2e_stats, instead of scalars you can pass as inputs DataArray objects with arbitrary dimensions and coordinates.

Earlier, we had a bandit with +10 Reflex roll against a DC21 fireball:

reflex_save = pf2.check(10, DC=21)

Let’s have the fireball hit three targets instead:

import xarray

reflex_bonus = xarray.DataArray(
    [10, 13, 11],
    dims=["target"],
    coords={"target": ["Alice", "Bob", "Charlie"]},
)
reflex_bonus
<xarray.DataArray (target: 3)> Size: 24B
array([10, 13, 11])
Coordinates:
  * target   (target) <U7 84B 'Alice' 'Bob' 'Charlie'

In the above example, the coords parameter is optional, but helps track what’s what in the next steps.

We’re going to roll the saving throw and the damage in a moment. Before we do that, we need to first go through the topic, in statistics, of dependent and independent variables. Two dependent variables are those that can be obtained by transforming the same original random variable through deterministic functions. Independent variables are those that don’t; in other words they’re not perfectly (anti)correlated.

In pathfinder2e_stats, every time you add extra dimensions to the inputs of check() or damage(), you need to declare whether the points along the new dimension should be rolled only once or separately, by passing parameters independent_dims and dependent_dims.

In the case of a fireball, each target rolls a separate check to save; the damage is rolled only once and then halved/doubled depending on the outcome:

reflex_save = pf2.check(reflex_bonus, DC=21, independent_dims=["target"])
fireball_damage = pf2.damage(reflex_save, fireball, dependent_dims=["target"])
fireball_damage
<xarray.Dataset> Size: 10MB
Dimensions:        (target: 3, roll: 100000, damage_type: 1)
Coordinates:
  * target         (target) <U7 84B 'Alice' 'Bob' 'Charlie'
  * damage_type    (damage_type) <U4 16B 'fire'
Dimensions without coordinates: roll
Data variables:
    bonus          (target) int64 24B 10 13 11
    DC             int64 8B 21
    natural        (roll, target) int64 2MB 13 19 14 12 4 6 ... 7 15 19 17 2 20
    outcome        (roll, target) int64 2MB 1 2 1 1 0 0 0 1 ... 0 1 0 1 1 1 0 2
    direct_damage  (roll, target, damage_type) int64 2MB 6 0 6 10 ... 9 7 15 0
    total_damage   (roll, target) int64 2MB 6 0 6 10 20 20 24 ... 19 9 9 7 15 0
Attributes:
    legend:       {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
    damage_spec:  {'Success': '(6d6)/2 fire', 'Failure': '6d6 fire', 'Critica...

Note how variables natural, outcome, direct_damage and total_damage have gained the extra dimension target, and how the target coordinate was propagated from the input. Let’s have a look at some damage outputs to observe that saving throws were indeed independent but the damage is not:

fireball_damage.total_damage.isel(roll=slice(10)).to_pandas()
target Alice Bob Charlie
roll
0 6 0 6
1 10 20 20
2 24 12 12
3 17 8 8
4 26 13 13
5 20 0 40
6 11 23 11
7 11 11 11
8 38 19 19
9 7 7 7

Which dimensions are dependent and which are independent depends on the situation. Consider:

  • You want to hit 3 times with MAP. All attack and damage rolls are independent:

MAP = xarray.DataArray([0, -5, -10], dims=["strike"])
strike = pf2.check(14 + MAP, DC=22, independent_dims=["strike"])
damage = pf2.damage(strike, flaming_rapier, independent_dims=["strike"])
  • You want to hit two targets with Swipe. You roll attack and damage only once; your attack may miss on one target and hit or critically hit on the other depending on their ACs:

AC = xarray.DataArray([22, 15], dims=["target"])
strike = pf2.check(14, DC=AC, dependent_dims=["target"])
damage = pf2.damage(strike, flaming_rapier, dependent_dims=["target"])
  • You want to know if it’s worthwhile to spend an action to move into flanking position, given the same target. Roll attack and damage only once and compare it against the AC with and without flanking. This type hypothetical study is commonly called a what-if analysis:

off_guard = xarray.DataArray(
    [0, -2], dims=["off-guard"], coords={"off-guard": [False, True]}
)
strike = pf2.check(14, DC=22 + off_guard, dependent_dims=["off-guard"])
damage = pf2.damage(strike, flaming_rapier, dependent_dims=["off-guard"])

What’s the impact on the damage distribution of the off-guard condition, given this specific attack bonus, base AC, and weapon?

damage.total_damage.display()
variable total_damage
off-guard False True
count 100000.000000 100000.000000
mean 16.054840 21.120090
std 16.993548 19.096843
min 0.000000 0.000000
25% 0.000000 7.000000
50% 15.000000 17.000000
75% 20.000000 26.000000
max 82.000000 86.000000

You can combine extra dimensions arbitrarily. For example, let’s strike the same target 3 times with increasing MAP, and observe how much getting them off-guard matters:

strike = pf2.check(
    14 + MAP,
    DC=22 + off_guard,
    independent_dims=["strike"],
    dependent_dims=["off-guard"],
)
damage = pf2.damage(
    strike,
    flaming_rapier,
    independent_dims=["strike"],
    dependent_dims=["off-guard"],
)
damage
<xarray.Dataset> Size: 118MB
Dimensions:                  (strike: 3, off-guard: 2, roll: 100000,
                              damage_type: 3, persistent_round: 3)
Coordinates:
  * off-guard                (off-guard) bool 2B False True
  * damage_type              (damage_type) <U9 108B 'fire' ... 'precision'
Dimensions without coordinates: strike, roll, persistent_round
Data variables:
    bonus                    (strike) int64 24B 14 9 4
    DC                       (off-guard) int64 16B 22 20
    natural                  (roll, strike) int64 2MB 5 20 3 8 14 ... 15 6 3 11
    outcome                  (roll, strike, off-guard) int64 5MB 0 0 2 ... 0 0 0
    direct_damage            (roll, strike, off-guard, damage_type) int64 14MB ...
    persistent_damage        (roll, strike, off-guard, damage_type, persistent_round) int64 43MB ...
    persistent_damage_DC     (damage_type) int64 24B 15 15 15
    persistent_damage_check  (roll, damage_type, strike, off-guard, persistent_round) int64 43MB ...
    apply_persistent_damage  (roll, damage_type, strike, off-guard, persistent_round) bool 5MB ...
    total_damage             (roll, strike, off-guard) int64 5MB 0 0 42 ... 0 0
Attributes:
    legend:       {-2: 'No roll', -1: 'Critical failure', 0: 'Failure', 1: 'S...
    damage_spec:  {'Critical success': '(2d6+3)x2 piercing plus 1d8 piercing ...

What’s the mean damage of the three strikes?

damage.total_damage.mean("roll").to_pandas()
off-guard False True
strike
0 16.14455 21.21379
1 8.50421 10.18417
2 4.22000 5.89245

Since labelling dimensions as (in)dependent on every check and damage call can get tedious, you can alternatively declare that, a dimension is always (in)dependent within your session:

pf2.set_config(
    check_independent_dims=["strike"],
    check_dependent_dims=["off-guard"],
    damage_independent_dims=["strike"],
    damage_dependent_dims=["off-guard"],
)
strike = pf2.check(14 + MAP, DC=22 + off_guard)
damage = pf2.damage(strike, flaming_rapier)

Conditional buffs/debuffs#

Not everything in the game does damage; frequently it’s worthwhile to spend an action to give to yourself or an ally a buff, or to debuff an enemy. A buff/debuff translates to a bonus/penalty to a check or to damage, or maybe to adding or removing a flat check (e.g. when it gives or removes the Concealed or Hidden conditions).

Crucially though, debuffs frequently involve a check that can negate or mitigate them. In pathfinder2e_stats, we can chain multiple actions, e.g. a Fear spell followed by a Strike. On each of our 100,000 simulations, the AC of the target will change depending on the target’s saving throw.

We’re going to simulate this as follows:

  1. Call check to roll a saving throw vs. Fear;

  2. Map its outcome with map_outcome() to the severity of the Frightned condition;

will_saving_throw = pf2.check(13, DC=21)
frightened = pf2.map_outcome(
    will_saving_throw.outcome,
    {
        pf2.DoS.critical_failure: 3,
        pf2.DoS.failure: 2,
        pf2.DoS.success: 1,
    },
)
frightened.display("frightened")
variable frightened
count 100000.000000
mean 1.248870
std 0.769389
min 0.000000
25% 1.000000
50% 1.000000
75% 2.000000
max 3.000000

What is the probability to get each level of the Frightened condition from Fear?

(
    frightened.value_counts("roll", new_dim="frightened", normalize=True)
    .to_pandas()
    .to_frame("%")
) * 100.0
%
frightened
0 15.098
1 50.070
2 29.679
3 5.153
  1. Subtract the Frightened condition from the enemy’s AC;

  2. Roll check again for the Strike: the DC parameter will have a roll dimension;

  3. Roll damage as usual.

strike = pf2.check(14, DC=22 - frightened)
damage = pf2.damage(strike, flaming_rapier)

Note that we didn’t need to clarify that roll is an independent dimension: roll is a special dimension that is always considered independent. But wait! We wonder, was it worth the effort to cast Fear to begin with (assuming we don’t care about anything but the extra damage on this Strike)? Let’s do a what-if analysis for it:

cast_fear = xarray.DataArray(
    [False, True], dims=["cast_fear"], coords={"cast_fear": [False, True]}
)
strike = pf2.check(14, DC=22 - frightened * cast_fear, dependent_dims=["cast_fear"])
damage = pf2.damage(strike, flaming_rapier, dependent_dims=["cast_fear"])
damage.total_damage.display()
variable total_damage
cast_fear False True
count 100000.000000 100000.000000
mean 16.039760 19.197040
std 16.974328 18.518977
min 0.000000 0.000000
25% 0.000000 0.000000
50% 15.000000 16.000000
75% 20.000000 22.000000
max 87.000000 87.000000

In other words: what was the expected % bonus to damage from casting Fear, before the saving throw was rolled?

mean_damage = damage.total_damage.mean("roll")
(mean_damage.sel(cast_fear=True) / mean_damage.sel(cast_fear=False)).item()
1.1968408504865409

The Fear spell, against this particular target and with this particular weapon (which, crucially, has the Deadly trait) gave us on average 20% extra damage on the Strike.

Flat checks, disrupting actions and conditional actions#

Some actions have a chance of failing. Among the most common causes, we have:

To model this, we need to introduce a special degree of success, DoS.no_roll. damage() always translates DoS.no_roll to zero damage. It works as follows:

  1. roll check() as normal;

  2. roll another flat check() for the failure chance;

  3. override the outcome of the first check with DoS.no_roll when the flat check failed;

  4. roll damage() normally.

strike = pf2.check(14, DC=22)
flat_check = pf2.check(
    DC=5, allow_critical_failure=False, allow_critical_success=False
)  # Concealed
strike["flat_check"] = flat_check.outcome
strike.outcome[strike.flat_check == pf2.DoS.failure] = pf2.DoS.no_roll
damage = pf2.damage(strike, flaming_rapier)

pf2.outcome_counts(strike).to_pandas().to_frame("%") * 100.0
%
outcome
Critical success 11.994
Success 40.102
Failure 24.002
Critical failure 3.961
No roll 19.941

Actions can also be disrupted; for example we may want to simulate the impact of receiving a Reactive Strike while casting a spell and risk losing the spell on a critical hit. It works in the same way as flat checks; you just update the check for the spell to DoS.no_roll as a function of the check for the reactive strike:

reactive_strike = pf2.check(14, DC=22)
saving_throw = pf2.check(reflex_bonus, DC=21, independent_dims=["target"])
saving_throw["disrupted"] = reactive_strike.outcome == pf2.DoS.critical_success
saving_throw.outcome[saving_throw.disrupted] = pf2.DoS.no_roll
fireball_damage = pf2.damage(saving_throw, fireball, dependent_dims=["target"])
saving_throw.disrupted.value_counts(
    "roll", new_dim="disrupted", normalize=True
).to_pandas().to_frame("%") * 100.0
%
disrupted
False 84.941
True 15.059

Test that the fireball never did any damage whenever it was disrupted:

fireball_damage.total_damage[fireball_damage.disrupted].max().item()
0

A player or the GM may also completely change course of action depending on the outcome of a roll. For example, one may be Restrained and decide that they are going to try to Escape; if successful they’ll cast a spell otherwise they’ll try again, unless it’s a critical failure:

# Try up to 3 times per round...
escape = pf2.check(8 + MAP, DC=22)
# ..but a critical failure failure stops you from retrying this round.
# We're retrying exclusively when the previous attempt was exactly a failure.
for i in (1, 2):
    escape.outcome.isel(strike=i)[
        escape.outcome.isel(strike=i - 1) != pf2.DoS.failure
    ] = pf2.DoS.no_roll
pf2.outcome_counts(escape).to_pandas() * 100.0
outcome Critical success Success Failure Critical failure No roll
strike
0 5.054 30.198 44.828 19.920 0.000
1 2.356 2.224 20.075 20.173 55.172
2 0.000 1.041 5.080 13.954 79.925

What’s the probability of escaping by the end of the round?

(
    (escape.outcome >= pf2.DoS.success)
    .any("strike")
    .value_counts(dim="roll", new_dim="escaped", normalize=True)
    .to_pandas()
    .to_frame("%")
) * 100.0
%
escaped
False 59.127
True 40.873

Now let’s cast the spell, but only if we escaped on the first round:

can_cast = escape.isel(strike=0).outcome >= pf2.DoS.success
reflex_save = pf2.check(10, DC=21)
reflex_save.outcome[~can_cast] = pf2.DoS.no_roll
pf2.outcome_counts(reflex_save).to_pandas().to_frame("%") * 100.0
%
outcome
Critical success 1.785
Success 15.764
Failure 15.905
Critical failure 1.798
No roll 64.748

Armory and tables#

For the sake of convenience, we don’t need to write by hand the damage spec of the +1 Striking Flaming Rapier from above every time. pf2.armory offers a wealth of weapons, runes, spells, and common class features:

(
    pf2.armory.swords.rapier(dice=2)
    + pf2.armory.runes.flaming()
    + pf2.armory.class_features.sneak_attack(level=5)
)
Critical success (2d6)x2 piercing plus 1d8 piercing plus (1d6)x2 fire plus (2d6)x2 precision plus 1d10 persistent fire
Success 2d6 piercing plus 1d6 fire plus 2d6 precision

We don’t need to calculate our character’s attack bonus either. pf2.tables.PC offers a wealth of precalculated progressions over 20 levels for most optimized character builds:

pf2.tables.PC
Available tables:
  • ability_bonus
  • attack_item_bonus
  • class_proficiency
  • level
  • polymorph_attack
  • rage
  • skill_item_bonus
  • skill_proficiency
  • spell_proficiency
  • untamed_druid_attack
  • weapon_dice
  • weapon_proficiency
  • weapon_specialization

Each table has a level dimension, plus variables and extra dimensions depending on the table:

pf2.tables.PC.weapon_proficiency.display()
variable alchemist animist barbarian bard champion cleric commander druid exemplar fighter guardian gunslinger inventor investigator kineticist magus monk oracle psychic ranger rogue sorcerer summoner swashbuckler thaumaturge witch wizard fighter_dedication
doctrine battle creed cloistered cleric warpriest
mastery True False True False
level
1 2 2 2 2 2 2 2 2 2 2 2 4 4 2 4 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
2 2 2 2 2 2 2 2 2 2 2 2 4 4 2 4 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
3 2 2 2 2 2 2 2 2 2 2 2 4 4 2 4 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
4 2 2 2 2 2 2 2 2 2 2 2 4 4 2 4 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
5 2 2 4 2 4 4 2 2 4 2 4 6 4 4 6 4 4 4 2 4 4 2 2 4 4 2 4 4 4 2 2 2
6 2 2 4 2 4 4 2 2 4 2 4 6 4 4 6 4 4 4 2 4 4 2 2 4 4 2 4 4 4 2 2 2
7 4 2 4 2 4 4 2 4 4 2 4 6 4 4 6 4 4 4 2 4 4 2 2 4 4 2 4 4 4 2 2 2
8 4 2 4 2 4 4 2 4 4 2 4 6 4 4 6 4 4 4 2 4 4 2 2 4 4 2 4 4 4 2 2 2
9 4 2 4 2 4 4 2 4 4 2 4 6 4 4 6 4 4 4 2 4 4 2 2 4 4 2 4 4 4 2 2 2
10 4 2 4 2 4 4 2 4 4 2 4 6 4 4 6 4 4 4 2 4 4 2 2 4 4 2 4 4 4 2 2 2
11 4 4 4 4 4 4 4 4 4 4 4 6 4 4 6 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 2
12 4 4 4 4 4 4 4 4 4 4 4 6 4 4 6 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
13 4 4 6 4 6 6 4 4 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4
14 4 4 6 4 6 6 4 4 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4
15 4 4 6 4 6 6 4 4 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4
16 4 4 6 4 6 6 4 4 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4
17 4 4 6 4 6 6 4 4 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4
18 4 4 6 4 6 6 4 4 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4
19 4 4 6 4 6 6 4 6 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4
20 4 4 6 4 6 6 4 6 6 4 6 8 6 6 8 6 6 6 4 6 6 4 4 6 6 4 6 6 6 4 4 4

We can build the attack bonus of the rogue from earlier by picking what we want from the PC tables:

rogue_atk_bonus = (
    # Start with DEX+4 at level 1 and always increase it
    pf2.tables.PC.ability_bonus.boosts.sel(initial=4, drop=True)
    # Get an Apex item at level 17 for +1 DEX
    + pf2.tables.PC.ability_bonus.apex
    # Upgrade weapons as soon as possible: +1 at level 2, +2 at level 10, etc.
    + pf2.tables.PC.attack_item_bonus.potency_rune
    # Trained (+2) at level 1, Expert (+4) at level 5, Master (+6) at level 13
    + pf2.tables.PC.weapon_proficiency.rogue
    # Add level to proficiency
    + pf2.tables.PC.level
)
rogue_atk_bonus.display(transpose=True)
level 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
variable
(unnamed) 7 9 10 11 14 15 16 17 18 21 22 23 26 27 28 30 32 33 34 36

Since adding up all of the above is tedious and error prone, pf2.tables.SIMPLE_PC is a simplified variant that makes a bunch of (opinable) choices, giving you an even more standardized progression:

rogue_atk_bonus = pf2.tables.SIMPLE_PC.weapon_attack_bonus.rogue
rogue_atk_bonus.display()
variable rogue
component level proficiency ability_boosts ability_apex item
level
1 1 2 4 0 0
2 2 2 4 0 1
3 3 2 4 0 1
4 4 2 4 0 1
5 5 4 4 0 1
6 6 4 4 0 1
7 7 4 4 0 1
8 8 4 4 0 1
9 9 4 4 0 1
10 10 4 5 0 2
11 11 4 5 0 2
12 12 4 5 0 2
13 13 6 5 0 2
14 14 6 5 0 2
15 15 6 5 0 2
16 16 6 5 0 3
17 17 6 5 1 3
18 18 6 5 1 3
19 19 6 5 1 3
20 20 6 6 1 3

So our level 5 rogue will have an attack bonus of

rogue_atk_bonus.sum("component").sel(level=5).item()
14

Note that the above is just a typical baseline, and does not take into consideration buffs, debuffs, suboptimal equipment, or uncommon character progression choices.

There are more Tables available:

  • pf2.tables.DC contains the Difficulty Classes tables from the GM Core;

  • pf2.tables.EARN_INCOME is the Earn Income table from the Player Core;

  • pf2.tables.NPC gives you the tables from the Building Creatures chapter of the GM Core;

  • pf2.tables.SIMPLE_NPC gives you a simplified version of NPC with just three targets to blast with your attack and spells (or to get blasted by):

    • a weak minion of your level - 2 with all stats rated Low;

    • a worthy foe of your level with all stats rated Moderate; and

    • a boss of your level + 2 with all stats rated High.

# One very easy, one average and one very hard enemy at level 5
pf2.tables.SIMPLE_NPC.sel(level=5).display(transpose=True)
challenge Weak Matched Boss
variable limited
abilities 1 4 6
saving_throws 6 12 18
perception 6 12 18
skills 7 12 17
AC 16 21 25
HP 31 75 148
strike_attack 8 13 18
strike_damage_dice 1d6+5 2d6+6 2d10+9
strike_damage_mean 8 13 20
spell_DC 0 19 25
spell_attack 0 11 17
area_damage_dice False 2d8 2d10 4d6
True 4d6 6d6 8d6
area_damage_mean False 9 12 15
True 14 21 28
resistances 3 6 10
recall_knowledge 18 20 25

Next steps#

Congratulations, you finished the basic tutorial!

From here, you can go look at the Notebooks. In the API Reference, you will find many functions, flags and options that were omitted here for the sake of brevity.