Programming a Yes, But Generator

While popular with the Solo RPGers, as a GM, when my players come up with a crazy idea, and ask something like, “Is there a candy vendor anywhere on this street?” I often feel to turn to a random number generator.

A popular Luck or Fate chart involves rolling a six-sided dice with these interpretations:

  1. No, and …
  2. No
  3. No, but …
  4. Yes, but ..
  5. Yes
  6. Yes, and ..

In other words, when rolling a ‘1’ on dice, you say “no”, and you make the situation worse, but on a 6, you get a yes answer, and make the results better.

“Is there a candy vendor on this street?”

  1. No, and the guards that have been looking for you round the corner.
  2. No, That would be pretty unlikely.
  3. No, but there is a little girl with a large lollypop in her hand. She might have more sweets in that bag she’s holding.
  4. Yes, but they’re closed.
  5. Yes. Can’t believe your luck.
  6. Yes, and they are going out of business and liquidating their wares!

Is the likelihood of a candy vendor on a random street, a 50/50 chance? In Tanya Pigeon’s Mythic GM Emulator, she has a table of shifting odds. You first start with figuring out the likelihood from choices like “probably” and “unlikely”, and then further adjust the range with a “chaos level” that further shifts it one way or the other. For instance, the candy vendor is a unlikely, but the chaos is high, and all sorts of weird events are happening, so the chances are higher.

I like it, but the “Fate Chart” is a bit complicated, so I programmed it. Rendering discrete dice rolls in code is harder than working with percentages, but I spent a fun evening doing it.

Now, I would like to fuse the two ideas … which means I have to figure out the math for myself. This essay contains my thoughts (and my code) on how to render a dynamic yes/no chart.

Note: One of my goals (or non-goals) is to keep the math simple. I had visions of calculating normal distribution (bell curves) and whatnot, but decided against this. I want my algorithms (and code) to be readable, and a yes/no boundary off by a percentage point or two, won’t be obvious when combined with random numbers. That said, if you, dear reader, have some ideas, throw me your perspective at @howard@emacs.ch on Mastodon.

Likelihood Levels

Let’s begin with the concept to quantifying how likely some event may be in our fictional worlds of RPGs.

At times, I might be thinking in percentages, like 80% (I highly doubt that I will every get to the point of getting specific like 27%) or words like “probably” and “unlikely”. This constant variable is a message that tells me I can do either … enter a percentage, like 2 for 20%, or a letter that corresponds to description for a percentage. I like y and n for a likelihood on the higher or lower end of that range, and hitting the “Return” key chooses a 50/50 coin toss.

(defvar choose-likelihood-prompt
  "On a scale of 1 to 10, what is the likelihood of a positive answer?
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
  i) Impossible  v) Very unlikely  u) Unlikely
  l) Likely      p) Probably       a) Absolutely
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
  n) Quite sure No   ⮐) Unsure…50/50  y) Quite sure Yes")

Please don’t worry about the fact that I coded this in Lisp. Mentally put the parens on the other side of the function, and you’ll be fine, or if you work in the medium of Emacs, feel free to steal all the code. Oh, and that highlight-choices function will colorize some of the choices to make them stand out, but that isn’t material for this essay.

This next function is my user-interface, that prompts with a string variable I defined above, and calls read-char (a function that waits for user feedback and returns the character typed). This allows me to hit a single character to determine the likelihood and return a percentage value, from 10 to 90. This is where I can state that an “very unlikely” event has a 25% chance of occurring.

(defun choose-likelihood ()
  "Query user and return a numeric _odds_ level.
  This number is from 1 to 10, where 5 is a 50/50."
  (case (read-char choose-likelihood-prompt)
    (?1  10)    ; Numeric values correspond to a
    (?2  10)    ; percentage, so typing 3 means 30%
    (?3  30)
    (?4  40)
    (?5  50)
    (?6  60)
    (?7  70)
    (?8  80)
    (?9  90)

    (?i  13)    ; With three negative values, we
    (?v  25)    ; split under 50 section
    (?u  37)
    (?l  62)    ; Same with the three positive values
    (?p  75)
    (?a  87)

    (?n  25)    ; The yes and no options are
    (?y  75)    ; quarter options, 25% and 75%
    (t   50)))  ; Anything else is a 50/50

The question mark in front of each letter is a Lisp-ism that is the same as surrounding a letter in single quotes in C-style, i.e. ’i’, to specify a character. I like this, as it makes it looks like a query or question.

