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
- Exultant houses, to assign to prefectures (already done)
- Fiat: names, specialties
- Generated: leaders, members, feuds
- Archons, to place in citadels (already done by fiat)
- Fiat: names, general natures, proclivities
- Generated: retainers,
- Autochthonous cultures, to assign to regions (in progress)
- Fiat: names, locations
- Generated: values, linguistic roots, styles of dress
- Archimages, to place in specific wizard's tower hexes
- Fiat: names, specialties
- Generated: tower details, locations,
- Legendary treasures, to place in dungeons
- Fiat: all
- Monsters, to place in dungeons and lair hexes
- Fiat: Names, some stats
- Generated: Some stats, lair locations
- Cults
- Fiat: names of their dark gods, beliefs
- Generated: which areas they've infiltrated, secret signs and membership tokens, maybe some kind of agenda?
Things to Generate
- Monster lairs (and the number of inhabitants)
- Dungeons
- Encounter tables (more on this in a future post)
- Local VIPs
- Mercenaries for hire
- Treasure hordes
- Landmarks
- City districts
- Minor settlement descriptions (e.g. what god does this temple worship, how battle-ready is this garrison)
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:
- Vaults of Vaarn by Leo Hunt - Vaarn has similar fictional roots to Antibor (and is on the whole better and more original by far) but I'm going for a slightly different vibe and am not limited to 'psychedelic blue desert'. Overall, the sandbox generator in Vaarn has been a big inspiration for thinking about what kinds of points of interest to write generators for.
- Weird North by Jim Parkins - Short but sweet, Weird North is sort of my reference point for a minimum viable product when it comes to building a pulpy sci-fantasy campaign.
- Worlds Without Number by Kevin Crawford - Tons of tables here, obviously. AFAIK these are in the non-CC parts of the book, so I'm trying to distance myself from specific entries and only using it as general inspiration.
- Delving Deeper by Simon J. Bull - Mostly leaning on the treasure tables and stocking procedures
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:
- While there's a bracketed word in the prompt:
- Check if there's a multiplier outside the brackets (e.g. 0.33*[Magic_item])
- 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.
- Grab the table corresponding to that word and roll a random entry on it
- Replace the bracketed word, brackets and all, with the rolled entry
- Check if there's a multiplier outside the brackets (e.g. 0.33*[Magic_item])
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.
- [x] Divide Antibor into manageable chunks, place regional cities and roads.
- [x] Snap roads and rivers to hex geometry.
- [x] Implement basic nested random generation functionality
- [ ] Compile random tables
- [ ] Names
- [x] Alabastrine
- [ ] Exultant
- [x] Kobellin
- [ ] Thaumaturge
- [ ] Cyberian
- [ ] Aldebarati
- [ ] Autochthonous
- [ ] Golden Men
- [ ] Stregovari
- [ ] Xikai
- [ ] Tlaxian
- [ ] Dungeon types
- [ ] Landmarks
- [ ] NPCs
- [ ] Commoner
- [ ] Armiger
- [ ] Exultant
- [ ] Hierodule
- [ ] Thaumaturge
- [ ] Mercenary
- [ ] Bandit chief / warlord
- [ ] Treasure tables
- [x] Treasure types
- [x] Gems
- [x] Artefacts
- [x] Absinthe
- [x] Art objects and special treasure
- [x] Magic weapons
- [x] Scrolls
- [ ] Grouped into hordes for lairs
- [ ] Smaller dungeon caches
- [x] Treasure types
- [ ] Names
- [ ] Compile bestiary
- [ ] Generate stats for all monsters
- [ ] Generate random descriptions/drives/weaknesses/spoors for monsters that need them
- [ ] Fill out biome preferences for all monsters
- [ ] Write script to build 'master' biome encounter tables based on monster biome preferences
- [ ] Stock each prefecture with lairs, dungeons, landmarks, and NPCs
- [ ] Develop a clear taxonomy of points of interest and develop a strategy for placing each type
- [ ] Iterate through prefectures and place each site, recording it in one or more hex attributes
- [ ] (Possibly) do another roads pass to link up POIs within prefectures
- [ ] Develop a clear taxonomy of points of interest and develop a strategy for placing each type
- [ ] Write scripts to randomly generate site descriptions to insert into the hex key
- [ ] Dungeon generator
- [ ] Settlement generator
- [ ] NPC generator
- [ ] Other?
- [ ] Insert legendary monsters, NPCs, and treasures into appropriate sites
- [ ] Write a script to assemble prefecture encounter table(s) based on biomes and lairs
- [ ] Write script to compile a markdown key file from the geodata and tables generated in the previous steps.
- [ ] Playtest (?????)