Idraluna Archives

The Great Antarctic Hexcrawl pt. 7 - A Trial Run

This is my own version of lore24, an admittedly over-ambitious attempt to procedurally generate a 43,000-hex crawl for my homebrewed far-future Antarctica, Antibor. Part 1, Part 2, Part 3, Part 4 and 4b, Part 5, Part 6 and 6b.


Realization

I'd been imagining a moment where, having compiled all my random generator prompts, monster stats, name lists, etc., I finally hit 'run' on my master script and churn out a ponderous compendium of every hex in Antibor.

That's obviously even more fantastical than 'habitable Antarctica'. I will never feel like I have enough hex fills and random tables,1 and even if I do reach that point I'll want to run the compiler script over and over again & fiddle with the various parameters. Above all, compiling everything at once doesn't leave room to grow and adjust as I get new ideas and learn from playtesting.

So I've been looking for a middle ground between 'stacking all the dominoes perfectly & letting it rip' and 'painstakingly writing hexes one by one'. My bestiary setup is a good example: I have a .csv file with the stats, a separate .tex file for each monster's prose description, and a master .tex file that uses the csvsimple package to compile it all into one document. When I feel inspired to explicate what a 'lugubarb' is, I just pull up lugubarb.tex and get writing, and the result gets seamlessly inserted into the corresponding bestiary entry. I used random scripts to get the basics in place, but also have a user-friendly way of directly writing what I want when so inclined.

Hexes are tougher. Separate files for 120ish monsters is fine, but doing so for 43,000 hexes would be a nightmare. Furthermore, to make GIS maps I have to store information as tabular data, not a user-friendly format for creative writing.

The solution, I think, is to approach Antibor like a megadungeon: continue to develop ideas, content & random tables, hand-place things where & when I want, but maintain all these campaign building blocks in a usable state where they can be assembled into playable content on short notice & apply this iteratively to small chunks of the map. In part 1, I aggregated hexes into 112 'prefectures' of 300-400 hexes -- an adequate size to get a good feel for the random tables but plentiful enough to not screw up the whole map if the results are bad.

Doing so confers some additional advantages:

My inner simulationist rankles slightly at the blow to impartiality -- regions generated in the future will (presumably) be generated by slightly different rules than those I compile first. But I can live with that.

Prefecture 375

(Why doesn't prefecture numbering start at 1? I honestly don't remember.)

The Code

The following is all part of one long script that takes a prefecture ID and the hexmap GIS file as input, randomly assigns an appropriate fill for each hex (see pt. 6 for how the fill types were assigned ), and then generates a map & key that can be compiled into a single document in LaTeX.

Filling Hexes

First, I specify the prefecture to be stocked and load in the hexmap and the table of possible hexfill entries (the homemade Perchance clone should also be loaded into memory). The big chunk after loading in the hexfill table normalizes the biome weights so that they add up to 1.

library(terra)
library(tidyverse)
library(tidyterra)

prefec_id <- 375

hexes <- vect(file.path(mapdir, 'Full_hexes.gpkg'))

hexfills <- read.csv(file.path(tabledir, 'Hexfills.csv')) %>% 
  mutate(biome_weight_sum = Alpine + Arid + Forest + Chaparral + Savannah + Steppe + Jungle + Taiga + Arratu + Sea) %>%
  mutate(Alpine = Alpine / biome_weight_sum,
         Arid = Arid / biome_weight_sum,
         Forest = Forest / biome_weight_sum,
         Chaparral = Chaparral / biome_weight_sum,
         Savannah = Savannah / biome_weight_sum,
         Steppe = Steppe / biome_weight_sum,
         Jungle = Jungle / biome_weight_sum,
         Taiga = Taiga / biome_weight_sum,
         Arratu = Arratu / biome_weight_sum,
         Sea = Sea / biome_weight_sum) %>% filter(Type!='') %>%
  mutate(fill_id = row_number())

hexfills$Max[is.na(hexfills$Max)] <- 9999

# these are the hex ids we will actually loop through
prefec_ids <- hexes %>% as.data.frame() %>% filter(prefecture_id_2 == prefec_id) %>% filter(Biome_majority > 0) %>% select(id)

Next, it loops through every hex in the prefecture.

