Org Babel for TTRPG Automation

I play a lot of solo tabletop RPGs, mostly for worldbuilding and writing purposes because thinking in the shoes of a person in the world helps you pinpoint what is and isn't ultimately important. Typically I use either pen and paper or PUM Companion to play, but I would like to play more complex games (particularly of my own design) and that requires some automation to be ergonomic for solo play. I've done this with spreadsheet scripting in the past, but that also has the problem of being very "numbers" focused, and it's difficult to structure it like I want to (it's still a narrative, after all).

So, for this Emacs Carnival, I wanted to solve this problem with Org Babel, because not only can it be used for literate programming in the traditional sense (as I've already talked about in this blog), it can also be used to interact with and manipulate the document the code itself is in. This allows the document to "come alive".

As a proof of concept of how I want this to work, I've mocked up a Fate Accelerated character sheet which does some very simple automations to make rolling dice easier. I've spent only a few hours mocking this up, and no doubt I will expand it, especially to include an oracle system of some sort for solo play as well. Let's have a look at how it works now.

The full sheet can be found here but I'll copy code blocks here as relevant. I won't duplicate the structure, but essentially all the "code" of this sheet is inside it's own code header so that it can be hidden. In that header there is a startup block, which looks like this

#+name: startup
#+begin_src elisp :results silent
  (org-sbe "fae")

  (define-minor-mode srpg-mode
    "Minor mode for solo RPG gameplay."
    :init-value nil
    :keymap (make-keymap))

  (define-key srpg-mode-map (kbd "M-r") 'fae/take-action)
#+end_src

The org-sbe macro evaluates another elisp code block that contains all the Fate system logic that I need. The minor mode is also defined here because I want key mapping, but I don't want to override any keys in the org major mode in other buffers. It's also defined here because I want the whole file to be redistributable.

There is another code block before the Fate code that looks like this:

#+name: prop-tbl-lookup
#+begin_src elisp :var lookup="" target="" :results silent
  (alist-get target
         (mapcar #'(lambda (x) (cons (replace-regexp-in-string "\\(\\[\\[.*\\]\\[\\)\\(.*\\)\\]\\]" "\\2" (cadr x)) (car x)))
                 (cl-remove-if (lambda (x) (not (listp x))) lookup))
         nil
         nil
         (lambda (x y) (let ((z (string-search x y)))
                         (and z (= 0 z)))))
#+end_src

This is a bit disgusting, but essentially what it does is it searches a table (lookup) for a value based on the right column contents (target). In this block, lookup and target are empty strings - the code blocks in the Fate code will set those values when they call this block. This is the least annoying way of fetching values from an org table that I could figure out.

You may be wondering about that regex replace. It converts links to their description, which is useful because the Fate approaches (skills) table for each character looks like this:

#+name: pc1
| +x | Approach |
|----+----------|
| +2 | Careful  |
| +0 | Clever   |
| +8 | Flashy   |
| +3 | Forceful |
| +0 | Quick    |
| +0 | Sneaky   |

If you've never seen elisp: links, I wouldn't blame you, but they are perfect here because they turn an otherwise static table into a bunch of clickable buttons. This is 1 way to roll checks in this sheet, which uses this snippet of code:

(defun fae/roll-approach (table approach)
  "Get careful approach value."
  (+ (fae/roll-ndF 4)
     (or (org-babel-execute-src-block nil (org-babel-lob-get-info `(babel-call (:call "prop-tbl-lookup" :arguments ,(format "%s, \"%s\"" table approach))))) 0)
     (if (y-or-n-p "Any other modifiers?")
       (read-minibuffer "+? ")
       0)))

fae/roll-ndF is just a dice rolling function and not that interesting. The org-babel-execute-src-block may draw your attention instead. What this is doing is constructing a #+call: block in code, which allows us to pass a dynamic table and approach as parameters. We can't use org-sbe here because any arguments passed to it are treated as strings, (org-sbe table) will treat table as a string literal "table" and not as it's actual value.

We have one more way of rolling dice, which is bound the M-r in the minor mode:

(defun fae/get-approach-modifier (approach)
  "Get approach value."
  (let ((table (read-string "For which approach table? ")))
    (or (org-babel-execute-src-block nil (org-babel-lob-get-info `(babel-call (:call "prop-tbl-lookup" :arguments ,(format "%s, \"%s\"" table approach))))) 0)))

(defun fae/take-action ()
  "Say what you're trying to do and insert a roll outcome."
  (interactive)
  (let* ((action (read-string "What are you trying to do? "))
         (dice-roll (fae/roll-ndF 4))
         (approach (completing-read
                  "Select approach: "
                  '("Careful" "Clever" "Flashy" "Forceful" "Quick" "Sneaky")))
         (app-mod (fae/get-approach-modifier approach))
         (add-mod (if (y-or-n-p "Any other modifiers?")
                    (read-minibuffer "+? ")
                  0)))
    (insert (format "%s (Approach %s, Rolled %s+%s+%s=%s)\n" action approach dice-roll app-mod add-mod (+ dice-roll app-mod add-mod)))))

This is used for logging the action at the current point instead of just displaying the result in the minibuffer, which is good because I like to keep track of my rolls and what I was trying to do with them.

And finally, I want this to all set itself up automatically when I open the file, which can be done by putting this at the very bottom to run the startup block when the file opens.

# Local Variables:
# org-confirm-babel-evaluate: nil
# eval: (progn (org-sbe "startup"))
# End:

And that's all for now, until I extend this to include other features from Fate like fate point tracking and aspects/stunts.