Biblyon the Great

This zine is dedicated to articles about the fantasy role-playing game Gods & Monsters, and other random musings.

Gods & Monsters Fantasy Role-Playing

Beyond here lie dragons

Percentage-based random tables

Jerry Stratton, March 19, 2014

Our current random item generator assumes that each item shows up as often as any other item. That’s very OD&D-ish; as often as not, there will be six items per table. But AD&D game masters often use percentage dice to generate some monsters and items more than others. Their tables look like:

01-15Orcs (d6)
16-25Goblins (2d20)
26-00Astertarte the Red Dragon

In this table, most likely for the red dragon’s lair, the red dragon shows up far more often than orcs and goblins. You can see more about this kind of table using the Wandering monster chart assistant.

We can make our random item generator recognize these tables and choose appropriately from them. The main difference is that, instead of creating a list of items and choosing from the list, we will need to generate a number from 1 to 100, and determine where that number falls in the list.

For test purposes, I’m going to use a modification of the default table from the wandering monster assistant:

  • 01-30 Orcs (d12)
  • 31-50 Mushroom People (2d20)
  • 51-70 Pixies (d100)
  • 71-83 Goblins (d20)
  • 84-95 Giant cucumbers (d4)
  • 96-00 Astertarte the Red Dragon

And I’m going to modify the “random” script to separate the parts where the script checks for the file and the part that loads the items in the file:

[toggle code]

  • #!/usr/bin/python
  • import random
  • import optparse
  • import os
  • parser = optparse.OptionParser()
  • parser.add_option('-l', '--locale')
  • (options, args) = parser.parse_args()
  • #load the table into the appropriate class
  • def tableFactory(name):
    • filename = name + '.txt'
    • filepath = filename
    • if options.locale:
      • localepath = os.path.join(options.locale, filename)
      • if os.path.exists(localepath):
        • filepath = localepath
    • items = open(filepath).read()
    • table = SimpleTable(items)
    • return table
  • #a table is a list of items that we choose randomly from
  • class Table(object):
    • #print some random items from the table
    • def choose(self, count):
      • for counter in range(count):
        • print counter+1, self.choice()
  • #a simple table is a list of items with equal probability
  • class SimpleTable(Table):
    • #load the list of items and save it
    • def __init__(self, items):
      • self.items = items.splitlines()
    • #choose a random item from the table
    • def choice(self):
      • return random.choice(self.items)
  • count = 1
  • while args:
    • firstArgument = args.pop(0)
    • #if the first argument is a number, it's the number of random items we want
    • #otherwise, it is the table and we want one item from it
    • if firstArgument.isdigit():
      • count = int(firstArgument)
      • table = args.pop(0)
    • else:
      • table = firstArgument
    • table = tableFactory(table)
    • table.choose(count)
    • print

The part that looks for the appropriate file based on the given file name (“monsters” or “dragons” or “gems”) is now in the function called “tableFactory”. This function generates a table for us depending on the contents of the file—although at this point it only knows about one type of file. At the location where it says “table = SimpleTable(items)” we will eventually have it look in the file and create either a SimpleTable or a PercentTable object, depending on the contents.

The part that shows the random item has been broken from one class into two classes. The basic class is “Table”. All it knows to do is print out however many choices that we ask for. It doesn’t even know how to make the choice: it calls a method called “choice” that needs to be on the subclass.

SimpleTable is the subclass: because it has “Table” in parentheses, it inherits everything the Table class knows and also adds its own. SimpleTable has two methods: one to load the list of items into an array, and one to make a choice from that array.

Make these changes and run the script, and it should work exactly as it did before.

So, now that we can have a class for each type of table, let’s make one for percent tables:

[toggle code]

  • class PercentTable(Table):
    • #load the list of items and save it
    • def __init__(self, items):
      • self.items = []
      • for line in items.splitlines():
        • #generate the range, all we need is the high number
        • (dieRange, item) = line.split("\t")
        • if '-' in dieRange:
          • (lowDie, highDie) = dieRange.split('-')
        • else:
          • highDie = dieRange
        • highDie = int(highDie)
        • #00 is often used in place of 100 in wandering monster tables
        • if highDie == 0:
          • highDie = 100
        • self.items.append((highDie, item))
    • #choose a random item from the table
    • def choice(self):
      • #generate a number from 1 to 100
      • d100 = random.randrange(100)+1
      • #find the corresponding line
      • line = bisect.bisect_right(self.items, (d100,))
      • (high, item) = self.items[line]
      • return item

Rather than simply using the lines in the file as the list of items, this class will loop through each line and store both the high number for that line and the item that high number corresponds to as a tuple. In Python, a tuple is a special kind of list that can be used with functionality such as that provided by bisect. When __init__ is finished, the items property contains:

  • [(30, 'Orcs (d12)'), (50, 'Mushroom People (2d20)'), (70, 'Pixies (d100)'), (83, 'Goblins (d20)'), (95, 'Giant cucumbers (d4)'), (100, 'Astertarte the Red Dragon')]

The “choice” method is now also more complex; instead of using random.choice to return one of the items at random, it first generates a number from 1 to 100. It then uses the bisect module to find which item has that random number but does not exceed it. Then, it returns the second piece of that item, which is the name of the random thing we just generated: an Orc, or a Mushroom People, and so on.

At the top of the script, with all of the imports, import the bisect module:

  • import bisect

Then, replace “table = SimpleTable(items)” with:

[toggle code]

  • if "\t" in items:
    • table = PercentTable(items)
  • else:
    • table = SimpleTable(items)

If there is a tab in the file anywhere, the script assumes that this is a d100 table and uses the PercentTable to generate the list; otherwise, it assumes it’s a simple list.

If you run this script as “./random 10 monsters” you should see that Orcs appear most often, Mushroom People next most often, and on down to Astertarte the Red Dragon least often. Sometimes Astertarte won’t show up in the list at all. He has less than a one in ten chance of appearing.

./random 10 monsters
1 Orcs (d12)
2 Mushroom People (2d20)
3 Mushroom People (2d20)
4 Pixies (d100)
5 Orcs (d12)
6 Orcs (d12)
7 Pixies (d100)
8 Pixies (d100)
9 Mushroom People (2d20)
10 Giant cucumbers (d4)

And if you run it one one of the old-style files, it should still return random lines from the file.

How many Mushroom People?

But the new form of table also includes information about how many of each item show up. For each encounter with Orcs, there will be 1 to 12 of them; for each encounter with Mushroom People, there will be 2 to 40 of them. In encounter charts and treasure charts, these are usually marked as d12 or 2d20. Rather than force a new notation or write something to parse standard dice notation, I did a Google search on “python dice notation” and found the dice library.

Then, on the command line, I used “pip search dice” to see that it was available via the Python installer tool. It was, so “sudo pip install dice” installed it.

Once installed, we can import it just like we imported bisect earlier:

  • import dice

Update the class by adding a section for storing the “number appearing” die roll as a third item, and then, when pulling it out, roll that die using the new dice library with “dice.roll”:

[toggle code]

  • class PercentTable(Table):
    • #load the list of items and save it
    • def __init__(self, items):
      • self.items = []
      • for line in items.splitlines():
        • #generate the range, all we need is the high number
        • (dieRange, item) = line.split("\t")
        • if '-' in dieRange:
          • (lowDie, highDie) = dieRange.split('-')
        • else:
          • highDie = dieRange
        • highDie = int(highDie)
        • #00 is often used in place of 100 in wandering monster tables
        • if highDie == 0:
          • highDie = 100
        • #generate the number appearing
        • appearing = '1'
        • if item.endswith(')'):
          • dieStart = item.rindex('(')
          • appearing = item[dieStart:]
          • appearing = appearing.strip('()')
          • if 'd' in appearing:
            • appearing = appearing + 't'
          • item = item[:dieStart]
          • item = item.strip()
        • self.items.append((highDie, item, appearing))
    • #choose a random item from the table
    • def choice(self):
      • #generate a number from 1 to 100
      • d100 = random.randrange(100)+1
      • #find the corresponding line
      • line = bisect.bisect_right(self.items, (d100,))
      • (high, item, appearing) = self.items[line]
      • numberAppearing = dice.roll(appearing)
      • return item + ' (' + unicode(numberAppearing) + ')'

