Tilesets and Makefiles Part 2: Elevation

This post is part of the Tilesets and Makefiles series.

Dec 03, 2025

In our last post we created a makefile that can generate a full tileset based on just a few tiled patterns. However, we want to include some improvements.

The first improvement we want is to be able to create elevated tilesets. To do this, we will need more information baked into our meta-template. We added 4 additional colors:

  • South Wall: 8888ff
  • East Wall: 88ff88
  • West Wall: ff8888

Raised Template

This let’s us create a new tile set for a raised template. Here we draw an elevated floor, where the borders draw walls pointing outwards from the tile: Pastedimage20251202212338.png

The creamy yellow part is the floor that we will draw with, the blue part is our border. We will keep the “transition” area ff88ff and convert it conditionally. For FLAT terrains (no elevation) all the Wall areas will be converted to the transition color for the ELEVATED terrains (terrains with walls), we will keep the walls distinct and convert the transition area to the floor color ffff88.

Sunken Template

But we will also need a sunken template. This template is for when we are drawing in the recessed area. In these areas, the borders are pointing inwards towards the tile.

Pastedimage20251202212219.png

Conditional formatting

Both of these have some extra information in the form of the $ff88ff area that can be recolored conditionally to either the floor or the border texture to flatten it

Pastedimage20251130205051.png >Note: The bottom transform is labled elevated, but the result is actually a sunken template. Sorry.

Multiplex Template

Ideally we can combine our raised and sunken template into a master template that can be converted in either a raised, sunken or flat terrain.

if we multiply the two templates we get an interesting pattern

Pastedimage20251202220421.png > Creating the two patterns by hand made sure they are misaligned and that leaves a ton of cleanup work. But the idea of creating all possible combinations of zones this ways is still clear.

Lets zoom in on one of the sections and compare them

Pastedimage20251202222810.png

If we multiply them and clean up the results we get 11 unique colors

Pastedimage20251202223221.png >Note: I Cleaned up the colors for clarity the idea is that we create distinct colors by multiplying the values, but the results were hard to read easily, so I recolored them for clarity.

These will be our new zones let’s give them a nice new palette to make them pop out a bit more.

Note: in the image above, I numbered them, and the table below is zero-indexed. I’m too lazy to retrace my previous steps, so just do the math yourself.

Index Zone Color Color Names
0 Floor #cccc00 Mustard
1 Shared Transition #888800 Olive
2 Border #444400 Khaki
3 Sunken West Wall #00cc00 Lime
4 Shared West Wall #008800 Forest
5 Raised West Wall #004400 Pine
6 Sunken East Wall #0000cc Navy
7 Shared East Wall #000088 Ocean
8 Raised East Wal #000044 Midnight
9 Sunken North Wall #440044 Violet
10 Raised South Wall #880088 Imperial

multiplex_template.png: Pastedimage20251203000247.png

Wow, that looks gross. This palette doesn’t pop, it looks digested, but it does create reasonably separable color zones, so we will keep it.

Starting over

Okay the previous makefile was getting a bit too convoluted, so we will start fresh. We will remake our make file to create each terrain seperately and combine them all together. To deal with the intricate steps we will keep track of the relationships between our colorcoded zones, the textures and our required terrains in a seperate config file.

Configuration

Dealing with all the relationships between zones, colors and textures is becoming a real pain with all these extra possible combinatorics introduced by height. We need to keep track of our variables in a better way, seperately from our makefile. Which in all honesty is really bad at managing variables in a legible way.

Let’s start with a yaml to declare all our dimensions and colors

#summer.yaml
title: "summer"
  
dimensions:
    sprite_width: 64
    sprite_height: 64
    horizontal_tiles: 20
    vertical_tiles: 15
    template_width: 512
    template_height: 384
  
colors:
    # Primary Colors
    red: "#ff0000"
    green: "#00ff00"
    blue: "#0000ff"
    yellow: "#ffff00"
    cyan: "#00ffff"
    magenta: "#ff00ff"
    black: "#000000"
    white: "#fffff
    # Zone Colors
    mustard: "#cccc00"
    olive: "#888800"
    khaki: "#444400"
    lime: "#00cc00"
    forest: "#008800"
    pine: "#004400"
    navy: "#0000cc"
    ocean: "#000088"
    midnight: "#000044"
    violet: "#440044"
    imperial: "#880088"

Now the reason we wanted to use yaml is to make use of it’s super useful anchor feature

