Idraluna Archives

A Tutorial for Making Hexcrawl Maps in QGIS

Goal: create a basic fantasy hex-map for use in role-playing games.

Why QGIS, and not something like Hexographer or Hexkit? In short, QGIS has:

What you'll need:

For this tutorial I assume no prior knowledge of GIS, but do assume basic computer literacy. If things are unclear, leave a comment and I'll try to edit this post and elaborate as needed. In any case, QGIS has good documentation and many other tutorials, so googling unclear features or concepts should yield helpful results.

Abbreviated summary for people with GIS experience

Caveat emptor

I'm better at making maps than I am at DMing, though I've done my fair share of both. No map will suffice if the (imaginary) territory it represents is shoddy and un-inspired. The goal of this tutorial is to introduce tools and spatial data that might be useful for making hexcrawl maps, not to imply that this is the best way to design a hexcrawl sandbox campaign.

For actually planning adventures applying the methods presented here, here are three sites I have found useful (but there are hundreds out there):

Select your parameters

The first thing you will need to decide is your desired map and hex size. 6-mile hexes seem to be the most popular, though a larger regional map might use 20, 24, or 30-mile hexes. Different sizes have different merits and realism levels. See here and here for some considerations.

We'll need to consider computation time and map projections. Trying to cover a whole continent in six mile hexes is going to cause QGIS to run slowly and will introduce inconsistent hex sizes due to projection distortion.

It seems like most hexcrawl maps vary from 10x10 hexes (60x60 miles) to 50x50 (300x300 miles). For reference, the former is about the size of Puerto Rico or Cyprus, and the latter comparable to Romania, Uganda, or the UK.

Next, you'll need to decide if you want to base your map off of real-world elevation data or if you want to wing it. Both are fine, but having real elevation data opens up lots of options for making your map look beautiful at the end. There are many ways to keep your map from looking like a copy of a real location.

For this tutorial, I will be making a hex map based off of the the Eastern Caucasus around Azerbaijan since it has a nice mix of mountains and lowlands, borders a large water body, and has a distinctive peninsula.

Defining the territory

Let's begin by opening a new session of QGIS. On the welcome page, click 'New Empty Project'.

Right now, we are faced with a blank canvas - QGIS has no data to display. To orient ourselves, let’s grab a basemap or two. In the Browser pane, right click on XYZ tiles and select New Connection.

In the dialog that pops up, enter the name ‘ESRI World Imagery’ and copy the following url, leaving all other options as they are:

https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/%7bz%7d/%7by%7d/%7bx%7d

Press OK, and then repeat the process, this time adding ‘Open Street Map’ and the following url:

https://tile.openstreetmap.org/{z}/{x}/{y}.png

Now, you should see the new layers appear in the browser pane. You can drag them from that pane to the map canvas, and should see something like this:

Use the mouse scroll wheel to zoom into your area of interest, in our case Azerbaijan. Using the measurement tool, we can see that our region is about 160 miles across — perfect for a large but manageable hexmap.

Even if you are committed to drawing out your own landmasses (described below), I recommend doing this initial step in order to calibrate the size of your map and hexes.

Creating the grid

Use the zoom and pan tools to align your map window to your desired map area. When you are happy with where it’s at, to to the search bar atop the Processing Toolbox pane and search for ‘grid’. Under Vector Creation, you should see Create Grid. Double-click on it.

A large Create Grid dialog should appear. For Grid type, select ‘Hexagon (polygon)’. For Grid extent, click the ‘…’ button at the end of the input box and select Use map canvas extent. For Horizontal and Vertical spacing enter 6.0 and change the units to miles (if you create six meter hexes QGIS will almost certainly crash). Ignore the next few parameters, and where it says ‘[Create temporary layer]’, click the ‘…’ and select Save to file. Navigate to a directory of your choice and save your layer as a .gpkg file called something like ‘Tutorial_hex_grid’. Then click run.

You should see something like the following:

To see the terrain under it, right click your new layer in the Layers pane and select Properties. Navigate to the Symbology tab. You should see something like this:

