Idraluna Archives

The Great Antarctic Hexcrawl Pt. 2 - We Have Perchance at Home (Writing Nested Random Generators)

This is my own version of lore24, an admittedly over-ambitious attempt to procedurally generate a 128,000-hex crawl. Part 1 here. I'll be taking a break from cartography for this post to write about preparing tables and random generators.

Trials & Tabulations

Like most people with more than a casual interest in ttrpgs, I have accumulated what the scientific community calls an 'ass-load' of random tables. Some are brilliant, some are stuffed with cruft, most have at least a few interesting ideas. Too actually fill out 128,000 keyed hexes, I'll need to develop a really robust set of my own tables and have a flexible of telling the computer to 'roll' on them as I generate content.

Allocation vs. Generation

As I compile my list of tables, I've found this to be a useful distinction. By 'Allocation' I mean randomly placing unique entries from a curated list into the world, and by 'Generation' I mean using non-unique table rolls to create content. For example, legendary unique treasures are an allocation problem, as it doesn't make sense for there to be a chance that multiple dungeons will have a Grasal of St. Ilidia at the bottom.

Allocation also helps with the 'bottom-up vs top-down' problem I touched on in Pt. 1. With random allocation I can get more detailed and specific with the entries to be allocated.

Also, generation can support allocation. I plan to use generators to start and/or finish lists of things to allocate. But after generation I can tweak the results, insert my own entries, etc.

Things to Allocate

Things to Generate

Storing Tables

Even at this early stage, the number of tables and entries I'm working with has gotten unwieldy. Initially I had a csv file where each column was its own table, but it ended up being quite ugly and difficult to work with. So, I switched to a perchance-style text file with table names marked with >> and entries on subsequent lines. This made adding entries much more flexible, and also makes it much easier to tie tables together in interesting ways. It can be viewed here

One catch: there are lists I want to be able to randomly roll on that are stored in separate csv files (monsters and spells being the main ones). Rather than trying to synchronize between the table list and a csv, I just added some code to auto-append the desired lists when loading in the master table file.

Sourcing Inspiration

I'm working on compiling all my tables by hand, but I can't avoid or deny being inspired by those compiled by others. Many have entries I really want to include somewhere in Antibor, but often the inspiration is structural; I usually have my own ideas of what specific things I do and don't want in Antibor, but it's helpful to see how others categorize and group their tables.

For what I hope is an adequately ethical approach, I'll only be drawing from works with some kind of creative commons license, citing them in these posts and whatever form the final hexcrawl takes, and not profiting on this. In any case, I'm taking care not to copy-paste anything, so the tables that actually get used should be adequately my own work. So far my top inspirations include:

The Perchance at Home

Because all the mapping is being done with GIS files in QGIS and R, I wanted to keep my workflow in R. Perchance is currently the best custom generator platform I've found, and I tried to imitate its ability to nest generator calls within table entries.

Compiling tables

As mentioned above, this reads in the master table text file, storing each table as a named vector in a list object. It then-appends specified lists stored in other files (e.g. monsters, spells).

lines <- readLines(file.path(tabledir, 'random_master.txt'))

current_header <- NULL
random_master <- list()

for (line in lines) {
  print(line)
  if (line=='') {next}
  if (startsWith(line, ">>")|startsWith(line, ' >>')) {
    # If line starts with ">>", update the current header
    current_header <- substr(line, 3, nchar(line))
    random_master[[current_header]] <- character(0)
  } else if (grepl('\\^', line)) {  # if there's a ^x indicating to repeat an entry x times:
    reps <- as.numeric(strsplit(line, '\\^')[[1]][2])
    entry <- strsplit(line, '\\^')[[1]][1]
    for (i in 1:reps){  # add repeated entries to the vector
      random_master[[current_header]] <- c(random_master[[current_header]], entry)
    }
  }else {
    # Otherwise, add the entry to the current header's vector
    random_master[[current_header]] <- c(random_master[[current_header]], line)
  }
}

