Idraluna Archives

Making a Classic Traveller Sector Map in QGIS

I've been having some fun using the rules in 1977 Traveller to make a big sector map in QGIS. Credit/blame goes to Unturned Hovel getting me interested in Classic Traveller.

One important clarification: this is just an effort to use GIS to render a map & automate the procedural tools in CT book 3. It should not be mistaken for actual campaign prep, though the end result is a map & list of UPPs that would work as a starting point.

1. Setup & World Occurrence

Each hex represents 1 parsec or 3.26 light years, so I used the Create Grid tool in QGIS to make a hex grid of 3.26 km cells. I used the selection tools to truncate it down to an 80x80 region. This is TOO BIG but I'm rolling with it since this is more of a tech proof-of-concept than actual campaign prep.1

To auto-partition the map into subsectors, I ran the following expression in the field calculator, generating a new attribute for each hex that stores its subsector ID. I then set up a new layer with the Subsector field displayed as semi-transparent polygons (later restyled, see below):

concat(  
floor("col_index" /8),  
'.',  
floor("row_index" /10)  
)  

The base chance of a world being present in 1977 Traveller is 50%, or 4-6 on a d6. There's also a note that selected subsectors or regions can be rolled with a +1 or -1 modifier to increase or decrease density respectively.

I used the lasso select tool to assign amorphous blobs of more or less dense space, via a field called DensityMod to which I applied values of +1, -1, or 0.

Next, to roll to see if a world is present I made a boolean field called World and assigned a value in field calculator as follows:

if(rand(1,6) + "DensityMod" >= 4, True, False)  

The resulting map is surprisingly dense! But there are still slightly more empty hexes (3309) than ones with inhabited worlds (3091). When I start fleshing out planets, I'll zero in on one of the less dense sectors.

image

2. Starport Type & 3. Route Determination

The instructions in the book say to start by generating a spaceport quality rating for each system.

Once spaceports are generated, we're supposed to go system by system, checking for a connection to all neighboring systems based on spaceport quality.

At this point, I pulled up R and got really carried away automating the instructions for generating connections between spaceports. However, this could also be done in QGIS by rolling for each connection and then manually digitizing a line layer.

The gist of the script is as follows:

library(terra)  
library(tidyverse)  
library(tidyterra)  
  
### Load sectormap  
Sector.shp <- vect("TravSector.gpkg")  
  
if (!"Spaceport" %in% names(Sector.shp)){  
  spaceports.df <- data.frame(  
    "Spaceport_roll" = 2:12,  
    "Spaceport" = c("A", "A", "A", "B", "B", "C", "C", "D", "E", "E", "X")  
  )  
   
  Sector.shp <- Sector.shp |>  
    rowwise() |>  
    mutate(Spaceport_roll = ifelse(World, sample(1:6, 1) + sample(1:6, 1), NA)) |>  
    ungroup() |>  
    left_join(spaceports.df) |>  
    select(!Spaceport_roll)  
   
  writeVector(Sector.shp, "TravSector.gpkg", overwrite = T)  
}  
  
Sector.shp <- vect("TravSector.gpkg")  
  
Worlds.shp <- Sector.shp |> filter(World) |> centroids() |> mutate(Worldnum = row_number())  
  
  
worldDists <- terra::distance(Worlds.shp, unit = "km", names = "id") |>  
  as.matrix()  
  
worldDists[worldDists > 14] <- NA  # eliminate connections longer than 4 parsecs (13.04 light years or km)  
worldDists[lower.tri(worldDists, diag = T)] <- NA  # eliminate redundant pairs, including with self; "Each specific pair of worlds should be examined for jump routs only once."  
  
  
worldDists <- worldDists |>  
  as.data.frame() |>  
  tibble::rownames_to_column("from") |>  
  pivot_longer(cols = -from, names_to = "to", values_to = "dist", values_drop_na = T)  
  
alphabetize <- function(instr){  
  letters <- strsplit(instr, "")[[1]]  
  sorted_letters <- sort(letters)  
  return(paste0(sort(letters), collapse = ""))  
}  
  
df1 <- data.frame(  
  "from" = Worlds.shp$id,  
  "fromSpaceport" = Worlds.shp$Spaceport  
)  
  
df2 <- data.frame(  
  "to" = Worlds.shp$id,  
  "toSpaceport" = Worlds.shp$Spaceport  
)  
  
  
jumptable.df <- data.frame(  
  "Connection" = c("AA", "AB", "AC", "AD", "AE", "BB", "BC", "BD", "BE", "CC", "CD", "CE", "DD", "DE", "EE"),  
  "Dist1" = c(1, 1, 1, 1, 2, 1, 2, 3, 4, 3, 4, 4, 4, 5, 6),  
  "Dist2" = c(2, 3, 4, 5, 9, 3, 4, 6, 9, 6, 9, 9, 9, 9, 9),  
  "Dist3" = c(4, 4, 6, 9, 9, 4, 6, 9, 9, 9, 9, 9, 9, 9, 9),  
  "Dist4" = c(5, 5, 9, 9, 9, 6, 9, 9, 9, 9, 9, 9, 9, 9, 9)  
) |> pivot_longer(cols = starts_with("Dist"), names_to = "Hexdist", names_prefix = "Dist", values_to = "d6Target") |>  
  filter(d6Target < 9) |>  
  mutate(Hexdist = as.numeric(Hexdist))  
  
  