Some notes:

  1. Currently, the hexagon layer is being rendering each individual hex exactly the same, hence 'single symbol' - one symbol for all polygons.

  2. 'Fill' means that the hexes are rendered by displaying something in the area covered by the hex.

  3. 'Simple fill' means that the thing displayed in the hex's area is a simple field of one color.

  4. This area has some options for other ways you could display the hexes. Feel free to try a few out to see what they do.

For now, select 'outline green'. You should see 'simple fill' change to 'simple line'. Click on it and knock the Stroke width parameter down to 0.15 millimeters. Click Apply and OK.

Now, take a look at your map. If you aren't happy with the hex spacing, now is the time to try different sizes or a new location.

Terrain and water

Now that we have some hexagons, we need to differentiate them. The method in this section will use real-world elevation data to assign some hexes as water, others as land, and others as impassable mountains. If you are free-wheeling it or don't want to deal with elevation data, skip ahead to the next section and use the method there to assign land and water.

To get terrain data, go to USGS Earth Explorer you will need to create a free account to download data. Navigate the map to your area of interest, and press the blue 'Use Map' button to set the imagery search boundary to your map window. Then, go to the data sets, expand the Digital Elevation category, expand the SRTM category, and select SRTM 1-Arc-Second Global. This is a global elevation dataset with roughly 30 meter resolution, plenty for our purposes. Press Results.

You should see a list of images appear. You can click the footprint and image buttons to see where each image falls on the map. Depending on how the file footprints line up, you might want to redefine your area of interest. Now use the download button to download the tiles. Any format is fine - I chose BIL. This process is tedious, so if you have a better elevation source for your region of choice, use it. If not, USGS Earth Explorer has you covered.

When the files are downloaded, add them to your QGIS canvas. We need to stitch them together into one file, so go back to the Processing Toolbox and search for ‘merge’. You should see it under GDAL → Raster miscellaneous. Double-click it. Under Input Layers, select all the elevation tiles but NOT the open street map or imagery layers you added. Make sure to click Ok and not the blue back arrow to confirm your choices.

Change Output data type to ‘Int32’.2 When selecting an output destination, it should default to a .tif file, which is fine. Save it as something like ‘Tutorial_elevation_mosaic' and press Run.

After a moment, your merged layer should appear, sans the seams between images seen before. Remove the old image tiles — you won’t need them anymore.

So what’s next? Our elevation layer gives us lots of cool options, some of which we’ll return to at the end of this tutorial. For now, we’ll focus on creating layers to clip out water and impassable mountains from our map.

First, let’s visualize the height differences we are working with. Right click on the elevation mosaic and select Properties and navigate again to Symbology. The panel looks different because this file is a raster, meaning data is stored as pixels rather than as list of geometric coordinates.

We want to find everything at or below sea level, and get a sense of where our mountains are. So, change Render type to ‘singleband pseudocolor’. Change Interpolation to ‘discrete’ and change Classes to four.

We can see that our elevations range from -45 meters to 4,079 meters above sea level. If you click apply, you should see that your elevation file now displays three colors corresponding to the ranges given in the symbology window. Change the numbers in the Value <= column to adjust what pixels are assigned to what colors.

Azerbaijan borders the Caspian Sea, which apparently has a water level somewhat below sea level. I kind of like the large bay created by placing the cutoff at zero, so I’m going to keep it there. The next two cutoffs help us locate impassable mountain peaks and hills. I ended up with the following:

Click Apply and Ok.

Now, we need to actually encode these values in a new layer. Search the toolbox for Reclassify by Table. In the reclassify window, press the ‘…’ after Reclassification table, and fill it out according to the cutoff values you settled on in the symbology pane.

Press Ok, leave the other settings as-is, and save the output as ‘Tutorial_elevation_reclass’.

This looks good, but due to the weirdness with the Caspian water level, some of the coastline is quite messy and pixelated. I want to smooth it out, so I’m going to use the r.neighbors tool to reassign each pixel to the majority pixel value of its neighbors. A size 9 window seems to work well.

Now, we need to use this to modify the grid cells we made. There are two approaches here and I’ll demonstrate both. For impassable mountains and water, we’ll create polygons that can be used to overlay the hexagon layers. Then, we’ll figure out whether the majority of each hex is hills or plains and assign those as hex attribute values.