This means we can give all these colors names like this:

# summer.yaml
# ...
# dimensions
# ...

colors:
    # Primary Colors
    red: &red "#ff0000"
    green: &green "#00ff00"
    blue: &blue "#0000ff"
    yellow: &yellow "#ffff00"
    cyan: &cyan "#00ffff"
    magenta: &magenta "#ff00ff"
    black: &black "#000000"
    white: &white "#ffffff"
    
    # Zone Colors
    mustard: &mustard "#cccc00"
    olive: &olive "#888800"
    khaki: &khaki "#444400"
    lime: &lime "#00cc00"
    forest: &forest "#008800"
    pine: &pine "#004400"
    navy: &navy "#0000cc"
    ocean: &ocean "#000088"
    midnight: &midnight "#000044"
    violet: &violet "#440044"
    imperial: &imperial "#880088"

And reuse them further down the line. For example let’s declare our template zones as described above

# summer.yaml
# ...
# dimensions, colors
# ...

zones:
  floor: *mustard
  shared_transition: *olive
  border: *khaki
  sunken_west_wall: *lime
  shared_west_wall: *forest
  raised_west_wall: *pine
  sunken_east_wall: *navy
  shared_east_wall: *ocean
  raised_east_wal: *midnight
  sunken_north_wall: *violet
  raised_south_wall: *imperial
  

Now in our config our zones will carry the color value assigned to them by the human-readable anchors. This makes it a lot easier to spot errors and overwrite values.

Let’s declare our different textures and map them to some primary colors. We can make them into a little object array.

# summer.yaml
# ...
# dimensions, colorsm, zones
# ...

textures:
  snow: &snow
    color: *white
    file: "snow.png"
  cliff: &cliff
    color: *black
    file: "cliff.png"
  grass: &grass
    color: *green
    file: "grass.png"
  dune: &dune
    color: *red
    file: "dune.png"
  dirt: &dirt
    color: *yellow
    file: "dirt.png"
  sand: &sand
    color: *cyan
    file: "beach.png"
  water: &water
    color: *blue
    file: "water.png"
  glacier: &glacier
    color: *magenta
    file: "glacier.png"

I don’t like yaml’s whitespace restrictions and these long objects are pretty clunky. I prefer toml’s optional horizontality. But you can’t argue with the power of anchors and not having to repeat yourself. Here I’m poiting to the human readable colors and im also anchoring the entire individual texture objects for further use later.

Now we can declare our terrains. We define a terrain as a combination of two three textures: 2 main textures and a transition. So a terrain from now on will refer to a single instance of our template. We also add some height information that we will use later.

  ```yaml # summer.yaml # … # dimensions, colorsm, zones, textures # …

terrains:   mountain:     floor: *snow     transition: *cliff     border: *grass     type: “elevated”   meadow:     floor: *grass     transition: *dune     border: *dirt     type: “flat”   beach:     floor: *dirt     transition: *sand     border: *water     type: “flat”   sea:     floor: *water     transition: *glacier     border: *snow     type: “sunken”

  
  How nice is that?! We can just reuse all the anchors we set up earlier and create a little yaml database of all the relationships between our textures. Neat!

## Querying our yaml

Now that we have a little yaml database of our biome, we are going to query it using [yq](https://github.com/mikefarah/yq) . YQ is a wonderful tool with a great command line that let's us query, update and transform not only yaml but all kinds of config languages. And it runs on everything!

If we run

```bash
$ yq .colors.red summer.yaml

we get back:

#ff0000

And as promised an anchorred reference like:

$ yq -ot .terrains.meadow.transition.color summer.yaml

note to resolve the anchors we need yq to output to json or in this case tsv

And we get red!

#ff0000

Makefile

Let’s make a makefile that uses yq as a config and makes color coded templates for our terrains

# set the template file as a variable
template_file = input/multiplex_template.png
  
#Here we create a little function that wraps
the yq command to extract values from the yaml config
conf = $(shell yq -ot '.$(1)' input/summer.yaml)
  
# STAGE 1 TERRAIN TEMPLATE GENERATION
#    Here we can encode the mapping between the color coding of the different terrain textures and the template colors
#    the mapping below generates a FLAT terrain
%-flat-template: $(template_file)
    convert $(template_file) \
        -fill "$(call conf,terrains.$*.border.color)"       -opaque "$(call conf,zones.border)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.floor)" \
        
        # all the transition zones will be set to the same transition color, so this will be a flat terrain.
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_transition)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_east_wal)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_north_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_south_wall)" \
        $*_template.png