jumpConnections <- worldDists |>  
  mutate(from = as.numeric(from), to = as.numeric(to)) |>  
  left_join(df1) |>  
  left_join(df2) |>  
  filter(fromSpaceport != 'X', toSpaceport != 'X') |>  # X denotes no spaceport  
  rowwise() |>  
  mutate(Connection = alphabetize(paste0(fromSpaceport, toSpaceport)),  
         Hexdist = round(dist/3.26, 0)) |>  
  ungroup() |>  
  inner_join(jumptable.df) |>  
  rowwise() |>  
  mutate(Route = ifelse(sample(1:6, 1) >= d6Target, T, F)) |> filter(Route)  
  
  
Routes.shp <- list()  
  
for (i in 1:nrow(jumpConnections)){  
  cat(paste0(i, "..."))  
   
  cnx = jumpConnections[i,]  
  
  p <- Worlds.shp |> filter(id %in% c(cnx$from, cnx$to))  
  l <- as.lines(p)  
  l$s1 <- cnx$from  
  l$s2 <- cnx$to  
  l$dist <- cnx$Hexdist  
  l$type <- cnx$Connection  
   
  # plot(as.lines(p), add = T, col = 'red')  
   
  Routes.shp[i] <- l  
  
   
}  
  
Routes.shp <- do.call(rbind, Routes.shp)  
plot(Routes.shp)  
  
  
writeVector(Routes.shp, "TravRoutes.gpkg")  
  

The result looks extremely tangled when zoomed out, but afaik is as intended. I think it's neat that there are visible voids, islands, and some clusters linked by chokepoints.

World Creation

Classic Traveller requires one to roll for planet size, atmosphere, hydrosphere, population, government, law, & tech.

I used the QGIS field calculator to generate these.

Planet sizes:

rand(1,6) + rand(1,6) -2  

Planetary atmosphere:

if("Size" = '0', '0', min(max(rand(1, 6) + rand(1, 6) + "Size" - 7, 0), 12))  

Hydrographic percentage:

if("Atmosphere" = 0 or "Atmosphere" = 1 or "Atmosphere" > 9,  
min(max(0, rand(1,6) + rand(1,6) + "Size" - 11), 10),  
min(max(0, rand(1,6) + rand(1,6) + "Size" - 7), 10)  
)  

Population:

rand(1,6) + rand(1,6) - 2  

Government:

max(min(13, rand(1,6) + rand(1,6) - 7 + "Population"), 0)  

Law:

max(min(9, rand(1,6) + rand(1,6) - 7 + "Government"), 0)  

Tech index gets this convoluted mess:

max(rand(1, 6) +  
if("Spaceport" = 'A', 6,  
if("Spaceport" = 'B', 4,  
if("Spaceport" = 'C', 2,  
if("Spaceport" = 'X', -4, 0)))) +  
if("Size" = 0 or "Size" = 1, 2,  
if("Size" = 2 or "Size" = 3 or "Size" = 4, 1, 0)) +  
if("Atmopshere" < 3 or "Atmosphere" > 9, 1, 0) +  
if("HydroPercent" = 9, 1, if("HydroPercent" = 10, 2, 0)) +  
if("Population" < 6, 1,  
if("Population" = 9, 2,  
if("Population" = 10, 4, 0))) +  
if("Government" = 0 or "Government" = 5, 1,  
if("Government" = 13, -2, 0)), 0)  

Styling The Map

After generating routes & worlds, I tried to make a nice-looking map.

Star Size & Color

I made a new attribute called StarHue with a random value rolled on 2d6 and then assigned a color ramp from red to white to blue. Afaik later versions of Traveller have a more detailed way to generate star color, but for this I'm not interested in getting that crunchy -- this is just to add visual interest to the map.

To have stars display at slightly different sizes, I set the Size parameter of the Simple Marker symbol to an expression: 1.5 + randf(0, 0.5) + randf(0,0.02,"fid") * ("StarHue"-7)^2

The minimum symbol size is 1.5mm, and there's a baseline variation of half a millimeter. Stars that are very red or blue have some additional variation to account for giants and supergiants.

Subsector Outlines

I made a copy of the base hex data layer and set it to render Subsector ID as Merged Features with a shapeburst fill to get neat glowing outlines:

Names & UPP

Currently, worlds are set to appear with their name printed over a semi-transparent background. It's a bit cluttered, but seems workable so far.

To render the UPP beneath a system's name, I had to use this ugly expression (tragically, QGIS has no to_hex function for hexadecimal notation):

concat(  
"Spaceport",  
'-',  
if("Size" = 10, 'A', "Size"),  
if("Atmosphere" = 10 , 'A' , if("Atmosphere" = 11, 'B', if("Atmosphere" = 12, 'C', "Atmosphere"))),  
if("HydroPercent" = 10, 'A', "HydroPercent"),  
if("Population" = 10, 'A', "Population"),  
if("Government" = 10 , 'A' , if("Government" = 11, 'B', if("Government" = 12, 'C', if("Government" = 13, 'D', "Government")))),  
"Law",  
'-',  
"TechIndex"  
)  

Here's a zoomed in shot of an area where I named most of the worlds:

Now, of course, the real fun begins... writing up descriptions of all these beautiful planets!

  1. In any case, overdoing the stuff generated by mechanical procedures seems fine, since the essence of prep seems to be interpreting what gets generated, necessarily small-scale.

#DIY #GIS #Traveller #scifi