for (hex_id in prefec_ids$id){...}

Within the loop, we grab the data for the hex and check if it is empty or if it has already been stocked, skipping the rest if either is true.

hex <- hexes[hexes$id==hex_id]
if(is.na(hex$Hexfill)&is.na(hex$Hexfill_hidden)){next}  # skip if the hex doesn't have a fill assigned
if(!is.na(hex$Assigned)){next}  # skip if the hex has already been stocked

Otherwise, the script figures out which biome weight factor the hex should use. I lumped steppe & tundra together as well as warm and temperate forest types. Also note that 'sea' and 'alpine' are descriptors that can overlap with biomes (i.e. a coastal hex could also be forest), so I have them saved as Boolean toggles. (Inelegant, but functional).

alpine = ifelse(hex$Elev_class=='Highlands', T, F)
sea = ifelse(hex$Elev_class=='Coastline', T, F)

if (hex$Biome_name %in% c('Desert')){
hex_biome <- 'Arid'
} else if (hex$Biome_name %in% c('Forest (warm)', 'Forest (temperate)', 'Conifers (temperate)')){
hex_biome <- 'Forest'
} else if (hex$Biome_name%in%c('Steppe', 'Tundra')){
hex_biome <- 'Steppe'
} else {
hex_biome <- hex$Biome_name
}

The next step is run twice, once for the main hexfill and once for a hidden hexfill (if any), so I'll only copy it down once.

It the hexfill is 'Flavor', I just use the random generator script to pick an entry:

if(hex$Hexfill=='Flavor'){
  hexes[hexes$id == hex$id, 'desc'] <- r_gen_eval('[Flavor_generic]')
  next
}

Otherwise, it uses the algorithm described in pt. 6b. First, we filter down the table of possible hexfills to those of the appropriate type (Lair, Settlement, Weird, or Dungeon), and those that are below their 'max placed' limit. If no entries meet the criteria, print a warning with the hex ID and move on.

possible_fills <- hexfills %>% filter(Type == hex$Hexfill, Placed < Max)
if(nrow(possible_fills)==0){print(paste('No options for hex', hex_id)); print(as.data.frame(hex)); next}

If there are possible entries, the biome weights are used to weight the likelihood of selecting each entry. First, we use the hex's biome (determined above) to grab the appropriate weight for each entry in a new column called hex_biome_weight.

possible_fills$hex_biome_weight <- possible_fills[,hex_biome]

