The Great Antarctic Hexcrawl Pt. 1 - This Train Is Bound for Idraluna (Planning, Prefectures, and Road Networks)
This post is a submission to the January 2024 blog carnival and, after a fashion, the start of my own version of lore24.
The Vision
A while ago I used real-world data (with some finagling) to make a quasi-realistic map of a post-ice Antarctica with believable biomes. Later, just for the hell of it, I overlaid the whole thing with approx. 128,000 six-mile hexes. (See here for how.)
It would be impossible to stock by hand, but I want to see if I can procedurally fill it with interesting locations. My ultimate goal is to write code that exports a GIS hex map along with a LaTeX or Markdown file containing a key with terse hex info, dungeon outlines, landmarks, and settlement descriptions. Nothing as ambitious as Hex Describe, but vaguely similar. For example, I envision entries something like this:
23456: Forest, hilly lowlands: Misty, aspen, rich soil. Dungeon: Murdicog's Manse (Crashed starship (military), 13 rooms & 3 levels) -- page 225.
I'll freely grant that campaigns that lovingly detail a smaller number of hexes are usually more fun and rewarding than a wide-but shallow world. But I'm mostly doing this for its own sake. Part of the fun here is figuring out how the hell do I make this work?
Here's the hex map I'm starting with:
(This is a hex map, the hexes are just really small!)
Lore Conceits
Antarctica in the year 102,024 is called 'Antibor'. It has a temperate climate due to ancient geo-engineering (the status of the rest of the planet is unknown).
There are three main culture groups on Antibor:
- The Alabastrines (named for the gleaming white starships they arrived on ca. 5,000 years ago) live mainly in crumbling arcologies overseen by demigod-like Archons, but also in regional outposts. The provincial ruling stratum below the Archons is the Exultant class, post-human aristocrats tracing their bloodlines to distant star systems (sorry Gene Wolfe).
- The Aldebarati are nomads tracing their descent from human battle-thralls brought to Irð by alien invaders ca. 2000 years ago. They wander the inland steppes hunting mastodon herds.
- Finally, humans living in Antibor prior to the Alabastrine landing are known as "Autochthonous" (sorry again, Gene). They're culturally diverse and live mainly in remote villages and hidden mountain enclaves.
The key conceit for this project is that cyber-agronomy districts in the citadels produce enough nutrient cubes to feed residents, so the most technologically advanced stratum is concentrated in 'points of light' (much as I dislike that metaphor) in a vast wilderness with minimal pressure to clear farmland or develop the countryside. Thus, a map full of danger and mystery for players to explore.
Bottom-up vs. Top-down
Antibor is an ad hoc creation. I like simulation & procedural generation and have tried to use it where possible, but I've imposed some higher-level lore by fiat. Anything established in my ongoing Worlds Without Number campaign is canonical, and I want to keep the list of region and city names I dreamed up. Accordingly, I'm trying to implement the procedural elements in a way that fills in the gaps between my fiat decisions.
Similarly, I want to leave space for flux: restocking dungeons, re-placing wilderness lairs, adding in more fiat ideas, etc.
Working Backwards
Populating 128,000 hexes introduces competing pressures: making them distinct requires maximal random generation, but the resulting descriptions must be ruthlessly parsimonious to be even remotely useable. So I'm trying to anchor my efforts in a (relatively) clear idea of the end product, starting from what I most need to know when players enter a new hex. I've broken this down into five key 'user points of contact' that any underlying generative process will need to serve: general needs, mapping needs, at-a-glance Hex key info, regional info (more on that below) and detailed info for settlements, dungeons, etc.
General Needs:
- Sparse hexes. Having something in every hex is nice but that won't fly here. Thankfully Antibor is mostly steppe, so it feels thematically appropriate to keep it sparse.
- Corollary: hexcrawling procedures should accommodate non-cumbersome movement through empty hexes & should be generous about providing information on neighboring hexes.
- Regional variation. Players should be rewarded for going from one side of the continent to the other, not met with more of the same.
Mapping Needs:
- Depict everything needed to adjudicate overland travel
- biome, elevation/ruggedness, roads, and water (rivers/lakes/ocean)
- Depict known settlements
- Display Hex ID for matching to the key
- Have a toggleable Ref-facing layer with adventure sites and lairs - providing at least their location and ideally also type.
With GIS, there's a lot of potential for ad hoc mapping as long as I store information about the world in a consistent and machine-readable way (i.e. csv files).
At-a-glance Hex Key Needs:
- Biome and terrain ruggedness (for calculating movement rates)
- Flavor
- I've written a table with ~70 ruinous terrain features intended to create Dying Earth vibes and an ambient sense of Deep Time, but I'll need a lot more.
- I'm thinking about compiling Biome-specific spark-tables.
- A reference to (and possibly quick details of) any points of interest in the Hex. If I use LaTeX for the output, I should be able to assign unique reference codes that will allow me to automatically generate page references.
Regional info needs:
- Info on the region's ruler(s) and capital city
- Encounter tables for each Biome in the region, incorporating local lairs
- Flavor (?) - I'd like to generate some details of the local culture(s), like architecture styles, dress styles, traditions, taboos, leadership structures, technology, etc.
Points of Interest Needs (the Gordian Knot that has so far stymied me at every turn):
- Obviously, friendly settlements should be named, given a population, and maybe some tags or hooks, plus important NPCs (?)
- Dungeons, listed below in order of achievability
- Room count & number of levels
- Simple OD&D-style stocking (table listing whether a room has a monster, monster w/treasure, unguarded treasure, or is empty)
- Dungeon theme or origin or spark tags. I have a good list of lore-appropriate dungeon types going, but this is where I get bogged down with taxonomy. Is a bandit camp a dungeon or a settlement? If there are bandits in a dungeon, does that make it a bandit camp? Stupid quibbles, I know, but it's been muddling up my attempts at clearly listing what generators I need to write.
- A map, maybe pointcrawl-style made using
DiagrammeR
? Or using geomorphs?
- Landmarks
- Cool descriptions needed, but not much else. I envision these mostly as reference points in the necessarily sparse map.
- Friendly/neutral NPC sites
- Temples, wizard's towers, exultant estates, and broadcast brother shrines are the main ones
- Will need number of combat-ready troops, services provided and cost, and some adventure hooks or quests.
- Lairs and hostile NPC sites
- How much to flesh these out? Do I really want a specific random generator for each type of bandit, let alone each type of monster?? Lots to think about here.
- Will at least need # of hostiles and loot
I don't have all the answers yet, but I've decided to move forward with the parts I feel confident about. In this post, I'm going to allocate some sub-regions, place their main population centers, and plot the major road networks connecting them.
Prefectures
When the Alabaster Potentate landed in the 97,000s they imposed rational administration, decreeing that the continent be divided into (roughly) hexagonal prefectures with a center-to-center distance of 180 miles, further subdivided into ~900 hexagonal tracts of six miles. It was ordained that each Prefecture should be given to an Exultant house with the power to settle and lease tracts as they saw fit.
When the Archons were installed after the Epoch of Heresies, these divisions remained largely intact, but strict controls on population growth, travel, and settlement were imposed. This, combined with widespread resource and soil depletion, the invasion of the Swallowed Ones in the 99,000s, tighter galactic sanctions, and perennial Exultant rebellions led to a substantial gulf between the technologically advanced but stagnating Alabastrine culture and the depopulated hinterlands of Antibor.
In terms of procedural generation:
- Antibor gets divided up into 180-mile Prefecture super-hexes, excluding selected Autonomous regions (which are reserved for when I feel like manually placing things or trying out other procedural setups.)
- Each Prefecture gets a name, a ruling Exultant house, and a hex selected as a capital city (unless there is already a citadel within its borders)
- High-speed rail networks will connect the citadels, and roads will connect the prefectural capitals.
Making the Prefectures
GIS Workflow
Prefecture Borders
I used the create grid tool in QGIS to make a 180-mile hex grid (note: some images are from an initial attempt with a 144-mile grid, was too lazy to take new screencaps). Then I use the centroids tool to create a point layer of hex centroids and deleted the hex grid. I used the translate tool to ensure that a point was precisely aligned with the South Pole. Then I used select by location to delete all hexes not on land. (This prevents the creation of tiny prefectures with only like 3 coastal hexes).
Then, I ran the Voronoi Polygons command to create nearest-neighbor regions around each centroid. The Voronoi algorithm creates zones such that if a point is in a given zone, it is closer to the centroid corresponding to that zone than any other centroid. This mostly recreated the old Hexes but allowed coastal hexes to blob onto the shorelines.
I then used R to spatially join the hexes to the prefectures, with the following result:
Placing Capitals
I was initially going to try to calculate some kind of suitability score to place capitals, but eventually decided to just do it randomly. (I also considered using the centroid locations, but that felt a little too inorganic.)
I wrote a short loop in R to check if a prefecture already had a fiat-placed city, and if not place a new one & give it a placeholder name. In this loop, I also assigned a random Exultant house as the overlords. I didn't worry about fairness; after 5000 years they probably changed hands a bunch.
# loop through prefectures
for (prefec in na.omit(unique(hexes$prefecture_id_2))){
print(prefec)
prefec_hexes <- hexes[hexes$prefecture_id_2==prefec,]
# grant to an Exultant house
Prefectures$Ruling_house[Prefectures$prefecture_id_2 == prefec] <- r_gen('Exultant_house')
# place capital
# is there a citadel here?
if (14%in%prefec_hexes$Biome||15%in%prefec_hexes$Biome){
print(paste('Prefecture', prefec, 'has', nrow(prefec_hexes[prefec_hexes$Biome%in%c(14,15)]), 'urban hexes.'))
}else { # place a random capital city and name it randomly
cap_hex <- sample(prefec_hexes$id, 1)
hexes$Biome[hexes$id == cap_hex] <- 14 # assign to urban
hexes$Hex_name[hexes$id == cap_hex] <- paste('Prefecture', prefec, 'capital')
}
# calc habitability
prefec_hexes <- prefec_hexes %>% left_join(Biome_map, by=c('Biome'='Code'))
print(paste(prefec, 'hab score is', mean(prefec_hexes$Hab_score)))
Prefectures$Habitability[Prefectures$prefecture_id_2 == prefec] <- mean(prefec_hexes$Hab_score, na.rm=T)
}
Habitability Score
I used population density estimates by biome (not the best source, but I'm not trying to be scientific) to assign relative habitability scores to each region.
My plan is to use this (and possibly mean terrain roughness index) to weight likelihood of ruins and settlements.
Roads (and rail)
This one was a bit tricky. I want the Antiborian elites to be able to jet from arcology to arcology on elevated maglev trains while the plebians lead horse- merychip-drawn carts through the mud below. For mapping purposes, I envision a three-layer hierarchy:
- Maglev trains connecting the citadel arcologies
- Surface roads leading from the train corridors to the prefecture capitals.
- Dirt trails leading from the prefecture capitals to (some) of the points of interest in the prefecture (modified by development score)
AFAIK there isn't a great way to do hex pathfinding in GIS, so I opted for doing path optimization on a raster, converting the resulting roads to line vector files, and then snapping those lines to hex centers.
The Rail Network
To draw high-speed maglev tracks between the citadel arcologies, I did a least-cost path analysis using the leastcostpath
R package. To make the 'friction surface' for the algorithm to optimize around, I assigned a friction score to each biome (high for jungle & xenoformed, extremely high for water, lower for steppe and savannah) and added that to the terrain ruggedness index. This probably overemphasized the effect of terrain (especially for a space-age civilization like the Alabaster Potentate) but I really wanted terrain to matter and stand out.
After A LOT of trial and error the algorithm ended up producing a pretty nice map. The black lines are the generated routes, overlaid against a map of pathfinding priority (higher values are easier to traverse).
I manually pruned it to remove redundant routes:
Prefectural roads
With the main rail lines in place, I still had a bunch of minor cities to link up. I discovered the roads
package for R, which seems to be designed for planning optimized forestry roads but essentially fits my use case of 'connect a bunch of random points to an existing network as efficiently as possible'.
I placed roads using the 'dynamic least cost paths' algorithm which incorporates newly generated roads into future friction calculations, cutting down on redundant and parallel pathing. This resulted in a branching network toward the perpiphery. I actually like this -- it will hopefully induce players to do more off-roading, and it makes it easier to name major roads. It also subtly emphasizes the frozen, stagnant incuriosity of the Alabastrines, for whom the only routes that matter are those to the comfort and safety of the nearest Citadel.
Here's the code I used to make these:
######################## leastcost path ----
hexes <- vect(file.path(mapdir, 'Full_hexes.gpkg')) %>% distinct()
r <- rast(TRI)
water <- vect(file.path(mapdir, 'land_water.gpkg')) %>% filter(land==0)
friction_reclass <- data.frame(
'Is' = Biome_map$Code,
'Becomes' = Biome_map$Friction
)
### create a rail friction raster based on TRI and biome
rail_friction <- (1/(rasterize(hexes, r, field='Biome') %>% classify(friction_reclass) + (TRI))) %>% trim()
rail_friction[is.na(rail_friction)] <- 0.0001
rail_friction <- aggregate(rail_friction, fact=9)
plot(rail_friction)
rail_cs <- create_cs(rail_friction)
plot(rail_cs)
destination_hexes <- hexes %>% filter(!is.na(prefecture_id_2), Biome%in%c(14,15)) %>% mutate(burn=1)
dest_pts <- destination_hexes %>% centroids()
citadel_pts <- dest_pts %>% filter(Biome==15) %>% rowwise() %>%
mutate(citadel_name = strsplit(Hex_name, " ")[[1]][1]) %>% ungroup() %>%
group_by(citadel_name) %>%
slice_head(n=1) # citadels are multi-hex, but we'll stick with only one transit hub per.
plot(rail_cs); plot(citadel_pts, add=T)
rail_network <- create_FETE_lcps(rail_cs, locations=citadel_pts)
plot(rail_network, add=T)
## Prune connections:
rail_network_pruned <- rail_network %>% rowwise() %>% mutate(connection = paste0(min(origin_ID, destination_ID), max(origin_ID, destination_ID))) %>%
ungroup() %>% group_by(connection) %>% slice_head(n=1) %>% ungroup() %>%
filter(!connection%in%c('26', '14', '47', '27', '56', '12', '13', '37', '36', '38', '35', '46', '78'))
plot(rail_cs); plot(rail_network_pruned, add=T); plot(citadel_pts, add = T)
text(citadel_pts, col='red', cex=3)
### Ok... it's a start.
writeVector(rail_network_pruned, file.path(mapdir, 'Rail_nework.gpkg'), overwrite=TRUE)
## PRUNE AFTERWARDS
######################## roads package -----
# now we branch provincial capital roads off the rail network.
rail_network <- vect(file.path(mapdir, 'Rail_nework.gpkg')) %>% mutate(burn=1)
destination_hexes <- hexes %>% filter(!is.na(prefecture_id_2), Biome%in%c(14,15)) %>% mutate(burn=1)
dest_pts <- destination_hexes %>% centroids()
landings <- dest_pts
friction_reclass <- data.frame(
'Is' = Biome_map$Code,
'Becomes' = Biome_map$Friction
)
trail_friction <- ((rasterize(hexes, TRI, field='Biome') %>% classify(friction_reclass) + (TRI))) %>% trim() ## I think it goes the other way for this package????
trail_friction[is.na(trail_friction)] <- 2000
plot(trail_friction); plot(landings, add = T); plot(rail_network, add = T)
roads <- projectRoads(landings=sf::st_as_sf(landings), cost=trail_friction, roads=sf::st_as_sf(rail_network), roadMethod='dlcp', roadsOut = 'sf', roadsInCost = FALSE)
plot(trail_friction)
plot(roads$roads, add=T, col='red')
plot(landings, add=T)
road_vect <- vect(roads$roads)
writeVector(road_vect, file.path(mapdir, 'Prefec_roads.gpkg'))
Prettification
Currently, the rail and road networks exist as lines created by 'connecting the dots' of raster cells selected by the pathing algorithms. So, they don't align perfectly with the hexes.
I ended up buffering the roads to filter hex centroids within 3 miles of the road, then used densify and snap to geometry to snap the road geometries to the hex centers. I'm pretty sure this resulted in some weird geometries and some sections are overly zig-zaggy but it got the job done insofar as the next hex along a road is unambiguous.
Similarly, for the rivers I had generated when I did the biomes (blue in the screencaps above), I wanted them to run along hex edges so that they'd serve as a clear obstacle when moving from hex to hex (you're either on one side of the river or the other). I found this QGIS Python script on Stack Exchange to snap lines to the edges of a hex. Unfortunately, it was for an older version of the QGIS Python API and was maddeningly difficult to get working in QGIS. But I managed to rewrite it in R to get much the same effect.
### Network snapping
river_hexes <- vect(file.path(mapdir, 'River_hexes.shp'))
geom(river_hexes[1,])
nrow(river_hexes)
test_cent <- centroids(river_hexes[1,])
# for the record, I do know how to spell 'triangle'
test_trangle <- rbind(geom(river_hexes[1,])[1:2,], geom(test_cent))
test_trangle <- vect(test_trangle, crs = crs(river_hexes), type='polygons')
segment_list <- vect(geom(river_hexes[1,])[1:2,], crs=crs(river_hexes), type='lines')
for(i in 1:nrow(river_hexes)){
print(paste('Hex', i, 'of', nrow(river_hexes)))
hex <- river_hexes[i,]
centroid <- centroids(hex)
for (j in 1:6){
cat(j)
trangle <- vect( # make triangle
rbind(geom(hex)[j,], geom(hex)[j+1,], geom(centroid)),
crs = crs(hex),
type = 'polygons'
)
cat('...')
#plot(trangle, add=T)
# test if the river overlaps this polygon
if(!is.empty(intersect(Rivers, trangle))){
print('edge_detected')
edge <- vect( # make triangle
rbind(geom(hex)[j,], geom(hex)[j+1,]),
crs = crs(hex),
type = 'lines'
)
segment_list <- rbind(segment_list, edge)
}
}
}
plot(segment_list)
# Remove dangling segments (if one vertex has only one segment and the other has three)
segment_list_pruned <- segment_list
for (i in 1:nrow(segment_list)){
print(paste('Segment', i, 'of', nrow(segment_list)))
seg <- segment_list[i,]
v1 <- geom(seg)[1,]
v2 <- geom(seg)[2,]
# proune out duplicates
if (i < nrow(segment_list)){
for (j in (i+1):nrow(segment_list)){
seg_to_check <- segment_list[j,]
v1b <- geom(seg_to_check)[1,]
v2b <- geom(seg_to_check)[2,]
if((sum(v1==v1b)==5&&sum(v2==v2b)==5)||(sum(v1==v2b)==5&&sum(v2==v1b)==5)){
print('Removing duplicate edge.')
# remove duplicate rom segment list and segment list pruned
segment_list_pruned <- segment_list_pruned %>% filter(id!=seg_to_check$id)
segment_list <- segment_list %>% filter(id!=seg_to_check$id)
cat(paste('...new total:', nrow(segment_list)))
break # don't loop all the way to the end, as there are only two hexes sharing an edge, so at most one extra segment
}
}
}
# for each vertex, we grab the list of all other segments that incorporate that vertex
v1_connections <- geom(segment_list)[geom(segment_list)[,'x']==v1['x']&geom(segment_list)[,'y']==v1['y'],]
v2_connections <- geom(segment_list)[geom(segment_list)[,'x']==v2['x']&geom(segment_list)[,'y']==v2['y'],]
if('numeric'%in%class(v1_connections)){v1_connections <- t(as.matrix(v1_connections))}
if('numeric'%in%class(v2_connections)){v2_connections <- t(as.matrix(v2_connections))}
if (nrow(v1_connections) == 3 & nrow(v2_connections) == 1 || nrow(v1_connections) == 1 & nrow(v2_connections) == 3){
# remove
print('dangler')
segment_list_pruned <- segment_list_pruned %>% filter(id!=seg$id)
}
}
writeVector(segment_list_pruned, file.path(mapdir, "rivers_snapped_pruned.gpkg"), overwrite=T)
Basically, the algorithm divides each hex into six triangular wedges. Each wedge gets checked for intersections with a river. If there is an intersection, the face of the hex corresponding to that wedge gets appended to the output.
This creates some dangling edges that stick off at a y-intersection, like this:
To get rid of them, the code loops through each segment. It checks for a) another identical segment overlapping it, removing it if found, and b) if the segment meets the condition that one vertex only belongs to one segment and the other vertex belongs to 3, removing it if true. Note: it runs extremely slow, there's definitely a better way to do this (though I had weird issues getting some of the built-in terra geometry equivalence functions to work), but thankfully I'm not trying to automate multiple Antibors at a time, so I'm willing to just let my brute-force solution run overnight.
Here's what the output looks like:
That's where I'll stop this post for now; lots remains to be added, but at least I have regular sub-regions and major roads to work with.
The full map, as it stands currently:
Remaining Tasks
- [x] Divide Antibor into manageable chunks, place regional cities and roads.
- [x] Snap roads and rivers to hex geometry.
- [ ] 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 (?????)
Discuss this post on Reddit