This part is a bit opaque and hard to fit into 90 character lines, so i kept it big for clarity. Copy and paste it yourself in a text editor to see what it does.

Now we can go

 $ make meadow-flat-template

And we get:

Pastedimage20251203104405.png

Gorgeous flat terrain

Raised terrain

our yaml contains information on whether the terrain we want is raised, sunken or flat. Let’s create mappings for raised and sunken terrains. For this one we want to map the SUNKEN zones to merge witht the floor pattern

# %-flat-template: $(template_file)
# ...

# For RAISED terrain we merge the sunken zones to the floor color
%-raised-template: $(template_file)
    convert $(template_file) \
        -fill "$(call conf,terrains.$*.border.color)"       -opaque "$(call conf,zones.border)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.floor)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.shared_transition)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.sunken_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_west_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.sunken_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_east_wal)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_north_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.raised_south_wall)" \
        $*-template.png

With fantastic results Pastedimage20251203105304.png

Sunken terrain

For the sunken terrain we do the inverse. The raised zones merge with the floor and the sunken zones get the transition code.

# %-flat-template: $(template_file)
# %-raised-template: $(template_file)
# ...


# For sunken terrain we merge the raised zones to the floor color
%-sunken-template: $(template_file)
    convert $(template_file) \
        -fill "$(call conf,terrains.$*.border.color)"       -opaque "$(call conf,zones.border)" \
        -fill "$(call conf,terrains.$*.floor.color)"        -opaque "$(call conf,zones.floor)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.shared_transition)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_west_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.raised_west_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.sunken_east_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.shared_east_wall)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.raised_east_wal)" \
        -fill "$(call conf,terrains.$*.floor.color)"    -opaque "$(call conf,zones.sunken_north_wall)" \
        -fill "$(call conf,terrains.$*.transition.color)"   -opaque "$(call conf,zones.raised_south_wall)" \
        $*-template.png

So now if we go:

make sea-sunken-template

And we have again, great results! Pastedimage20251203105836.png

I realise theres some chipped corners in the multiplex template that I can fix. but this looks great already!

Let’s combine the three. Let the makefile figure out what type of template it should be based on the yaml config.

%-template:
    # A guard that checks whether the requested terrain actually exists
    [ $(call conf,terrains.$*.type) != "null" ] || { echo exit 1; }; \
    # Call the correct build target
    $(MAKE)  "$*-$(call conf,terrains.$*.type)-template"

Then we can just go

$ make mountain-template

And it will figure out what to do <3

Overlays

We can just order templates for any terrain, but each terrain contains 3 textures we need to overlay. The texture relationships are all encoded in the yaml config, so we just need to hook them up together

I need a query that i can plug in our terrain and section (floor, border or transition) and it will return the correct texture

we could have jsut simply go

yq -oy '.terrains.sea.floor' input/summer.yaml | cut -c 2-

To return the anchor value and strip the leading ‘*’ but that would mean that the anchor names and the actual keys of the objects have to be aligned and that’s not something I want to worry about

So now we have to make a tricky join between two objects. To show case the power of yq I made this query: bash yq -ot '.terrains as $t | .textures | to_entries[] | select(.value.file == $t.sea.floor.file).key' input/summer.yaml

This takes the sea.floor terrain and returns the key associated with that texture and we can ignore possible data inconsistency issues with our yaml.

Let’s wrap these up in a nice makefile function we can call at will

TEXTURE_PROPERTY = $(shell yq -ot '.terrains as $$t | .textures | to_entries[] | select(.value.file == $$t.$(1).$(2).file).$(3)' $(config_file))

This takes three parameters:

  • the terrain
  • the section (floor, border or transition)
  • the property of the target texture we want >The nature of yq queries makes it so that for the third parameter I need to call ‘key’ to get the name of the texture while the actual properties of the texture object are found under ‘value.color’ ‘value.file’

Now let’s use it to create the overlays

%-overlays:
    convert -size $(width)x$(height) tile:input/textures/$(call TEXTURE_PROPERTY,$*,border,value.file) $(call TEXTURE_PROPERTY,$*,floor,key)-overlay
    convert -size $(width)x$(height) tile:input/textures/$(call TEXTURE_PROPERTY,$*,border,value.file) $(call TEXTURE_PROPERTY,$*,border,key)-overlay.png
    convert -size $(width)x$(height) tile:input/textures/$(call TEXTURE_PROPERTY,$*,border,value.file) $(call TEXTTEXTURE_PROPERTY,$*,transition,key)-overlay.png