Next, (I'm proud of this one but its hard to explain) we calculate a final weight by multiplying the biome-specific factor times the overall weight factor. If the hex is coastal or mountainous, we take the higher of the coastal or mountain weight and the biome weight. Also, if the entry has been placed fewer times than its 'minimum to place' threshold, we double the weight to give it a little boost. In some cases this will result in odds of 0, so we filter again and include another warning prompt.

possible_fills <- possible_fills %>%
  mutate(hex_weight = Weight*pmax(hex_biome_weight, as.integer(alpine)*Alpine, as.integer(sea)*Sea)*ifelse(Placed < Min, 2, 1)) %>%  # lmao this actually works!
  filter(hex_weight > 0)

if(nrow(possible_fills)==0){print('No options!'); next}

Then we do the weighted sample:

if(nrow(possible_fills)>1){
  selected_fill <- sample(possible_fills$fill_id, 1, prob = possible_fills$hex_weight)
} else {
  selected_fill <- possible_fills
}

Finally, we record that the fill was placed by incrementing the 'Placed' counter in the list of hexfills, and we insert the descriptions and necessary info into the dataframe of hexes (note that this is the master hex dataframe, not a subset! Anything placed is canonical.)

# log that the fill was placed
hexfills$Placed[hexfills$fill_id==selected_fill] <- hexfills$Placed[hexfills$fill_id==selected_fill]+1

# record the placement in the map file
hexes[hexes$id == hex$id, 'Subtype'] <- hexfills[hexfills$fill_id==selected_fill, 'Subtype']
hexes[hexes$id == hex$id, 'Assigned'] <- hexfills[hexfills$fill_id==selected_fill, 'Name']
hexes[hexes$id == hex$id, 'desc'] <- r_gen_eval(hexfills[hexfills$fill_id==selected_fill, 'Desc_prompt'])
hexes[hexes$id == hex$id, 'encounter'] <- r_gen_eval(hexfills[hexfills$fill_id==selected_fill, 'Enc_contrib'])
hexes[hexes$id == hex$id, 'Name'] <- r_gen_eval(hexfills[hexfills$fill_id==selected_fill, 'Name_prompt'])

Then repeat, checking for a hidden features and filling in the appropriate respective entries. When the script finishes, every hex that rolled a feature in part 6b should have an entry.

The Map

I wrote a script to auto-generate a prefecture map using the tmap package in R. It has limited aesthetic range & seems to be a sort of cartographic fig leaf for data scientists, but it's adequate for utilitarian GM-facing maps.2

Priority #1 is to be able to reference hex ID numbers, everything else is secondary. I ended up symbolizing biomes, roads, hillshade, neighboring prefecture IDs, and lairs/settlements/dungeons without it feeling too cluttered.

library(tmap)
library(ragg)
tmap_mode("plot")
#tmap_style('watercolor')

ragg::agg_png(filename = file.path(mapdir, 'Regional_maps', paste0(Region_aoi, '.png')), width=7, height = 9, units = 'in', res=300, scaling=0.45)
tm_shape(sf::st_as_sf(area_hexes)) +
  tm_polygons('Biome_name', palette = c(
    "Water" = "#11467b",
    "Savannah" = "darkolivegreen1", 
    "Urban" = "#86909a", 
    "Jungle" = "olivedrab",
    "Tundra" = "#7fbee9",
    "Desert" = "#df5454",
    "Chaparral" = 'goldenrod',
    "Citadel" = "black",
    "Steppe" = 'palegoldenrod',
    "Forest (temperate)" = 'forestgreen',
    "Taiga" = 'darkgreen',               
    "Conifers (temperate)" = 'darkseagreen4',
    "Xenoformed" = "#7b1072")) +
  tm_shape(sf::st_as_sf(marked_hexes) %>% sf::st_cast('LINESTRING')) + tm_lines('Hexfill', lwd = 2, palette = c("Lair" = 'red', "Dungeon" = 'black', "Settlement" = 'cyan')) +
  tm_shape(hillshade) + tm_raster(palette = gray(0:100 / 100), n = 100, legend.show = FALSE, alpha = 0.5) +
  tm_shape(sf::st_as_sf(water)) + tm_polygons(col='#11467b') +
  tm_shape(sf::st_as_sf(rivers)) + tm_lines(col='#11467b', lwd=5) +
  tm_shape(sf::st_as_sf(roads)) + tm_lines(col='tan4', lwd=4) +
  tm_shape(sf::st_as_sf(rail)) + tm_lines(col='black', lwd=2) +
  tm_shape(sf::st_as_sf(prefec_subset %>% filter(Biome_majority>0))) + tm_text('rowcol', size='AREA') +
  tm_shape(sf::st_as_sf(surrounding_hexes)) + tm_fill('black', alpha = 0.5) + 
  tm_shape(sf::st_as_sf(neighbor_prefecs) %>% sf::st_cast()) + tm_text('prefecture_id_2', fontface = 'bold', col = 'white', size=2) +
  tm_layout(#main.title = paste('Prefecture', Region_aoi),
            #main.title.position = c('center','TOP'),
            #main.title.fontfamily = 'TT2020 Style B',
            fontfamily = 'Cormorant Infant', 
            saturation = 1,
            legend.frame=T,
            legend.title.fontface = 'bold',
            legend.outside = T,
            legend.outside.position = 'bottom',
            legend.text.size = 1.5,
            legend.title.size = 2)
dev.off()

Generating A Key Document

In the script below I've formatted outputs as macro commands that I can redefine later. Why not use the csvsimple package, as with the bestiary? Annoyingly, it gets weird when fed text containing commas, and I so far haven't found a good solution. Also, I envision wanting to insert extensive notes into a key description, so I'd prefer to be able to edit the text directly in VSCode.

So, I defined three macros: one (\hex) formats the bits of information shared by all hexes, and the others (\hexentry and \texentryhidden) can be used to format sub-entries in a hex so that I can include separate "Landmark", "Hidden", and "Secret" components. (LaTeX by default only allows 9 inputs to a macro, thus the nesting).

\newcommand\hex[6]{
\textbf{#1}:  % hex ID
#2,  % biome
#3 in  % terrain
\textit{#4}   % cultural region
{\headerfont #5}  % name (if any)
{\small #6}  % any other content to put in the hex
}

\newcommand\hexentry[4]{
\textsc{#1}  % hexfill type
(#2):  % hexfill subtype
#3.  % description
\textit{#4}  % encounter contribution
}

\newcommand\hexentryhidden[4]{
{\color{gray!80}
\textsc{(Hidden) #1}  % hexfill type
(#2):  % hexfill subtype
#3.  % description
\textit{#4}  % encounter contribution
}

These can be tinkered with later, but the point is that all the generated info now lives in the key file and can be toggled on/off or manually edited with relative ease. The nested macros also make it easier to restock and insert new hexfills as needed. (I could even make new custom macros for inserting dungeon keys or city pointcrawls).

Anyways, the code to generate the key just loops through the hexes and concatenates the relevant info into a .tex file.

bracketwrap <- function(string, file){
  if(is.na(string)){
    cat('{', file=outfile, append=T)
    cat('}', file=outfile, append=T)
  } else{
    cat('{', file=outfile, append=T)
    cat(string, file=outfile, append=T)
    cat('}', file=outfile, append=T)
  }

}

hexes_df <- hexes %>% as.data.frame() %>% filter(prefecture_id_2 == prefec_id) %>% filter(Biome_majority > 0) %>% arrange(row_index, col_index)

# ASK BEFORE OVERWRITE
if(file.exists(file.path('..', 'Hex_keys', paste0(Region_aoi,'_key.tex')))){
  if(askYesNo(msg='Key already exists. Continue?')){
    outfile <- file.path('..', 'Hex_keys', paste0(Region_aoi,'_key.tex'))
  } else(stop('Already exists.'))
} else{
  outfile <- file.path('..', 'Hex_keys', paste0(Region_aoi,'_key.tex'))
}

cat(paste0('\\chapter{Prefecture ', Region_aoi, '}\n\n\\includegraphics[width=\\textwidth]{Maps/Regional_maps/', Region_aoi ,'.png}\n\n\\clearpage\n\n'),file=outfile,sep="\n")
cat('\\begin{multicols}{2}\n',file=outfile,append = T)

for(i in 1:nrow(hexes_df)){
  print(i)

  cat('\\hex', file=outfile, append=T)
  bracketwrap(hexes_df[i, 'rowcol'], outfile)  # id
  bracketwrap(hexes_df[i, 'Biome_name'], outfile)  # biome
  bracketwrap(paste(hexes_df[i, 'Rugged_class'], hexes_df[i, 'Elev_class']), outfile)  # terrain
  bracketwrap(hexes_df[i, 'Region_Name'], outfile)  # region
  bracketwrap(hexes_df[i, 'Name'], outfile)  # name

  cat('{\n\n', file=outfile, append=T)
  if(!is.na(hexes_df[i, 'Hexfill'])){
    if(hexes_df[i, 'Hexfill']=='Flavor'){
      cat(hexes_df[i, 'desc'], file=outfile, append=T)
    } else{
      cat('\\hexentry', file=outfile, append=T)
      bracketwrap(hexes_df[i, 'Hexfill'], outfile)  # type
      bracketwrap(hexes_df[i, 'Assigned'], outfile)  # subtype
      bracketwrap(hexes_df[i, 'desc'], outfile)  # description
      bracketwrap(hexes_df[i, 'encounter'], outfile)  # encounter
    }
  }

  if(!is.na(hexes_df[i, 'Hexfill'])&!is.na(hexes_df[i, 'Hexfill_hidden'])){cat('\n\n', file=outfile, append=T)}  # if there are two entries, let them breathe lol

  if(!is.na(hexes_df[i, 'Hexfill_hidden'])){
    if(hexes_df[i, 'Hexfill_hidden']=='Flavor'){
      cat(hexes_df[i, 'desc_h'], file=outfile, append=T)
    } else{
      cat('\\hexentryhidden', file=outfile, append=T)
      bracketwrap(hexes_df[i, 'Hexfill_hidden'])  # type
      bracketwrap(hexes_df[i, 'Assigned_h'])  # subtype
      bracketwrap(hexes_df[i, 'desc_h'])  # description
      bracketwrap(hexes_df[i, 'encounter_h'])  # encounter
    }
  }

  cat("}\n",file=outfile,append=T)
  cat("\n",file=outfile,append=T)
}

cat('\\end{multicols}\n',file=outfile,append=T)

Once generated, I have a primary .tex file into which I can insert prefecture keys by using the \input command. This one defines the font, page size, margins, etc.

Font

I've been using the TT2020 typewriter simulacrum by Fredrick R. Brennan for the core Archons & Armigers booklet. It's gimmicky, but I feel like it conveys the DIY, ad-hoc nature of this project & it shares a blotchy texture with my pen-and-ink illustrations. I had hoped to use it here as well, but found that a monospace font is too inefficient and tough to read.

For now, I've opted for the Cormorant family of fonts, because:

It's a bit textbook-y, but perhaps that's a worthy standard to set.

The Results

The following is an honest look at what my code spat out. An archived copy of the key exactly as generated is here.

Prefecture 375 is comprised of 330 10-mile hexes, making it approximately the size of Panama, Sierra Leone, or Czechia, just a bit bigger than Ireland. (Also roughly the same land area as OD&D's Wilderness Survival map, albeit with coarser hexes).

The most common hexfills are:

There's also a 'long tail' of 61 additional types of lairs and 'weird' entries that appear 1-2 times.

It contains one proper Dungeon, name and details TBD.

Known Bugs & Cut Corners

I obviously ignored some details to get to this point -- here's a working list of priority fixes:

Evaluation

Bugs & elisions aside, I've tried to consider the key as a potential Player & GM. Would this be fun to explore? Do I feel like I have enough improvisational cues? Is it readable?

Shortcomings:

What turned out well:

Conclusions

First of all, the code actually worked! I had to rerun several times to work out some bugs, but I was able to use my random generators, map GIS file, and LaTeX template to generate a key that I could print and use. Whatever the shortcomings, I have a start-to-finish process that I can build on.

For a first attempt, I (subjectively) feel like I have ample material to work with -- If given maybe an hour of prep I could imagine running a few good sessions from this key. That feels extremely good.

Moreover, I think the preparatory work of writing my own tables and lists and random generator scripts has started to pay off. I won't pretend that my worldbuilding is especially interesting or original, but it's incredibly satisfying seeing it come together like this -- a kind of satisfaction I haven't gotten from cramming homebrewed content into systems/worlds that aren't my own. Despite the issues listed above, I feel energized by this glimpse at an end product.

Lastly, I'll concede that this is forcing me to reckon with the preposterous scale of this project, initiated more or less on a whim. So much material can be packed into these 330 hexes -- entire campaigns could unfold within the borders of Prefecture 375. However, ttrpg blogging is the one area of my life that I won't allow to be governed by reason & good sense, so onward I go.

I've christened this prefecture 'St. Weirlund's Folly' after the patron of wizardry and architecture in the Icosidyadic pantheon. Folly it might be, but there's a reason The Magician is the first stop on the Fool's journey. For the next entry in this series, I'll spend more time in this Prefecture & write about manually editing the key, constructing the city of Jouissance, and excavating the dungeon in hex 269;269.


  1. Earlier I was realizing that my list of 'petty gods' is too short and will inevitably get repetitive unless I pull another 40-50 entries out of the air. I'm learning that this sort of thing is sensitive to bottlenecks; if not the petty gods it might be the wizard names, or the quirky village traditions, or the subtypes of shipwreck, or the types of gemstone, or the trade goods, or the local cuisines, and so on and so on...

  2. My vision for mapping Antibor is to have these minimalist, user-friendly GM maps in the key, some number of nice-looking artistic maps made in QGIS, and some hand-drawn player-facing maps (sans hexes) giving the general geography of a region.

  3. On the other hand, just last night the White Box campaign I'm playing in had an overland adventure, and despite not tripping over keyed encounters every hex we still had fun... food for thought.

#DIY #GIS #antibor #lore24