# Add other necessary lists
spells <- read.csv(file.path(chapterdir, 'Spells.csv')) %>% select(Proper_name, Grimoire)
random_master$Spell <- spells$Proper_name
random_master$Grimoire <- unique(ifelse(!is.na(spells$Grimoire)&spells$Grimoire!='', paste('Grimoire (', spells$Grimoire, ')'), NA))
monsters <- read.csv(file.path(chapterdir, 'Bestiary.csv'))

Die roller

Extremely basic babby's first function type stuff here, just sharing for completeness' sake. Note that it accepts 'XdY' and 'X-Y' notation.

roll <- function(xdy){
  if(grepl('^(\\d*)d(\\d+)', xdy)){
    match_result <- str_match(xdy, "^(\\d*)d(\\d+)")
    x <- as.integer(match_result[1, 2])
    y <- as.integer(match_result[1, 3])

    out=0

    for(i in 1:x){
      out <- out+sample(1:y, 1)
    }

    return(out)
  }else if (grepl("^\\d+-\\d+$", xdy)){
    x <- as.integer(strsplit(xdy, '-')[[1]][1])
    y <- as.integer(strsplit(xdy, '-')[[1]][2])
    return(sample(x:y, 1))
  }else{
    print(paste(xdy, 'is invalid. Returning NaN.'))
    return('NaN')
  }
}

Main Randomizer Function

The algorithm is as follows:

  1. While there's a bracketed word in the prompt:
    1. Check if there's a multiplier outside the brackets (e.g. 0.33*[Magic_item])
      1. If so, try to roll under the decimal multiplier. If successful, delete the multiplier and proceed. If not, replace the whole thing with an empty string.
    2. Grab the table corresponding to that word and roll a random entry on it
    3. Replace the bracketed word, brackets and all, with the rolled entry

And that's it! What it lacks is the context-awareness of perchance; there's no way to weight entries by an input or previously-rolled result. So far, I think I can work around that by just making more specific generators and adding tables for specific situations. Which is inelegant, but something I can build around.

r_gen_eval <- function(prompt){
  while(grepl('\\[', prompt)){
    #print(prompt)
    # check for chance multipliers
    if(grepl('\\*\\[', prompt)){
      new_key <- str_match(prompt, "0\\.\\d+\\*\\[.*?\\]")
      odds <- strsplit(new_key, '\\*')[[1]]
      print(odds)

      if(runif(1)<as.numeric(odds[1])){  # if it passes
        prompt <- str_replace(prompt, "0\\.\\d+\\*\\[.*?\\]", odds[2])
      } else{  # if it fails, eliminate the prompt
        prompt <- str_replace(prompt, "0\\.\\d+\\*\\[.*?\\]", '')
        next
      }
    }

    # get first keyword with a regular expression
    new_key <- str_match(prompt, "\\[([^\\]]*)\\]")[,2]
    #print(new_key)

    if(grepl('^(\\d*)d(\\d+)', new_key)|grepl("^\\d+-\\d+$", new_key)){  # if die notation
      die_roll <- as.character(roll(str_match(prompt, "\\[([^\\]]*)\\]")[,2]))
      prompt <- str_replace(prompt, '\\[(.*?)\\]', die_roll)

    } else{
      new_list <- random_master[[new_key]]
      sub_result <- sample(new_list, 1)
      # replace [keyword] with 
      prompt <- str_replace(prompt, '\\[(.*?)\\]', sub_result)
    }

  }
  return(prompt)
}

Here, I call for it to roll a random gemstone, a name, and a die roll. The name includes nesting: the [Name_alab_m] entry calls for a forename and surname roll like so: [Forename_alab_f] [Surname_alab]

r_gen_eval("This is a test generator. [Gem] is a rock. My name is [Name_alab_m]. I am [4d12] years old.")

[1] "This is a test generator. Carnelian is a rock. My name is Stromulus Octane. I am 18 years old."

Remaining Tasks

It's not perfect, but now I have basic code for nested random generation and can store all my tables in a single source file. Continuing to compile the random tables is going to be an ongoing process, but now I have the structure in place to start using them for piecemeal aspects of Antibor.

#DIY #antibor #lore24 #random-tables