To overlay the hexagons, we’ll turn the smoothed elevation categories into a vector file. Pull up the Polygonize (raster to vector) tool. Fill it out as follows:

Now, we can use this layer to mask out non-traversable area, providing a sharp definition between land, mountains, and water. Open the symbology panel for the new layer. Instead of ‘single symbol’, select ‘categorized’. Under Value, select ‘Elev_class’ and hit Classify. For now, let’s not display plains and hills (see subsequent steps) and instead display water as blue and mountains as grey. Double-click the square color swatch next to the value 1. Click Simple fill and change the color to a pleasing blue. Click Ok.

Repeat for value 4, but with a mid-grey. For values 2 and 3, change the Fill style and Stroke style to ‘No brush’ and ‘No pen’. This tells QGIS not to render polygons with values 2 or 3 (plains or hills).

Your symbology window should look like this (note that I manually changed the legend entries - you can and should too):

And here is the layer displayed over the hex map:

We have a nice demarcation of traversable and impassable terrain (and if your players hop on a boat, just move the hex grip up in the layer order). If that isn’t to your taste (i.e. you’d rather coastlines follow hex borders exactly), read on.

Now, lets symbolize hexes according to whether they are plains or hills.

Right-click the hex grid layer and select Open Attribute Table. A new window with a table should appear. Each hex in our grid is attached to a row in a spreadsheet. If you click on one of the rows of the table, you should see the corresponding hex highlighted on the map.

The columns in this table are called attributes and are used to store information of one type. We want to add a new column in the table that records whether a hex is hills or flatlands. We can get this information from our elevation layer and summarize it within each hex using something called zonal statistics.

Open the Zonal Statistics tool. Specify the hex grid as the Input layer and specify the elevation mosaic as the Raster layer. Set the output column prefix to ‘elev_’. Click the ‘…’ next to Statistics to calculate and select Mean and St dev.

Click Run. We have told QGIS to calculate the mean and standard deviation of the elevation pixels within each six-mile hex and assign these to new attributes.

Select the new layer and open the attribute table. You should see two new columns.

You can now remove the old hex layer.

Now, we want to use this information to divide hexes into ‘hill’ and ‘plains’ categories. I chose to use the elevation standard deviation, reasoning that hexes with more variation in height values should be hillier than hexes with less. By opening the symbology pane, setting it to Graduated symbology and selecting elev_stdev, then clicking Histogram, you can view a histogram of the hexes.

We can see that most hexes are quite flat, with fewer hilly hexes and a handful of hexes with extreme slopes. Like with the elevation cutoffs, you can play with where you want the hill/not-hill cutoff to be. I settled on 40 meters of elevation standard deviation.

Open the attribute table for the hex layer. Click the abacus icon along the top to open the Field Calculator. Check Create a new field. Name it ‘Terrain’. Make sure the type is Text (string) and set the length to something greater than 10 characters. In the Expression box, enter the following:

if("elev_stdev" < 40, 'Plains', 'Hill')

In plain English, this says: ‘If the value of elev_stdev for a hex is less than 40 meters, assign the value ‘Plains’ to the ‘Terrain’ column for that hex. If not, assign the value ‘Hill’’.

Click Ok, and return to the attribute table to make sure the values were assigned correctly. If they were, click the Save edits button:

Now, you can visualize these by using Categorized symbology.

I want to have hills display as an icon in the middle of each hex so that I can use color to denote biome. So, I changed the simple fill symbology to centroid fill which places an icon at the centroid (geometric center) of each hex. QGIS comes with a pretty decent hill symbol under the SVG Marker category. To have the symbols stay the same size relative to each hex, I set the width and height of the symbol to be 10,000 meters at scale.

The result is pictured below. Not perfect, but perfectly acceptable for a hex crawl.

Assigning more hex attributes

The beauty of GIS data is that you can attach a basically unlimited amount of information to each hex. For this tutorial, we will mark out territory for some factions and also place some forested hexes.

Adding states/factions

Right click on the hex grid and return to the attribute table. First, put the hex grid layer into edit mode:

Click the Calculate Field button:

You should see the field calculator window pop up. Enter 'controller' as the field name, and make sure the type is set to Text (string). In the Expression, type in 'Wilderness' (Note the single quotes! This distinguishes text strings from references to attributes/columns). This tells QGIS to add 'Wilderness' as the default value for the 'controller' field for each hex. Click Ok.