so this one takes the target terrain then uses the function above to get the properties and scales the required overlays

Pastedimage20251203202736.png

Cutouts

Now it’s time to create the cutouts. First we isolate the floor section from the template

%-floor-section: %-template
    convert $*-template.png -alpha set +transparent "$(call TEXTURE_PROPERTY,$*,floor,value.color)" $*-floor-section.png

So,

$ make sea-floor-section

get’s us

Pastedimage20251203203104.png

and we apply the associated overlay to complete the cutout

%-floor-cutout: %-floor-section %-overlays
    convert $(call TEXTURE_PROPERTY,$*,floor,key)-overlay.png $*-floor-section.png -compose copyopacity -composite $*-floor-cutout.png

And,

$ make sea-floor-cutout

get’s us

Pastedimage20251203203640.png

With a little bit of duplication can get recipes for the border and transitions

# SECTIONS
%-floor-section: %-template
    convert $*-template.png -alpha set +transparent "$(call TEXTURE_PROPERTY,$*,floor,value.color)" $*-floor-section.png
    
%-border-section: %-template
    convert $*-template.png -alpha set +transparent "$(call TEXTURE_PROPERTY,$*,border,value.color)" $*-border-section.png

%-transition-section: %-template
    convert $*-template.png -alpha set +transparent "$(call TEXTURE_PROPERTY,$*,transition,value.color)" $*-transition-section.png

# CUTOUTS
%-floor-cutout: %-floor-section %-overlays
    convert $(call TEXTURE_PROPERTY,$*,floor,key)-overlay.png $*-floor-section.png -compose copyopacity -composite $*-floor-cutout.png

%-border-cutout: %-border-section %-overlays
    convert $(call TEXTURE_PROPERTY,$*,border,key)-overlay.png $*-border-section.png -compose copyopacity -composite $*-border-cutout.png  

%-transition-cutout: %-transition-section %-overlays
    convert $(call TEXTURE_PROPERTY,$*,transition,key)-overlay.png $*-transition-section.png -compose copyopacity -composite $*-transition-cutout.png

Putting it all together

The last thing we need to do is put all the cutouts together

%-tileset: %-border-cutout %-floor-cutout %-transition-cutout
    convert $*-border-cutout.png \
        $*-floor-cutout.png -compose over -composite\
        $*-transition-cutout.png -compose over -composite \
        $*-tileset.png

And with zero intervention

$ make sea-tileset
Pastedimage20251203204243.png

All we have to do now is loop through all the available terrains an stich the tilesets together

# Loop through all the terrain keys we can find in the config
TILESETS := $(foreach terrain,$(call conf,terrains|keys),$(terrain)-tileset.png)

create tilesets for them and stich them together
tilesets: $(TILESETS)
    mkdir -p output
    montage $(TILESETS) -tile 2x2 -geometry +0+0 output/tilemap.png
    $(MAKE) clean

So the original textures are 64x64 pixels and the tilemaps are arrange as 6x8 tiles of that size. This is so that i can make use of Tiled’s mixed terrain set mapping .

I discovered that using the results as 12x16 tilemaps of size 32x32 and treating them as corner terrain set, yeilds much better results, so I created an alternative template with a 2x2 mapping for corner sets.

Once hooked up, we are able to draw our elevated levels!

video.gif # Conclusion

Wow we overhauled the whole thing! Our final makefile is funnily enough still 100 lines, even though we added another 100 line yaml configuration file.

We are however, now capable of generating tile sets for different heights! In addition to that declaring the contents of the tilesets in a yaml file without having to touch the makefile makes us so much more powerfull. We can mix and match textures to create all kind of tilesets!

Next Steps

We can now create tilesets from yaml at will, but there is still more to be done.

  • We want to create shadow maps to enhance the 3D feel of the maps
  • We want to detect the dimensions of the tilesets and templates dynamically instead of having to encode them by hand
  • We want to scale our textures to the correct tile size (in this case 32x32)
  • We want to file off the sharp edges from our new meta-template and make our elevations even more pronounced
  • We want to generate the Tiled tileset xml dynamically instead of relying on the dummy.
gamedev
Creative

Yasen Dinkov

Tilesets and Makefiles Part 3: Shading

Tilesets and Makefiles