Skip to content

Rollin’ with rollers and rolls

Experimental

This functionality should be considered experimental. Be warned that future release may introduce incompatibilities or remove it altogether. Suggestions and contributions are welcome.

dyce provides additional primitives useful for generating and inspecting rolls of weighted random outcomes without requiring the overhead of enumeration.

1
>>> from dyce import R

R objects represent rollers. Rollers produce Roll objects. Roll objects are sequences of RollOutcome objects, which represent weighted random values.

Each object can be a node in a tree-like structure. Rollers, for example, can represent scalars, histograms, pools, operators, etc., and can be assembled into trees representing more complex calculations. Rolls can derive from other rolls, forming trees that are generally analogous to the roller trees that generated them. Similarly, roll outcomes can derive from other roll outcomes.

The simplest roller we can create represents a single value. Each roll it generates has that value as its sole outcome. Let’s see what that looks like (now with tasty entity relationship diagrams).

Rollers, rolls, and outcomes, oh my!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> from dyce.r import ValueRoller
>>> r_1 = ValueRoller(1)
>>> roll = r_1.roll()
>>> roll.total()
1
>>> tuple(roll.outcomes())
(1,)
>>> roll
Roll(
  r=ValueRoller(value=1, annotation=''),
  roll_outcomes=(
    RollOutcome(
      value=1,
      sources=(),
    ),
  ),
  sources=(),
)

Hopefully, that’s relatively straightforward. Let’s look at some more substantial examples.

Emulating a hundred-sided die using two ten-sided dice

D00 & D10

In many games it is common to emulate a hundred-sided die using a “ones” ten-sided die (faces numbered \([{{0}, {1}, \ldots , {9}}]\)) and a “tens” ten-sided die (faces numbered \([{{00}, {10}, \ldots , {90}}]\)). Let’s try to model that as a roller and use it to generate a roll.

We start by creating two histograms1 representing our two ten-sided dice (d00 for our “tens” die and d10 for our “ones“ die).

1
2
3
>>> from dyce import H
>>> d10 = H(10) - 1
>>> d00 = 10 * d10

Next, we create a roller using the R.from_values class method.

1
2
3
4
5
6
7
8
>>> r_d100 = R.from_values(d00, d10) ; r_d100
PoolRoller(
  sources=(
    ValueRoller(value=H({0: 1, 10: 1, 20: 1, 30: 1, 40: 1, 50: 1, 60: 1, 70: 1, 80: 1, 90: 1}), annotation=''),
    ValueRoller(value=H({0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1}), annotation=''),
  ),
  annotation='',
)

Well, wouldya look at that? That durned class method created a whole roller tree, which is actually three rollers.

  1. One ValueRoller for the d00 histogram;
  2. Another for the d10 histogram; and
  3. A PoolRoller for aggregating them both.

Tip

We could have also composed an identical tree using roller implementations from dyce.r instead of the R.from_values convenience method.

1
2
3
>>> from dyce.r import PoolRoller, ValueRoller
>>> r_d100 == PoolRoller(sources=(ValueRoller(d00), ValueRoller(d10)))
True

Let’s use our new roller to create a roll and retrieve its total.

1
2
3
>>> roll = r_d100.roll()
>>> roll.total()
69

No surprises there. Let’s dig a little deeper and ask for the roll’s outcome values.

1
2
>>> tuple(roll.outcomes())
(60, 9)

As we mentioned before, the top level of our roller tree is a PoolRoller, which aggregates (or “pools”) rolls from its sources. For our roll, the aggregated outcomes are 60 are 9.

Tip

You might be wondering to yourself, “Self, one wonders, can one have a pool of pools?” Such questions command the response, “Why the heck not? Try it!”

1
2
3
4
5
6
>>> two_r_d100s = PoolRoller(sources=(r_d100, r_d100))
>>> roll_two = two_r_d100s.roll()
>>> roll_two.total()
63
>>> tuple(roll_two.outcomes())
(40, 2, 20, 1)

So the answer is a resounding, “Of course. What devious entity would prohibit such a thing? Please identify that creature so we may flog it until it achieves enlightenment,” “Yes.”