If an item ends with a close parenthesis, this uses the rindex method to get the location of the open parenthesis. The rindex method starts from the end of the text and finds the first match from the end (thus, “reverse index”). We then copy the text of the item variable starting at where the open parenthesis is and going to the end. That’s what “item[dieStart:]” does: it starts at the number represented by dieStart, and goes to the end of the text (because there is no number after the colon).

It then strips off any parentheses and adds the letter ‘t’ to the text if it’s a die roll; according to the documentation for dice this provides the total instead of a list of each die. That is, 2d20 will produce, say, 15, instead of [7, 8].

Depending on usage, it may be necessary to verify that the text with the parentheses is a die roll, but I’ll wait to see what happens in use before adding that extra code.

The “choice” method gets updated to handle the new third value and generate the number appearing from it.

When it returns the random item, it appends the number appearing in parentheses after it. Because Python can’t add numbers to text, it first converts the number “numberAppearing” to text—unicode text.

./random 10 monsters
1 Orcs (1)
2 Goblins (4)
3 Giant cucumbers (2)
4 Giant cucumbers (1)
5 Pixies (58)
6 Pixies (25)
7 Mushroom People (2)
8 Pixies (44)
9 Mushroom People (16)
10 Mushroom People (28)

Make the list look better

When printing things in Python, multiple items can be printed one after the other using the comma. But this also puts a space between those things. When printing out list, we have the ordinal number of each choice printed first, then a space, and then the random choice itself.

It is more normal to put a period or a close parenthesis after the ordinal number. One way to put a period after a number without an intervening space is to use unicode and the ‘+’ operator as we did above:

  • print unicode(counter+1)+'.', self.choice()

However, this produces poorly-aligned numbers. Python has string formatting built-in that can align numbers to a specific number of spaces. It uses special codes to specify where an integer goes and where a string goes, and how long they need to be padded to. For example, to pad an integer to three spaces, use “%3i”; to display a string of text, use “%s”.

To do this, we need to know how long the maximum number will be, but we know that because we know what the maximum number is: it’s the count. Change the “choose” method in the Table class to be:

[toggle code]

  • def choose(self, count):
    • maximumLength = len(unicode(count))
    • formatString = '%' + unicode(maximumLength) + 'i. %s'
    • for counter in range(count):
      • if count > 1:
        • print formatString % (counter+1, self.choice())
      • else:
        • print self.choice()

First, this converts the count variable from an integer to a string of text, and gets the length of that text. That’s how far the integer needs to be padded to. Then it constructs the format string, which will be like “%2i. %s” or “%1i. %s”. And then it loops through as it did before, but printing the results using the format string. The format string has two codes in it, so it needs a tuple with two items. In this case, those two items are the counter (with one added to it) and the results of the choice method.

And if we only requested one item, it doesn’t bother to print the ordinal number.

./random 10 monsters
 1. Orcs (1)
 2. Giant cucumbers (1)
 3. Pixies (11)
 4. Orcs (11)
 5. Pixies (40)
 6. Mushroom People (24)
 7. Orcs (6)
 8. Pixies (22)
 9. Orcs (8)
10. Pixies (7)

./random monsters
Pixies (81)

Here’s another list pulled from the Deep Forest in Lost Castle of the Astronomers:

  • 01-12 Large Spiders (d3)
  • 13-24 Pixies (d20)
  • 25-36 Treeherders (d4)
  • 37-47 Huge Spiders (d2)
  • 48-57 Apparitions (d100)
  • 58-63 Mist encounter
  • 64-68 Pegasi (d2)
  • 69-73 Unicorns (d3)
  • 74-77 Brownies (d20)
  • 78-81 Dryad (1)
  • 82-85 Petraiad (1)
  • 84-85 Petraiads (d4)
  • 86-88 Carrion Worms (d4)
  • 89-91 Gryphon (1)
  • 92 Naiad (1)
  • 93-94 Naiads (d20)
  • 95-97 Satyrs (d10)
  • 98-99 Poltergeist (1)
  • 00 Ghouls (d4)

