Welcome to random-table.el
, an Emacs package for defining random tables and rolling on them.
What is a random table? Maybe it’s a list of given names. And you pick a random one from that list. Maybe it’s the creatures appearing in an RPG dungeon.
I’ve been blogging about this in my Emacs random-table.el Package series.
You may also find my random-tables-data.el useful for what I’ve been setting up.
- Composing Tables
- The results of one table could be to roll on another table or tables; and so on.
- Cached Results
- Some dice rolls inform later dice rolls. There’s a mechanism to do that; though it likely needs improvement.
- Private Tables
- Useful when you don’t want to see the named table as an option in the
random-table/roll
; but you want to reference this table elsewhere. - Prompt for Dice Rolls
- Random tables can encode complex procedures, and sometimes you might want to follow the procedures but provide you’re own dice rolls.
- Mad Libs Like Interpolation
- Instead of picking a table to roll on for
random-table/roll
you can type an expression (e.g. “There are {2d6} orcs”) and it will interpret that results (e.g. rolling a 6 we’d have “There are 6 orcs”). - Inner Tables
- Within a table’s result you can have an inner table (e.g. “you meet a [brigand/priest/child/zealot/haunting nightmare]”) and the parser will pick a random element.
- Custom “Reporter”
- By default, the results are written to the
*Messages*
buffer and added to the kill ring. But you can configure to have a different reporter. (seerandom-table/reporter
) - Registering and Caching Prompt Choices
- During the life cycle of the
random-table/roll
you have access to the hash in therandom-table/roll/cache
variable. Useful for remembering things you’ve already prompted the user for; such as their Charisma modifier. - Extensible Parsing Logic
- The
random-table/text-replacer-functions
is a custom variable that allows for adding and removing functions to provide more extensible logic.
And more.
As of <2023-08-21 Mon> this repository is not yet part of any of the package archive (e.g. https://melpa.org). But you can install it via use-package
.
(use-package random-table
:straight (:host github :repo "jeremyf/random-table.el"))
I have tested this using Emacs v29.1; it might work with earlier versions.
I have tested this package with Emacs 29.1. It might work with earlier versions. Otherwise, there are no external dependencies.
In prior iterations, I used the s
package (See https://melpa.org/#/s) but I have since removed that dependency.
I type M-x random-table/roll
and am prompted to give an Expression. I can select from a pre-populated list of registered tables (via random-table/register
). Or I can enter an expression, such as “2d6+1” and random-table/roll
will then evaluate the expression.
Let’s look at registering a table:
(random-table/register :name "Coin Toss"
:data '("Heads" "Tails"))
When I invoke random-table/roll
, I can select “Coin Toss”, it will add to the kill ring and write a message based on the roll; either “Heads” or “Tails”.
I could also call random-table/roll
and provide the following: “You toss a coin and it lands on {Coin Toss}”
And will get back: “You toss a coin and it lands on Heads” (assuming you rolled a Heads).
In fact we could register a new table:
(random-table/register :name "Things We Throw"
:data '("Rocks"
"Tantrum"
"Coin and it comes up {Coin Toss}."))
When we “roll” on that table, when we get the “Coin and…” result, we’ll evaluate rolling on the Coin Toss table. The end result is “Coin and it comes up Heads.”
In the above case the “Coin and it comes up {Coin Toss}.” replacement relies on the random-table/text-replacer-functions
; in particular the random-table/text-replacer-function/named-table
; which looks at the text between {
and }
and then uses the value between to lookup a registered table.
Customize the random-table/text-replacer-functions
to have different strategies for replacing text. For example, I don’t automatically replace dice expression (e.g. “There are 2d4 ogres” does not roll the 2d4 unless it is between {
and }
.)
However you could configure the replacer functions to allows expand dice expressions.
Instead of relying on a custom table for a coin toss, we could call M-x random-table/roll
and provide “[heads/tails]”. This syntax leverages the random-table/text-replacer-function/inner-table
logic; that is interpret an inner table. We take the text between [
and ]
and pick one of the elements; elements are separated by a slash (e.g. /
) character.
We can also create ranges, but will need to consider the roller:
(random-table/register :name "Reaction Roll"
:roller "2d6"
:data '(((2) . "Hostile")
((3 . 5) . "Unfriendly")
((6 . 8) . "Unsure")
((9 . 11) . "Amicable")
((12) . "Friendly")))
Alternatively we can use a function:
(random-table/register :name "Reaction Roll"
:roller (lambda (&rest args) (+ 2 (random 6) (random 6)))
:data '(((2) . "Hostile")
((3 . 5) . "Unfriendly")
((6 . 8) . "Unsure")
((9 . 11) . "Amicable")
((12) . "Friendly")))
The given :roller
is effectively two six-sided dice. And we use the rolled values to then find the correct entry in :data
. For example, when we roll a 4 we’d return “Unfriendly”.
The roller can also be a named function; something you can re-use. This is also the place where you could prompt for a modifier or a choice.
Let’s look at a more complicated example:
(defun jf/2d6-plus-prompt-for-bonus (&rest args)
(let ((modifier (read-number "Modifier: " 0)))
(list (+ 2 modifier (random 6) (random 6)))))
(random-table/register :name "Reaction Roll with Prompt"
:roller #'jf/2d6-plus-prompt-for-bonus
:data '(((-1000 . 2) . "Hostile")
((3 . 5) . "Unfriendly")
((6 . 8) . "Unsure")
((9 . 11) . "Amicable")
((12 . 2000) . "Friendly")))
In the above case, when we roll the “Reaction Roll with Prompt”, Emacs will prompt for a Modifier. We’ll then use the given modifier to adjust the dice roll.
We could also use a registered prompt (see random-table/prompt
docstring) and our roller could then be a sequence:
(random-table/prompt "Charisma Bonus" :type #'read-number)
(random-table/register :name "Reaction Roll with Prompt"
:roller '(+ "2d6" "Charisma Bonus")
:data '(((-1000 . 2) . "Hostile")
((3 . 5) . "Unfriendly")
((6 . 8) . "Unsure")
((9 . 11) . "Amicable")
((12 . 2000) . "Friendly")))
The above will add the results of rolling “2d6” to the prompt for the character’s “Charisma Bonus.”
As of <2023-09-18 Mon> , I am considering how I might represent/parse: =’(+ “2d6” (read-number “Charisma Bonus: “))=; I’m uncertain about that syntax compared to what I see as the more legible =’(+ “2d6” “Charisma Bonus”)=; albeit with the need to create a prompt.
As you register tables, via random-table/register
, you add them to the table registry. The list of tables shown in the M-x random-table/roll
can become quite lengthy. To register a table, without adding it to the selection list, add :private t
as one of the key word arguments.
Below is the “Name” table. When we roll on the “Name” table we’ll pick a random one. Then roll on a “sub-table”. So as to not clutter the list, we mark those “sub-tables” as :private t
.
(random-table/register :name "Name"
:data '("{Name > Masculine}" "{Name > Feminine}" "{Name > Non-Binary}"))
(random-table/register :name "Name > Masculine"
:private t
:data '("George" "Michael"))
(random-table/register :name "Name > Feminine"
:private t
:data '("Mary" "Margaret"))
(random-table/register :name "Name > Non-Binary"
:private t
:data '("Quin" "Ash"))
Given the composition of tables, we may also want to store the results of the roll for future reference. Why might we do this? Some tables may say “Roll 3 dice. Then on table one use the highest value. And on table two use the lowest value. And on table three, if there are doubles, use the number that is the “double”.
(random-table/register :name "High Low"
:roller (lambda (&rest args) (list (+ 1 (random 6)) (+ 1 (random 6))))
;; We include this so that we only return the first data element. The
;; dice rolls are for the High Value and Low Value
:fetcher (lambda (data roll) (car data))
:data '("\n- High :: {High Value}\n- Low :: {Low Value}")
:store t)
(random-table/register :name "High Value"
:reuse "High Low"
:private t
:filter #'max
:data '("One" "Two" "Three" "Four" "Five" "Six"))
(random-table/register :name "Low Value"
:reuse "High Low"
:private t
:filter #'min
:data '("One" "Two" "Three" "Four" "Five" "Six"))
As of 2023-08-16 I store the roll in a somewhat naive manner; for a table with :store t
, when we “roll on that table” we add to a hash the table name and the results of the roll (e.g. the specific dice as a list). Then until we’ve fully evaluated the roll for that table, we can reference the dice results for that table.
On 2023-09-20, I added random-table/storage/results/get-data-value
; this function can retrieve the resolved value of the stored roll. Where random-table/storage/results/get
retrieves the dice results (e.g. 1
from a “1d6” roll), the random-table/storage/results/get-data-value
interprets the 1
on from the stored table’s data
struct.
One thing I introduced in the above was the :fetcher
and :filter
elements. The :filter
takes the dice pool (as a list) and returns an integer. The :fetcher
takes the integer and looks things up in the provided :data
.
The general flow is:
:roll
the dice:filter
the roll:fetch
the filtered result
That flow is defined in random-table/evaluate/table
.
I set about encoding the Death and Dismemberment rules for my Random Table package.
This required a few changes:
- I needed the concept of a
current_roll
. The Death and Dismemberment table. - I wanted dice to be able to return strings and then use those strings as the lookup on the table’s
:data
.
I did not, at present, worry about the cumulative effects of data. However, I’m seeing how I might do that.
Let’s dig in.
There are five tables to consider for Death and Dismemberment:
- Physical
- Acid/Fire
- Eldritch
- Lightning
- Non-Lethal
Here’s how I set about encoding that was as follows:
(random-table/register :name "Death and Dismemberment"
:roller #'random-table/roller/prompt-from-table-data
:data '(("Physical" . "{Death and Dismemberment > Physical}")
("Acid/Fire" . "{Death and Dismemberment > Acid/Fire}")
("Eldritch" . "{Death and Dismemberment > Eldritch}")
("Lightning" . "{Death and Dismemberment > Lightning}")
("Non-Lethal" . "{Death and Dismemberment > Non-Lethal}")))
The :roller
is a function as follows:
(defun random-table/roller/prompt-from-table-data (table)
(completing-read
(format "%s via:" (random-table-name table))
(random-table-data table) nil t))
In the case of passing the Death and Dismemberment
table, you get the following prompt: “Death and Dismemberment via”. And the list of options are: Physical, Acid/Fire, Eldritch, Lightning, and Non-Lethal.
Once I pick the option, I then evaluate the defined sub-table. Let’s look at Death and Dismemberment > Physical
.
(random-table/register :name "Death and Dismemberment > Physical"
:roller (lambda (table) (+ 1 (random 6)))
:private t
:data '(((1) . "Death and Dismemberment > Physical > Arm")
((2) . "Death and Dismemberment > Physical > Leg")
((3 . 4) . "Death and Dismemberment > Physical > Torso")
((5 . 6) . "Death and Dismemberment > Physical > Head")))
This is a rather straight-forward table. Let’s say the :roller
returns a 5. We will then evaluate the Death and Dismemberment > Physical > Head
table; let’s look at that. The resulting table is rather lengthy.
(random-table/register :name "Death and Dismemberment > Physical > Head"
:roller #'random-table/roller/death-and-dismemberment/damage
:private t
:data '(((1 . 10) . "Head Injury; Rolled {current_roll}\n- +1 Injury\n- Concussed for +{current_roll} day(s).")
((11 . 15) . "Head Injury; Rolled {current_roll}\n- +1 Injury\n- Concussed for +{current_roll} day(s).\n- One Fatal Wound.\n- {Save vs. Skullcracked}")
((16 . 1000) . "Head Injury; Rolled {current_roll}\n- +1 Injury\n- Concussed for +{current_roll} day(s).\n- {current_roll} - 14 Fatal Wounds.\n- {Save vs. Skullcracked}")))
The :roller
(e.g. random-table/roller/death-and-dismemberment/damage
) is as follows:
(defun random-table/roller/death-and-dismemberment/damage (&rest table)
(+ 1
(random 12)
(read-number "Number of Existing Injuries: " 0)
(read-number "Lethal Damage: " 0)))
We roll a d12, add the number of existing injuries, and accumulated lethal damage. Then look up the result in the :data
of Death and Dismemberment > Physical > Head
. Let’s say the result is a 12. We’ll need to roll on the the Save vs. Skullcracked
table, which I’ve included below:
(random-table/register :name "Save vs. Skullcracked"
:roller #'random-table/roller/saving-throw
:private t
:data '(("Save" . "Saved against cracked skull…gain a new scar.")
("Fail" . "Failed to save against cracked skull. {Save vs. Skullcracked > Failure}")))
The :roller
(e.g. random-table/roller/saving-throw
) will prompt for the saving throw score and any modifier to the roll. Then it will return “Fail” or “Save” depending on the results. See the function.
(defun random-table/roller/saving-throw (table)
(let ((score (read-number (format "%s\n> Enter Saving Throw Score: " (random-table-name table)) 15))
(modifier (read-number (format "%s\n> Modifier: " (random-table-name table)) 0))
(roll (+ 1 (random 20))))
(cond
((= roll 1) "Fail")
((= roll 20) "Save")
((>= (+ roll modifier) score) "Save")
(t "Fail"))))
Let’s say that we “Fail” the saving throw. We now lookup on the Save vs. Skullcracked > Failure
table:
(random-table/register :name "Save vs. Skullcracked > Failure"
:private t
:data '("Permanently lose 1 Intelligence."
"Permanently lose 1 Wisdom."
"Permanently lose 1 Charisma."
"Lose your left eye. -1 to Ranged Attack."
"Lose your right eye. -1 to Ranged Attack."
"Go into a coma. You can recover from a coma by making a Con check after 1d6 days, and again after 1d6 weeks if you fail the first check. If you fail both, it is permanent."))
Let’s say we get “Permanently lose 1 Intelligence” for the failed save. Now, working our way back, let’s see what that all evaluates to:
Head Injury; Rolled 12 - +1 Injury - Concussed for +12 day(s). - One Fatal Wound. - Failed to save against cracked skull. Permanently lose 1 Intelligence
The modified d12 roll resulted in a 12; hence the +12 day(s).
Let’s create a quick table:
(random-table/register
:name "Random Attribute"
:data '("Strength"
"Constitution"
"Dexterity"
"Intelligence"
"Wisdom"
"Charisma"))
Given that I passed the universal prefix arg (e.g. C-u
) when I roll on the Random Attribute
table then I will get the prompt “Roll 1d6 for:” and the value I enter will be used for looking up the correct :data
element.
In this way, you can roll the dice and use this package to encode the rules lookup.
Any table that has one element in :data
will not prompt for the roll. Also, you can specify :exclude-from-prompt t
when registering a table; then any “rolls” on that specific table will not prompt to give the dice value.
Ultimately, the goal is to ask for dice rolls when they might be something the player wants to roll.
In my quest for more random tables and functionality, I worked through Errant’s Hiring Retainers section. Using the PC’s presence, you look-up the morale base. Then roll 2d6, modified by the offer’s generosity, to then determine the modifier to the morale base.
To perform mathematical operations, I continue to leverage the s-format
functionality. That is s-format
will evaluate and replace the text of the following format: {text}
.
Below is the definition of a random Henchman for Errant.
(random-table/register
:name "Henchman (Errant)"
:data '("\n- Archetype :: {Henchman > Archetype}\n- Morale :: {(Henchman > Morale Base) + (Henchman > Morale Variable)}"))
The {Henchman > Archetype (Errant)}
will look on the following table:
(random-table/register
:name "Henchman > Archetype"
:private t
:roller #'random-table/roller/1d10
:data '(((1 . 5) . "Warrior")
((6 . 8) . "Professional")
((9 . 10) . "Magic User")))
The {[Henchman > Morale Base] + [Henchman > Morale Variable]}
does the following:
- Roll on
Henchman > Morale Base
- Roll on
Henchman > Morale Variable
- Add those two results together.
(random-table/register
:name "Henchman > Morale Base"
:private t
:roller (lambda (table) (read-number "Hiring PC's Presence Score: "))
:data '(((3 . 4) . 5)
((5 . 8) . 6)
((9 . 13) . 7)
((14 . 16) . 8)
((17 . 18) . 9)
((19 . 20) . 10)))
(random-table/register
:name "Henchman > Morale Variable"
:private t
:roller (lambda (table)
(let* ((options '(("Nothing" . 0) ("+25%" . 1) ("+50%" . 2) ("+75% or more" . 3)))
(key (completing-read "Additional Generosity of Offer: " options))
(modifier (alist-get key options nil nil #'string=)))
(+ modifier (random-table/roller/2d6 table))))
:data '(((2) . -2)
((3 . 5) . -1)
((6 . 8) . 0)
((9 . 11) . 1)
((12 . 15) . 2)))
Similar to random-table/roller
, you can register a prompt via random-table/prompt
. There are common prompts (e.g. “Charisma Modifier”). In registering a prompt, during an invocation of random-table/roll
each prompt will only be requested once. That is to say, the package will cache the prompt’s response and re-use that through out the roll.
This functionality leverages the per random-table/roll
cache (as stored in the random-table/roll/cache
variable).
(random-table/prompt "Charisma Modifier"
:type #'read-number
:default 0)
(random-table/register :name "Reaction Roll"
:roller (lambda (table)
(+ (random-table/prompt "Charisma Modifier")
(random-table/roller/2d6))))
Why include the caching? In reviewing Kevin Crawford’s Scarlet Heroes there’s a table for reaction rolls that asks for a few modifiers, the rolls on one table, and one result is to roll on another table using those same modifiers.
Instead of expressing the :roller
as a lambda
, you can express it as a list. The following is equivalent to the prior example:
(random-table/prompt "Charisma Modifier"
:type #'read-number
:default 0)
(random-table/register :name "Reaction Roll"
:roller '(+ "2d6" "Charisma Modifier"))
As of v0.7.0
you can include a registered table in a roller.
Consider the following table derived from 1520 HRE: 2d6 Adventure in the Holy Roman Empire.
(random-table/register :name "SOC"
:roller "2d6"
:reuse "SOC"
:store t
:data '(((2 . 7) . "{CURRENT_ROLL}")
((8 . 12) . "{CURRENT_ROLL} (EDU + 1)")))
In the above, a character’s Social (SOC) attribute affects their EDU attribute. Rolling an 8 or higher on SOC adds one to their Education (EDU).
(random-table/register :name "EDU modifier from SOC"
:reuse "SOC"
:private t
:exclude-from-prompt t
:filter (lambda (&rest dice) (if (>= (car dice) 8) 0 1))
:data '(0 1))
(random-table/register :name "EDU"
:roller '(+ "2d6" "EDU modifier from SOC")
:store t
:data '(2 3 4 5 6 7 8 9 10 11 12 13))
The “EDU modifier from SOC” table encodes the +1 EDU from SOC. And the :roller
for EDU references the filtered value in the “EDU modifier for SOC”.
Note: As of the v0.7.0
I still need to write the possible range for dice rolls. Not ideal and something I’ll fix for v0.8.0
.
I have added the non-interactive random-table/roll/test-all
function; this will roll once on each of the registered non-private tables and report the results. I’ve found it most useful when testing notable refactoring; namely how I handle the :roller
slot for a random-table
.
During Emacs Conf 2023 I watched Howard Abrams’s presentation How I play TTRPGs in Emacs. And I suspect I’ll be migrating to that. What I have works well, but there’s quite a bit I’m loving about what I saw. In particular, having the tables be their own files creates several affordances. Namely sharing those text-based files and repurposing plain-text.
Also, if I’m going to spend effort on the functionality, I’d love to be collaborating. So we’ll see.
My plan is to start converting my tabular data to the plain text formats of the rpgdm package.
I have looked at Howard Abram’s rpgdm package and later rpgtk package for inspiration and adoption consideration. However, I have settled on my current approach. In part due to the feature comparisons:
Feature | RPGDM/RPGTK | random-tables |
---|---|---|
Automatic dice evaluation (e.g. “There are 2d6 giants” will always roll the 2d6) | ✔ | - |
Caching prompt choices; remembering the Charisma Modifier | - | ✔ |
Caching rolls for later reference | - | ✔ |
Complex dice rollers (e.g. 2d6 + Charisma Modifier + Situational Modifier) | - | ✔ |
Conditional dice evaluation (e.g. “2d6” in a table is not evaluated, but “{2d6}” is) | - | ✔ |
Custom reporter; configure how you report the results of a roll | - | ✔ |
Evaluate text region and roll | - | ✔ |
Evaluating dice within a result | ✔ | ✔ |
Extensible parser functions | - | ✔ |
Inner tables (e.g. “You meet a [dragon/knight/peasant]”) | ✔ | ✔ |
Lazy load tables | ✔ | - |
Load text tables (e.g. org-mode, plain text, markdown) | ✔ | - |
Mathematical operations of table results | - | ✔ |
Multiline output (e.g. table results can include \n ) | - | ✔ |
Private tables | - | ✔ |
Prompt for table evaluated as a “roll” (e.g. I can input “There are {2d6} [giants/frogmen/hermits]” into the table prompt) | - | ✔ |
Prompting to provide own dice roll | - | ✔ |
Results of a roll can then roll on more tables | ✔ | ✔ |
Rolled dice overview; the results of each dice and the sum | ✔ | - |
I provide the above feature comparison not to diminish the excellent work of Howard, as both of his above packages provide other dice rolling adjacent functionality as well as allow for loading plain text data as a table; a feature that should not be discredited in it’s friendliness as well as greater shareability.