Now, move the hex grid to the top of the layer stack, or uncheck the layers above it. Click on the Select by Freehand tool, and make sure the hex grid is selected in the Layers panel. Use the lasso selection to select a blob of hexes belonging to the first faction you want to assign. Then re-open the attribute table. Along the top, you should see a selection box for a field and a text box. Select 'controller' and type in the name of your first faction. Then click Update selected. This will update all of the selected hexes with the value in the box.

Repeat this process until as much of the territory is controlled as you’d like. Save edits frequently. I placed some human duchies around the bay and peninsula, an orcish chiefdom to the north, dwarven republics in the mountain foothills, and several elven city-states in the north-west. I left the remainder as wilderness for players to bushwhack through. If you want to get fancy, try the method here.

Remember, you can visualize your choices by switching the displayed attribute in Symbology.

Forests/biomes

Woodlands are important obstacles in a hexcrawl, so I'd like to mark out forested hexes. We can also designate other biomes like marshes, deserts, and tundras. We'll use the same method as above, this time adding a 'biome' attribute with a default 'grassland' value.

I opted to carve out forests, deserts, marshes, farms, urban, and a few blighted zones.

To display my new layer without overwriting my hill/flatland symbology, I duplicated the hex grid in the layer stack. This allows me to display the same data with different symbology.

It is important to be aware that edits to one of the duplicated layers will affect the other. You haven't duplicated the layer data on your hard drive - you're just telling QGIS to display the same data in two different ways.

Here's what my map looks like with assigned biomes and updated symbology:

Rivers

One last touch while we wrap up our natural geography is the addition of rivers. If you are using real-world elevation data, it makes sense to use real-world river data as well. (If not, you can draw them as in the Roads section below).

I grabbed the Europe dataset found here. It's comprehensive, but way too large and slows down my machine. So, I used Select by Location to select all rivers that intersect our hex grid area of interest:

Once the selection completes (it takes a long time), right click the layer, go to Export and Save selected features as…

This will save the selected rivers as a new (faster) layer. To display the rivers according to their real-life size, I had QGIS display them by using the ‘a_width’ attribute to modify the line width as ‘meters at scale’.

Adding points of interest

Up till now, we've mostly been storing information as hex attributes. However, some features on our map might be better represented as lines or points.

Let's start with minor cities. I already placed some major cities by assigning urban terrain to a few hexes, but for settlements that don't dominate a whole six-mile hex, we can create a point layer. In the toolbar, click New Geopackage Layer.

In the dialog that pops up, name it something like ‘Tutorial_towns’ and set it to the points geometry type. Add two fields, one for the name of the town and one for its population.

Press Ok. To place points, turn on editing for the new layer and select the Add points tool:

Click on the map to place a town. You will be prompted to name it and fill in a population, but you can also leave this blank to fill out later.

If you're unsure where to place settlements here are some quick rules of thumb:

Next, roads. For these, we can create a line layer and draw them in between cities and other areas of interest. The process is the same as above, but select LineString for the geometry type. I suggest adding a ‘type’ field to distinguish major/minor/trail roads.

That's all great, but we need adventure sites for our players to explore. You can hand-place these like the cities and roads, but to save time let's scatter them around randomly.

I'm going to use the Random Points in Layer Bounds tool for this. I've set the number of dungeons to 1120 (one for every other hex in my map - with plans to delete ones landing in water or mountains) and the minimum distance between dungeons to 3 miles.

If you wanted to ensure only one dungeon per hex, you could use the Random points in polygons tool, but I personally like the extra randomness.

I used the lasso select tool to select & delete most of the aquatic dungeons, by turning on edit mode and clicking Delete selected.

Now, for the really snazzy part: we're going to use random tables to flesh out the points of interest.

For this simple example, we're going to assign an originating faction to each of our randomly placed dungeons. This method should work for any random table, but I'll use the following:

  1. First-men castle

  2. Dark Elven sanctuary

  3. Vampire citadel

  4. Dwarven mine

  5. Natural caverns

  6. Goblin camp

  7. Orc stronghold

  8. Dragon lair

  9. Bandit camp

  10. Other