You might notice that Naiad and Petraiad are in twice; this is because in the original, they’re listed as 50% 1 or d4 and 50% 1 or d20. That’s hard to parse with a computer program, and it’s easier to just give them two lines, one for the single instance and one for the group instance.

./random Deep\ Forest
Satyrs (9)

./random 3 Deep\ Forest
1. Apparitions (38)
2. Gryphon (1)
3. Pegasi (2)

And here is the full script for reference:

[toggle code]

  • #!/usr/bin/python
  • import random
  • import optparse
  • import os
  • import bisect
  • import dice
  • parser = optparse.OptionParser()
  • parser.add_option('-l', '--locale')
  • (options, args) = parser.parse_args()
  • #load the table into the appropriate class
  • def tableFactory(name):
    • filename = name + '.txt'
    • filepath = filename
    • if options.locale:
      • localepath = os.path.join(options.locale, filename)
      • if os.path.exists(localepath):
        • filepath = localepath
    • items = open(filepath).read()
    • if "\t" in items:
      • table = PercentTable(items)
    • else:
      • table = SimpleTable(items)
    • return table
  • #a table is a list of items that we choose randomly from
  • class Table(object):
    • #print some random items from the table
    • def choose(self, count):
      • maximumLength = len(unicode(count))
      • formatString = '%' + unicode(maximumLength) + 'i. %s'
      • for counter in range(count):
        • if count > 1:
          • print formatString % (counter+1, self.choice())
        • else:
          • print self.choice()
  • #a simple table is a list of items with equal probability
  • class SimpleTable(Table):
    • #load the list of items and save it
    • def __init__(self, items):
      • self.items = items.splitlines()
    • #choose a random item from the table
    • def choice(self):
      • return random.choice(self.items)
  • class PercentTable(Table):
    • #load the list of items and save it
    • def __init__(self, items):
      • self.items = []
      • for line in items.splitlines():
        • #generate the range, all we need is the high number
        • (dieRange, item) = line.split("\t")
        • if '-' in dieRange:
          • (lowDie, highDie) = dieRange.split('-')
        • else:
          • highDie = dieRange
        • highDie = int(highDie)
        • #00 is often used in place of 100 in wandering monster tables
        • if highDie == 0:
          • highDie = 100
        • #generate the number appearing
        • appearing = '1'
        • if item.endswith(')'):
          • dieStart = item.rindex('(')
          • appearing = item[dieStart:]
          • appearing = appearing.strip('()')
          • if 'd' in appearing:
            • appearing = appearing + 't'
          • item = item[:dieStart]
          • item = item.strip()
        • self.items.append((highDie, item, appearing))
    • #choose a random item from the table
    • def choice(self):
      • #generate a number from 1 to 100
      • d100 = random.randrange(100)+1
      • #find the corresponding line
      • line = bisect.bisect_right(self.items, (d100,))
      • (high, item, appearing) = self.items[line]
      • numberAppearing = dice.roll(appearing)
      • return item + ' (' + unicode(numberAppearing) + ')'
  • count = 1
  • while args:
    • firstArgument = args.pop(0)
    • #if the first argument is a number, it's the number of random items we want
    • #otherwise, it is the table and we want one item from it
    • if firstArgument.isdigit():
      • count = int(firstArgument)
      • table = args.pop(0)
    • else:
      • table = firstArgument
    • table = tableFactory(table)
    • table.choose(count)
    • print

In response to Programming for Gamers: Choosing a random item: If you can understand a roleplaying game’s rules, you can understand programming. Programming is a lot easier.

  1. <- Multiple random tables
  2. Random table rolls ->