The Effects of Chaos

The GM Emulator had the idea of the ebbing and flowing of chaotic tides in the realms of luck, where even if something was highly probable, the tides of chaos might be so low and stable, and the chances of a “no” result were higher than they would be otherwise. I look at this as a way to help control the narrative beats. We should have a value to represent this during the game session:

(defvar odds-of-chaos-level 0
  "A value from -4 to +4 that affect the outcomes from the `odds' function.")

This variable will start at 0, and the documentation string displays in the help section.

I could then define a couple of functions that I can use to increase or decrease this value during game play.

(defun chaos-level-increase ()
  "Increase the current session's chaos level.
Does nothing if the chaos is at max value, according to
`odds-of-chaos-max-level'."
  (interactive)
  (when (< odds-of-chaos-level odds-of-chaos-max-level)
    (setq odds-of-chaos-level (1+ odds-of-chaos-max-level))))

(defun chaos-level-decrease ()
  "Decrease the current session's chaos level.
Does nothing if the chaos is at min value, according to
`odds-of-chaos-max-level'."
  (interactive)
  (when (> odds-of-chaos-level (- odds-of-chaos-max-level))
    (setq odds-of-chaos-level (1- odds-of-chaos-max-level))))

No need to teach any more Lisp than needed, as you are reading this essay to get ideas for your own, but the interactive line is a feature of Emacs that says I can interactively call this function from the user interface. Also, Lisp does not have operators. We call functions, and since we can name a function almost anything, < and > are functions that compare their two parameters.

How would the positive or negative “energy” affect the likelihood of a question? The levels need to have some bounds, as I never want the likelihood to ever hit either 0 or 100% (if so, why bother asking). I will make sure that the chaos-level must be between -4 and +4, but instead of hard-coding the +4, let’s make the max range of chaos with a variable:

(defvar odds-of-chaos-max-level 4
  "The maximum value the chaos level could reach.
It also refers to the negative value limit, too.")

And a function to allow the user to pick a chaos level on the fly:

(defun choose-chaos-level ()
  "Read the chaos level, as a number, from the user."
  (read-number
   (format "Choose a chaos level from -%d to +%d.
Higher values increase odds of a ‘yes’ value. "
           odds-of-chaos-max-level odds-of-chaos-max-level)
   odds-of-chaos-level))

This function calls the built-in function, read-number to prompt the user to enter a number. The format function builds up a string where I can insert the min and max values in the prompt. The read-number takes two parameters, the prompt and the default. This is how is looks when I call it:

programming-yes-but-chaos.png

Note that hitting “Return” will have the function return 0 as a number.

How this chaos influences likelihood is described later.

Division of Odds

For a 50/50 likelihood, the odds of yes/no will be at 50% (go figure), and the likelihood will shift this to some other value between 10% and 90%. The yes, but… feature could be:

1. No, and … 25% of the “No” odds section.
2. No 42% of the “No” odds section.
3. No, but … 33% of the “No” odds section.
4. Yes, but … 33% of the “Yes” odds section.
5. Yes 42% of the “Yes” odds section.
6. Yes, and … 25% of the “Yes” odds section.

Since I’m never sure discreet values shown in this table, I will create constant (but changeable) variables:

(defvar no-low-mark 0.25
  "The percentage of the ‘no’ section for worse results.")

(defvar no-high-mark 0.33
  "The percentage of the ‘no’ section for not-that-bad results.")

(defvar yes-low-mark 0.33
  "The percentage of the ‘yes’ section for complications or less-desirable results.")

(defvar yes-high-mark 0.25
  "The percentage of the ‘yes’ section for better than expected results.")

Build a function to return all a calculation of the five value marks. We calculate the numbers based on the yes/no likelihood border. If I think the likelihood of a situation is 30%, then all yes values should be 30% and all no values will be 70%. The calculations aren’t crazy:

(defun odds-markers (likelihood)
  "Return a list of yes, and limit values from LIKELIHOOD.

The LIKELIHOOD should be between 10 and 90 representing
the percentage separating ‘yes’ values from ‘no’ values.
Where the higher the value, the greater the ‘yes’ chance."
  (when (< likelihood 1)
    (setq likelihood (* 100 likelihood)))
  (when (< likelihood 10)
    (setq likelihood (* 10 likelihood)))

  (list
   (* likelihood yes-high-mark)
   (- likelihood (* likelihood yes-low-mark))
   likelihood
   (+ likelihood (* (- 100 likelihood) no-high-mark))
   (- 100 (* (- 100 likelihood) no-low-mark))))

For example, for a 50% chance, the borders would be:

programming-yes-but-03.svg

With smaller chances of “yes”, each of the three “yes” areas shrink, leaving room for larger chances for no answers. For instance, when the likelihood of a yes drops down to 30% we illustrate the area boundaries for each of the six results:

programming-yes-but-04.svg

Let’s see if this works by wrapping up a test function:

(ert-deftest odds-markers-test ()
  (should (equal (odds-markers 50) '(12.5 33.5 50 66.5 87.5)))
  (should (equal (odds-markers 30) '( 7.5 20.1 30 53.1 82.5)))
  (should (equal (odds-markers 70) '(17.5 46.9 70 79.9 92.5))))

Interplay of Chaos and Likelihood

The following function returns a new value for a given likelihood based on the level of chaos. Note that if chaos is 0, we return the likelihood value unaltered.

But how should affect it. The chaos can not shift the likelihood by a fixed amount, for a chaos of 3 couldn’t shift a highly probably likelihood of 90% by much, and still make an interesting different when the likelihood was low. I decided that the shift will be a fraction of what is available. For instance:

A likelihood of 50 for 50% means each chaos level will shift the likelihood by 10%:

programming-yes-but-01.svg

A likelihood of 30 means that:

  • Low chaos levels, will shift down by 6%, e.g. 30% divided by chaos limit (which would be 1 more than the max chaos level of 4), but
  • High chaos levels, will shift up by 14%, e.g. 70% / 5.
programming-yes-but-02.svg

This approach tends to push the rolls towards the middle.

(defun likelihood-offset (likelihood &optional chaos-level)
  "Return modified value of LIKELIHOOD affected by CHAOS-LEVEL.
If CHAOS-LEVEL is `0', return LIKELIHOOD unmodified.
Otherwise, increase or decrease the returned value of LIKELIHOOD
based on the magnitude of the positive or negative value of
CHAOS-LEVEL."
  (unless chaos-level
    (setq chaos-level odds-of-chaos-level))

  (let ((chaos-magnitude (abs chaos-level))
        (chaos-limit  (1+ odds-of-chaos-max-level)))
    (cond
     ;; If chaos is normal, return the original likelihood:
     ((= 0 chaos-level)   likelihood)

     ;; If chaos is high, take the area of possibilities (from
     ;; likelihood level to 100%), and divide into `chaos-limit'
     ;; sections (e.g. 5 levels) for each chaos level:
     ((>= chaos-level 0)
      (+ likelihood (* chaos-magnitude (/ (- 100 likelihood) chaos-limit))))

     ;; Otherwise, the chaos is low, so we do the same thing, but from
     ;; 0% to the given likelihood level:
     (t (- likelihood (* chaos-magnitude (/ likelihood chaos-limit)))))))

The following tests may make the results more clear:

  (ert-deftest likelihood-offset-test ()
    ;; 50/50 odds with no chaos should keep to 50%:
    (should (= (likelihood-offset 50  0) 50))

    ;; 50/50 odds with 2 levels of chaotic energy making it more likely
    ;; for something to be true, should bump us up around 75%:
    (should (= (likelihood-offset 50 +2) 70))

    ;; And 50/50 odds with 2 levels of stabilty should do the opposite:
    (should (= (likelihood-offset 50 -2) 30))

    ;; An unlikely question (around 37%) with 1 level of chaos, should
    ;; bump us close to the 50/50 realm:
    (should (= (likelihood-offset 37 +1) 49))

    ;; But an unlikely question with 2 levels of chaos, should put the
    ;; question _likely_ (around 62%):
    (should (= (likelihood-offset 37 +2) 61))

    ;; An absolutely sure thing (around 87-90% chance) with one level of
    ;; stability, should bring it down to just likely (around 62%):
    (should (= (likelihood-offset 87 -1) 70))

    ;; But an absolutely sure things with more chaos, shouldn't affect
    ;; the outcome much:
    (should (= (likelihood-offset 87 +1) 89))

    (should (= (likelihood-offset 50 +5) 89))
)

The Odds Function

I’ve worked from the bottom-up in designing a function for answering the question, “What are the odds of that?” Let’s create this interative function.

The order of operations:

  1. Validate the parameters are within range
  2. Gather the boundaries for each setting (by calling odds-markers above)
  3. Roll a percentile die virtually using the random function
  4. Get the results from the function odds-results (defined below)
  5. Printing out the results using the message function.

The odds-results does the work of determining with area the roll landed, and it pretty straight-forward, using a switch function, cond:

(defun odds-results (roll yes-high yes-low yes-or-no no-low no-high)
  "Compare ROLL with the values of the rest of the parameter.
Return a list consisting of the `main-message', a `helper-message',
and a proper face color to display the `main-message'.

If ROLL is less-than or equal to:

YES-HIGH (lowest number), we return ‘yes, and’
YES-LOW, we return ‘yes
YES-OR-NO, we return ‘yes, but’
NO-LOW, we return ‘no, but’
NO-HIGH, we return ‘no
Otherwise, we return ‘no, and’ "
  (let ((yes-and  '("Yes, and" "make it better"     rpgtk-critical-success-roll))
        (yes-only '("Yes"      nil                  rpgtk-successful-roll))
        (yes-but  '("Yes, but" "add a complication" rpgtk-middlin-roll))
        (no-but   '("No, but"  "add a slight help"  rpgtk-other-roll))
        (no-only  '("No"       nil                  rpgtk-failed-roll))
        (no-and   '("No, and"  "make it worse"      rpgtk-critical-failure-roll)))
    (cond
     ((<= roll yes-high)  yes-and)
     ((<= roll yes-low)   yes-only)
     ((<= roll yes-or-no) yes-but)
     ((<= roll no-low)    no-but)
     ((<= roll no-high)   no-only)
     (t                  no-and))))

When looking over the odds function below, note that:

  1. The interactive expression below, both tells my system that a user can use this function, but also calls two functions to interactively populate the parameters passed in as arguments to the function. I defined those functions above, but the prefix will be non-null if I call the function differently. Specifically, if I type Control-u before calling the function, I will have it display the results plus all the actual numbers used.
  2. Like most functions, a bit chunk of the code is actually checking for correct values.
(defun odds (prefix likelihood &optional chaos-level)
  "Return a random Yes or No message (with twists).

The LIKELIHOOD represents the percentage that \"something\"
will be true (or \"yes\"), and should be a value from 10 to 90.

The CHAOS-LEVEL should be a value from -5 to +5.
The higher the CHAOS-LEVEL, the more chance for a yes.

With PREFIX, return message with more stats on the random roll."
  (interactive (list current-prefix-arg
                     (choose-likelihood)
                     (choose-chaos-level)))

  ;; Since this is an interactive function and the primary interface,
  ;; we need to validate all the parameters (or adjust them to fit):
  (cond
   ((< likelihood 0)
    (error "The likelihood odds require a percentage between 10 and 90"))
   ((> likelihood 90)
    (error "The odds won't work with likelihoods above 90%"))
   ((> chaos-level odds-of-chaos-max-level)
    (error "The chaos-level, %d, can't be above %d" chaos-level odds-of-chaos-max-level))
   ((< chaos-level (- odds-of-chaos-max-level))
    (error "The chaos-level, %d, can't be below -%d" chaos-level odds-of-chaos-max-level))
   ((< likelihood 1)
    (setq likelihood (* 100 likelihood)))
   ((< likelihood 10)
    (setq likelihood (* 10 likelihood))))

  ;; The `seq-let' assigns each member of the list returned by
  ;; `odds-markers' to individual variables:
  (seq-let (yes-high yes-low yes-or-no no-low no-high)
      (odds-markers (likelihood-offset likelihood chaos-level))

    (let ((roll (random 100)))

      ;; This `seq-let' assigns differt parts of the "results table"
      ;; to variables to use to re-color text differently:
      (seq-let (main help color)
          (odds-results roll yes-high yes-low yes-or-no no-low no-high)

        ;; Giving the `C-u' prefix will display more information:
        (message
         (if prefix
             (odds-display-message-full main help color roll
                                        yes-high yes-low yes-or-no no-low no-high)
           (odds-display-message main help color)))))))

Summary

What does this look like in action? Calling odds displays:

programming-yes-odds.png

In the odds function, it calls odds-display-message and odds-display-message-full which take the main and help messages (along with the appropriate color code) to render a nice display. For instance:

programming-yes-but-yes.png programming-yes-but-no.png

And:

programming-yes-but-no-but.png

And everyone’s favorite:

programming-yes-but-yes-and.png

As noted before, if I type Control-u first, I get more information in the message:

programming-yes-but-no-and-full.png

What does this look like in action?