What does our pooled roll look like?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
>>> roll
Roll(
  r=PoolRoller(
    sources=(
      ValueRoller(value=H({0: 1, 10: 1, 20: 1, 30: 1, 40: 1, 50: 1, 60: 1, 70: 1, 80: 1, 90: 1}), annotation=''),
      ValueRoller(value=H({0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1}), annotation=''),
    ),
    annotation='',
  ),
  roll_outcomes=(
    RollOutcome(
      value=60,
      sources=(
        RollOutcome(
          value=60,
          sources=(),
        ),
      ),
    ),
    RollOutcome(
      value=9,
      sources=(
        RollOutcome(
          value=9,
          sources=(),
        ),
      ),
    ),
  ),
  sources=(
    Roll(
      r=ValueRoller(value=H({0: 1, 10: 1, 20: 1, 30: 1, 40: 1, 50: 1, 60: 1, 70: 1, 80: 1, 90: 1}), annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=60,
          sources=(),
        ),
      ),
      sources=(),
    ),
    Roll(
      r=ValueRoller(value=H({0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 9: 1}), annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=9,
          sources=(),
        ),
      ),
      sources=(),
    ),
  ),
)

Roll ERD from emulating a d100

Let’s break that down so it doesn’t feel like trying to drink from a fire hose.

Calling the R.roll method on our PoolRoller resulted in a Roll object. Actually, it resulted in a roll tree (analogous to our roller tree). Each Roll object in that tree has:

  • A reference to the R object in the roller tree that generated it, retrieved via its r property;
  • Zero or more RollOutcome objects, retrieved by accessing the roll as a sequence (i.e., via __getitem__, __len__); and
  • Zero or more source rolls, retrieved via its sources property.

The RollOutcome objects also form trees (in our case, simple ones). Each one has:

  • A single value, retrieved via its value property;
  • Zero or more source outcomes from which the value was derived, retrieved via its sources property; and
  • A weak reference back to the roll that generated it, retrieved via its roll property (omitted from the diagram for the sake of readability).

Composing rollers with arithmetic

Rollers support arithmetic operators.

1
2
3
4
5
6
7
8
>>> d12 = H(12)
>>> r_d12_add_4 = ValueRoller(d12) + 4 ; r_d12_add_4
BinaryOperationRoller(
  op=<built-in function add>,
  left_source=ValueRoller(value=H(12), annotation=''),
  right_source=ValueRoller(value=4, annotation=''),
  annotation='',
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
>>> r_d12_add_4.roll()
Roll(
  r=BinaryOperationRoller(...),
  roll_outcomes=(
    RollOutcome(
      value=11,
      sources=(
        RollOutcome(
          value=7,
          sources=(),
        ),
        RollOutcome(
          value=4,
          sources=(),
        ),
      ),
    ),
  ),
  sources=(
    Roll(
      r=ValueRoller(value=H(12), annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=7,
          sources=(),
        ),
      ),
      sources=(),
    ),
    Roll(
      r=ValueRoller(value=4, annotation=''),
      roll_outcomes=(
        RollOutcome(
          value=4,
          sources=(),
        ),
      ),
      sources=(),
    ),
  ),
)

Roll ERD for arithmetic composition

Dropping dice from prior rolls – keeping the best three of 3d6 and 1d8

The trifecta of roller trees, roll trees, and outcome trees might appear complicated or redundant. Everything serves a purpose.2

Consider excluding (or “dropping”) dice from a roll. How would we account for that? Let’s see how to generate rolls that keep the best three outcomes from rolling three six-sided dice and one eight-sided die.

We start by using the R.from_value class method to create ValueRollers for histograms representing our six- and eight-sided dice.

1
2
3
4
5
6
>>> d6 = H(6)
>>> d8 = H(8)
>>> r_d6 = R.from_value(d6) ; r_d6
ValueRoller(value=H(6), annotation='')
>>> r_d8 = R.from_value(d8) ; r_d8
ValueRoller(value=H(8), annotation='')

For homogeneous pools, we can use the matrix multiplication operator.

1
2
3
4
5
6
>>> r_3d6 = 3@r_d6 ; r_3d6
RepeatRoller(
  n=3,
  source=ValueRoller(value=H(6), annotation=''),
  annotation='',
)

Finally, we’ll create a SelectionRoller by calling the R.select_from_rs method on our other rollers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> r_best_3_of_3d6_d8 = R.select_from_rs((slice(1, None),), r_3d6, r_d8) ; r_best_3_of_3d6_d8
SelectionRoller(
  which=(slice(1, None, None),),
  sources=(
    RepeatRoller(
      n=3,
      source=ValueRoller(value=H(6), annotation=''),
      annotation='',
    ),
    ValueRoller(value=H(8), annotation=''),
  ),
  annotation='',
)

Oh boy! Aren’t you super excited to try this thing out?

1
2
3
>>> roll = r_best_3_of_3d6_d8.roll()
>>> tuple(roll.outcomes())
(1, 5, 6)

There are indeed three values, despite starting with four dice. Given that the lowest value we see is a 1, we might assume that the eliminated value is also a 1. But, we all know what happens when one assumes.

xkcd: When You Assume