Step 1: Open Sheets or Excel and copy-paste the text from your random table into it under the 'label' column, one cell per entry.

Step 2: Assign each row a unique integer. Note that if your table is a d66 or other non-base-10 scheme you will need to re-assign numbers to just be 1 through n. Save your table as a .csv file. You can download mine here.

Step 3: Use the field calculator to create a new field in your dungeons layer, called 'Originator'. Make it an integer field. In the Expression box, type:

rand(1,10)

Click OK. Save edits. This assigns a random integer from 1 to 10 to each dungeon - like rolling a d10.

Now open the dungeon layer's Properties and go to the Attributes Form tab. Select 'Originator'. Change Widget type to 'Value map'. Click Load Data from CSV File. You should see the box populate with the contents of the file. Click Apply.

Now, when we re-open the attribute table, we can see that the ‘Originator’ column is populated with the descriptions we supplied in the table.

This example is crude but hopefully you can see the possibilities. It is enormously helpful when using a d100 table.

Symbology and finishing touches

In this section, I'll go through some ways to make your map symbology more pleasing and appropriate for a fantasy theme. If you are happy with what you have and just want to create an exportable map layout, skip to the final section.

Quick list of other things you might want to add

(You should have all the tools you need for these)

Visualizing state borders

I want to unobtrusively visualize my state boundaries as outlines around the hexes they control. To achieve that, I'm going to use the Dissolve tool to merge hexes belonging to a state into a contiguous unit. In the tool, select 'Controller' as the Dissolve field.

I symbolized the new layer with a Shapeburst fill to illuminate the state outlines without filling in the entire area. Note that the second color is transparent:

Numbering hexes

If you're keying hexes in a separate document or want to easily keep notes from your campaign, you can add numbered labels to each hex. In the properties for your hex grid, go to Labels and select 'Id' as the value to display. I recommend setting the display size to 4000 meters and decreasing the opacity.

To toggle labels on and off, right click on the layer in the Layers panel and select Show labels.

Pretty water

I used a shapeburst fill to make the water turn a deeper blue further from shore. Alternately, see here:

Fog of war

Rather than having our hex grid just 'stop', let's add some fuzziness around the edges of the map to enhance the sense of mystery. To do this, we will create a new multi-step polygon to cover the edges of the map.

Make a new layer as above, but make it a polygon layer this time. (For this step, don't save it as a geopackage but just make a new Scratch layer. Draw a rough polygon around the area you want not to be covered by clouds.

The make a second (scratch layer) polygon that spans an area much larger than your map. Use the Difference tool to cut the smaller polygon out of the larger, listing the smaller as the Overlay:

For the symbology, set the fill to Shapeburst, with the first color a transparent white, the second a non-transparent white, the distance set to ~20,000 meters at scale, and blur cranked up:

Save the layer when done.

Final map layout

The last step is to create images or handouts to share with players. If you've used any layout or design software before, the interface should be pretty familiar.

Use the New Layout button to create a map layout. Name it whatever you want.

Under Add Item at the top, select Add Map. Drag it across the whole page. Use the Move item content button on the sidebar to pan and zoom the map to the desired location.

Use the Add Legend, Add Scale Bar and Add North Arrow tools to add key map elements. For the legend, uncheck Auto Update and manually use the red minus-sign button to remove superfluous (i.e. hidden or GM-facing) layers, renaming the ones you choose to keep. Change the fonts to something fantastical or archaic.

Finally, if you want to give the map a parchment-like texture, Add Picture and draw it over the whole page. Select Raster Image and Stretch. Download this paper texture and select it. Under Rendering, change the blending mode to Multiply and drag the opacity until you are happy with the amount of texture showing.

Feel free to play with additional map elements, such as a title, extra labels, sea monster decals, etc. Here’s my final product:

Thus ends this tutorial - I hope it was helpful and mostly easy to follow! If parts of it were confusing or broken, feel free to let me know.


Discuss this post on Reddit


  1. Check out John Nelson's Youtube channel for examples of gorgeous cartography done in GIS. He uses ArcGIS, but pretty much everything he does can be replicated with ease in QGIS.

  2. To save memory - we don't need floating-point precision in our elevation layer.

#DIY #GIS