Recall that in roll trees, a roll may have references to other rolls (its “sources”) from which it derives. We should be able to get information about the dropped die by traversing that tree. Let’s see if we can validate our assumption by looking at the outcomes from our roll’s direct source.

1
2
3
>>> from itertools import chain
>>> tuple(chain.from_iterable(source.outcomes() for source in roll.sources))
(6, 1, 1, 5)

Yup! We were right! There’s the other 1, plain as day. Our work here is do—

What? You want to know which die we eliminated? We can see that, too!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
>>> roll
Roll(
  r=SelectionRoller(
    which=(slice(1, None, None),),
    sources=(
      RepeatRoller(
        n=3,
        source=ValueRoller(value=H(6), annotation=''),
        annotation='',
      ),
      ValueRoller(value=H(8), annotation=''),
    ),
    annotation='',
  ),
  roll_outcomes=(
    ...

*snip* ✂️

147
148
  ),
)

Oof. ☝️ That was … a lot. Let’s visualize!

Roll ERD for dropping outcomes

Holy entangled relationship diagrams, Batman! One thing you may notice about our top-level roll is that it has four outcomes. One of those kids is not like the others. Specifically, it has a value of None. That’s our dropped outcome!

1
2
3
4
5
6
>>> len(roll) == 4
True
>>> roll[-1].value is None
True
>>> tuple(roll_outcome.value for roll_outcome in roll)
(1, 5, 6, None)

Info

A roll outcome with a value of None is akin to a “tombstone”. It conveys one whose sources were present in immediately prior rolls but excluded from the current roll. This implies that such a roll outcome must have at least one source. That constraint is enforced.

1
2
3
4
5
>>> from dyce.r import RollOutcome
>>> RollOutcome(value=None)
Traceback (most recent call last):
  ...
ValueError: value can only be None if sources is non-empty

However, because such a roll signals its absence from the current roll, its value is not included by the Roll.outcomes method.

We can programmatically verify that the excluded outcome originated from one of the six-sided dice.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> excluded = roll[-1]
>>> excluded.value is None
True
>>> excluded.sources[0].value
1
>>> excluded.sources[0].roll.r is r_3d6
True
>>> excluded.sources[0].sources[0].value
1
>>> excluded.sources[0].sources[0].roll.r is r_d6
True
>>> assert isinstance(excluded.sources[0].sources[0].roll.r, ValueRoller)
>>> excluded.sources[0].sources[0].roll.r.value
H(6)

We can also verify that the 5 came from the eight-sided die.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> five = roll[1]
>>> five.value
5
>>> five.sources[0].roll.r is r_d8
True
>>> five.sources[0].value
5
>>> assert isinstance(five.sources[0].roll.r, ValueRoller)
>>> five.sources[0].roll.r.value
H(8)

Alternatively, could have also used our old friend the P object to eliminate the RepeatRoller for a similar, but slightly simpler result.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> from dyce import P
>>> R.select_from_values((slice(1, None),), 3@P(d6), d8).roll()
Roll(
  r=SelectionRoller(
    which=(slice(1, None, None),),
    sources=(
      ValueRoller(value=P(6, 6, 6), annotation=''),
      ValueRoller(value=H(8), annotation=''),
    ),
    annotation='',
  ),
  roll_outcomes=(
    ...

*snip* ✂️

79
80
  ),
)

Alternate Roll ERD for dropping outcomes

In this case, our results are still mostly traceable, since our pool is homogeneous. However, results from P.roll are sorted, meaning they lose association with their source histograms. This risks ambiguity. Consider:

1
2
>>> P(6, 8).roll()  # doctest: +SKIP
(3, 4)

Is the 3 from the d6 or d8? 🤔💭 No one knows.

1
2
3
4
>>> R.from_value(P(6, 8))  # doctest: +SKIP
: UserWarning: using a heterogeneous pool (P(6, 8)) is not recommended where traceability is important
  ...
ValueRoller(value=P(6, 8), annotation='')

Lean(er) rolls — what if we don’t care about all this transparency?

What if all we wanted to do was generate some rolls, but we’re not interested in their detailed origin stories or that ridiculous entanglement of cross-references all the way down? In almost all cases, P and H objects will get you were you need to go. Start there. (See also the section on performance below.)

If you really want to use rollers to generate rolls without histories for some reason, Roll.__init__ will surgically remove any source references from its arguments if its roller argument r is annotated with None.

This has the effect that rollers annotated with None will appear to generate “flat” rolls.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
>>> r_forgetful_best_3_of_3d6_d8 = r_best_3_of_3d6_d8.annotate(None)
>>> roll = r_forgetful_best_3_of_3d6_d8.roll() ; roll
Roll(
  r=SelectionRoller(
    which=(slice(1, None, None),),
    sources=(
      RepeatRoller(
        n=3,
        source=ValueRoller(value=H(6), annotation=''),
        annotation='',
      ),
      ValueRoller(value=H(8), annotation=''),
    ),
    annotation=None,
  ),
  roll_outcomes=(
    RollOutcome(
      value=2,
      sources=(),
    ),
    RollOutcome(
      value=3,
      sources=(),
    ),
    RollOutcome(
      value=5,
      sources=(),
    ),
  ),
  sources=(),
)

Alternate Roll ERD for dropping outcomes

This isn’t magic. It is merely a way to cleave away any accumulated history at the point a Roll object is constructed by an appropriately annotated roller. To prevent all history accumulation for a roller tree, every roller in that tree must be annotated with None.

Warning

Technically, this violates the immutability of roll outcomes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
>>> from dyce.r import Roll
>>> origin = RollOutcome(value=1)
>>> descendant = RollOutcome(value=2, sources=(origin,)) ; descendant
RollOutcome(
  value=2,
  sources=(
    RollOutcome(
      value=1,
      sources=(),
    ),
  ),
)
>>> roll = Roll(PoolRoller(annotation=None), roll_outcomes=(descendant,))
>>> descendant  # sources are wiped out
RollOutcome(
  value=2,
  sources=(),
)

dyce does not generally contemplate creation of rolls or roll outcomes outside the womb of R.roll implementations. Roll and RollOutcome objects generally mate for life, being created exclusively for (and in close proximity to) one another. A roll manipulating a roll outcome’s internal state post construction may seem unseemly, but that intimacy is a fundamental part of their primordial ritual.

More practically, it frees each roller from having to do its own cleaving.

That being said, you’re an adult. Do what you want. Just know that if you’re going to construct your own roll outcomes and pimp them out to different rolls all over town, they might come back with some parts missing.

(See also the RollOutcome.roll property.)

Performance

How much overhead do all these data structures contribute? It obviously depends on the complexity of the structure. Consider a simple example d20 + d12 + 4. Let’s do that 5,000 times, sort the results, and take every other one starting with the highest.

We might use a pool, if we didn’t care about traceability.

1
2
3
4
5
6
7
8
In [1]: from dyce import H, P, R

In [2]: d20, d12 = H(20), H(12)

In [3]: p = 5000@P(d20 + d12 + 4)

In [4]: %timeit p.roll()[::-2]
26.3 ms ± 520 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

That’s not bad. Is a significant improvement possible?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
In [5]: from itertools import chain

In [6]: from random import choices

In [7]: a20, f20 = list(range(20, 0, -1)), [1] * 20

In [8]: a12, f12 = list(range(12, 0, -1)), [1] * 12

In [9]: %timeit sorted(sum((choices(a20, f20)[0], choices(a12, f12)[0], 4)) for _ in range(5000))[::-2]
24.1 ms ± 203 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

That’s pretty close, even when including the repeated sum. What about those frequencies? Do we need them? As is the case with most traditional dice, ours are all 1s in this example. Can we eliminate that redundancy?

1
2
In [10]: %timeit sorted(sum((choice(a20), choice(a12)), 4) for _ in range(5000))[::-2]
6.34 ms ± 32.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

That’s …

sssmokin’!

Pooling histograms clearly adds some overhead over a targeted, native solution. What about rollers?

1
2
3
4
In [11]: r = (5000@(R.select_from_values((slice(None, None, -2),), d20, d12, 4)))

In [12]: %timeit r.roll()
257 ms ± 3.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In this particular case, our roller takes about ten times longer than our histogram pool and about forty times longer than our bespoke one-liner. It is unsurprising that a roller is slower, since the math is deferred until R.roll time. But there are also likely inefficiencies from generalization and tree creation. In other cases, rollers may be more competitive with their histogram pool analogies.

1
2
3
4
5
6
7
8
9
In [13]: p = 5000@P(((d20 + 4) * 9) ** d12)

In [14]: %timeit p.roll()[-1]
68.2 ms ± 188 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [15]: r = (5000@(((R.from_value(d20) + 4) * 9) ** R.from_value(d12))).select(-1)

In [16]: %timeit r.roll()
366 ms ± 1.45 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

All that being said, for periodic rolls simulating handfuls (not thousands) of dice, such performance disparities probably won’t matter that much. Just use the primitives whose semantics work best for you. If ever performance becomes an issue, let us know, and we can collaborate on how to improve it.

Further exploration

Consider reviewing the roller API.


  1. If you’re not already familiar with histograms, consider skimming the counting tutorial

  2. We may still be discovering what those are. We have the utmost faith such purposes exist, even if they have yet to reveal themselves. If you discover one, consider contributing an example.