Making an Isometric Map with Depth

Published: March 13, 2023

Welcome to my blog! This is my very first post, so go easy on me! Here, I will explain how to make an isometric map that supports tiles at differing heights. These can serve as a basis for making games inspired by SimCity, OpenTTD, and TheoTown. I had a hard time finding other tutorials about this, so I figured I would make one myself!

Most isometric tutorials only show how to implement flat isometric maps. Despite that, they serve as a good foundation for creating isometric maps with depth (which I will now refer to as "3D isometric maps").

One last thing: The full version of this program can be found on GitHub.

Setting Up

Before we can make a 3D map, we must master the 2D map! I'll assume you already know the basics of the isometric perspective and just jump into the implementation. For this example, assume that each tile is 32x16 pixels large. (This tutorial works best if the tile's height is half its width.)

Let's start by setting up a simple game using the LOVE2D framework. Don't worry if you're using another framework, game engine, or language. The basic concepts I demonstrate here should still apply. We should also make a 2D table to store our tiles. For the sake of simplicity, let's assume each tile is a number that represents its height (aka depth). While we're at it, let's also store an image that will represent each of our tiles.

-- Sets the image filter and line style so the graphics aren't blurry

love.graphics.setDefaultFilter ("nearest", "nearest")

love.graphics.setLineStyle ("smooth")


-- Declares the local variables

local map = {}

local mapSize = 32  -- Max size of the isometric map


local tileImage = love.graphics.newImage ("tileImage.png") -- Loads the image

local tileW = 32 -- Image width

local tileH = 16 -- Image height


function love.load ()

    -- Initializes the map table with a bunch of tiles with heights of 0

    for i = 1, mapSize do

        map[i] = {}

        for j = 1, mapSize do

            map[i][j] = 0

        end

    end

end



function love.update (dt)

   

end



function love.draw ()

    

end



function love.keypressed (key)

   

end



function love.mousepressed (x, y, button, istouch, presses)

   

end

Rendering a 2D Map

That was pretty simple, right? Now here's the tricky part: How can we possibly render each tile on the map? Fortunately, it's fairly easy once you find the right formula. Using a set of for loops, we can iterate over the table and apply these formulas to the tile's position within the map. This will give us the screen position that the tile should be rendered to. Let's put this functionality into love.draw.

function love.draw ()

    for i = 1, mapSize do

        for j = 1, mapSize do

            local screenX = (i - j) * (tileW / 2) -- Formula for the screen's x position

            local screenY = (i + j) * (tileH / 2) -- Formula for the screen's y position

            love.graphics.draw (tileImage, screenX, screenY) -- Renders the tile's image

        end

    end

end

This gives us the following result:

It's certainly a good start! Just ignore how half the map is off-screen. You can easily fix this later by using a camera system.

Translating Back to Map Positions

Now that we can render a 2D map, let's find a way to track what tile the mouse is currently hovering over. To do this, we must reverse the previous formulas we used. If you're willing to dust off your high school algebra textbook, you can solve for i and j to get the new formulas. If you're lazy, you can just copy the formulas found below. There are actually several variations you can use, but this is just one of them.

function love.update (dt)

    mouseX, mouseY = love.mouse.getPosition ()


    mapX = math.floor (mouseY / tileH + (mouseX - tileH) / tileW)

    mapY = math.floor (mouseY / tileH - (mouseX - tileH) / tileW)

end

If we print these numbers to the screen, we can see which tile our mouse is hovering over.

Rendering a 3D Map

Here comes the calm before the storm! Thankfully, this step is fairly trivial. We'll need to modify our previous rendering algorithm to account for the tiles' heights. For this example, assume that each height level is a multiple of 8 pixels. This means that a tile at height level 3 should be 24 pixels off the ground. To accomplish this, we can simply multiply the tile's height by 8 (represented by heightMult in the code) and use that to offset screenY.

for i = 1, mapSize do

        for j = 1, mapSize do

            local screenX = (i - j) * (tileW / 2) -- Formula for the screen's x position

            local screenY = (i + j) * (tileH / 2) - map[i][j] * heightMult -- Formula for the screen's y position

            love.graphics.draw (tileImage, screenX, screenY) -- Renders the tile's image

        end

    end

As an example, let's set the height of the tile at (32, 1) to 4. Notice how it hovers 32 pixels above its normal vertical position?

However, we have a new issue: the map coordinates are completely wrong!

We should be hovering over (32, 1), but the game says (30, -1). What gives? Unfortunately, our previous algorithm does not take the tiles' heights into account.

Map Positions in a 3D Map

We must find a new way to detect what tile the mouse is hovering over. This is much easier said than done! Unfortunately, there's no mathematical way to accomplish this, so a custom algorithm is necessary. Let's start with a basic algorithm and work ourselves up to something more sophisticated (and optimized).

Thankfully, our previous algorithm may still be useful. If we check every tile in the map and offset the mouse's y position by the tile's height, we can get a 2D map position. With that, we can confirm whether the mouse is hovering over the tile we're currently checking.

I'll demonstrate this with a picture. Imagine that the blue tile has a height of 4 and is located at (32, 1) like before. The red X is where the mouse cursor is. In step 1, we get the position of the mouse and we choose a tile to test. (In this case, we arbitrarily chose (32,1), although our algorithm will eventually test every tile in the map) In step 2, we take the height of the tile, multiply it by 8, and add it to the cursor's y position. This will offset the mouse to the location of the purple X. In step 3, we translate the purple X's position into 2D map coordinates. If these coordinates match the coordinates of the tile we're checking, then it must be the tile the mouse is hovering over. In this case, the coordinates translated into (32, 1), indicating a match.

Let's see another example where the opposite happens. Imagine the same situation as before, except the tile only has a height of 1. In this case, the mouse's y position is only offset by 8 pixels. When we convert it into a 2D map position, we get (30, -1) instead of (32, 1). This means the mouse can't be hovering over the tile we're checking.

If we test enough tiles, we may eventually find the one the mouse is hovering over. For now, let's test every tile in the map using the algorithm found below.

function love.update (dt)

    -- Gets the mouse position and translates it into a map position

    mouseX, mouseY = love.mouse.getPosition ()


    -- Checks every tile in the map

    for i = 1, mapSize do

        for j = 1, mapSize do

            local tempY = mouseY + map[i][j] * heightMult -- Offsets the mouse's y position

           

            local predictedX = math.floor (tempY / tileH + (mouseX - tileH) / tileW) -- Formula for the map's x position

            local predictedY = math.floor (tempY / tileH - (mouseX - tileH) / tileW) -- Formula for map's y position


            -- Checks if the predicted coordinates match the coordinates of the tile we're checking

            if predictedX == i and predictedY == j then

                mapX, mapY = i, j

            end

        end

    end

end

Ah, that's better!

A More Sophisticated Approach

The previous algorithm is a good start, but it has some major flaws. For instance, checking the entire map is horrible for performance! However, you may have noticed that only a small set of tiles can possibly overlap at a given mouse position. In fact, this set is limited to a few diagonal strips of tiles above and below the point.

Our algorithm should start at the lowest point in these strips. If we define maximum and minimum tile heights, we can calculate two offsets. Then we can use these offsets to generate starting and ending positions for our algorithm. The formulas for these are shown here:

belowOffset = math.ceil (maxHeight / (tileH / heightMult))

aboveOffset = math.floor (minHeight / (tileH / heightMult))

Let's define our maximum height as 9 and our minimum height as 0. Given that our tile height is 16 and our height multiplier is 8, our below and above offsets are 5 and 0, respectively.

belowOffset = math.ceil (9 / (16 / 8)) = 5 tiles

aboveOffset = math.floor (0 / (16 / 8)) = 0 tiles

For example, if the 2D map position of the mouse is (4, 3), our algorithm should start checking tiles at (9, 8) and stop at (4, 3).

All this is visualized in the graphic below:

(If you use a minimum height that's less than 0, you will also need to extend this strip above the magenta tile. Thankfully, our algorithm will handle this automatically.)

However, there's one last optimization we can make. If you're good at spotting patterns, you may have realized that we can skip over one of these strips. Let's take a look at the screenshot below:

Notice how this dirt pillar only overlaps with the right side of the magenta tile? If our mouse is within the right side of the tile, we can skip the leftmost strip (and vice-versa). Only non-blue tiles are capable of overlapping with the mouse position when they're raised/lowered. (Try copying the image and moving any of the blue tiles straight upward. They'll never touch the X!) This will make the game run faster since it won't have to iterate over as many tiles.

With all this in mind, we can finally begin implementing our final algorithm. Let's say our mouse is positioned over the 2D map position (24, 4) and is located on the right side of the tile. Using our offsets, we know that our algorithm should start at (29, 9) and stop at (24, 4). We should also track when we've "hit" the correct 3D map position so we can exit the loop early, whenever possible. If you recall our previous algorithm, we know we've hit the correct tile when the predicted position matches the current tile we're checking. Finally, we need some logic to decide which tile our algorithm should move toward next. Depending on our current position in the map and our mouse position within the tile, we should either decrement the x position (moving right/north) or the y position (moving left/west). Our final version of the algorithm can be found below.

function love.update (dt)

    -- Gets the mouse position and translates it into a map position

    mouseX, mouseY = love.mouse.getPosition ()


     -- The 2D map position that the mouse cursor is over

    local origX = math.floor (mouseY / tileH + (mouseX - tileH) / tileW)

    local origY = math.floor (mouseY / tileH - (mouseX - tileH) / tileW)


    -- The next tile that the algorithm should check. It starts at the lowest possible position within the diagonal strips

    local nextX = origX + belowOffset

    local nextY = origY + belowOffset


    -- The last possible tile the algorithm should check. It ends at the highest possible position within the diagonal strips

    local targetX = origX + aboveOffset

    local targetY = origY + aboveOffset


    local hit = false


    -- Used to determine the next value of nextX and nextY

    -- It's based on the next map position and which half of the next map position the mouse resides within

    local moveEast = ((mouseX % tileW) > (tileW / 2)) ~= (nextX % 2 == 1) ~= (nextY % 2 == 1)


    -- Loops until the correct tile is "hit" or until it surpasses the target tile

    while hit == false and nextX >= targetX and nextY >= targetY do

        -- Used to offset mouseY so we can calculate the predicted position

        local tempY = 0

       

        -- Ensures that we don't reference the map table when the current position is out of bounds

        if (nextX >= 1 and nextX <= mapSize) and (nextY >= 1 and nextY <= mapSize) then

            tempY = mouseY + map[nextX][nextY] * heightMult

        end


        -- Translates the mouse position (with the offset mouseY value) into a 2D map position

        local predictedX = math.floor (tempY / tileH + (mouseX - tileH) / tileW)

        local predictedY = math.floor (tempY / tileH - (mouseX - tileH) / tileW)


        -- Checks if the correct tile was hit

        if predictedX == nextX and predictedY == nextY then

            hit = true

            mapX = predictedX

            mapY = predictedY


        else

            -- Finds the next tile's coordinates

            if moveEast == false then

                nextX = nextX - 1 -- Chooses the north (right) tile

                moveEast = not moveEast

            else

                nextY = nextY - 1 -- Chooses the west (left) tile

                moveEast = not moveEast

            end

        end

    end


    -- If a tile was never found, we can assume the mouse was out of bounds and set a default value

    if hit == false then

        mapX = -1

        mapY = -1

    end

end

That's quite a beefy algorithm! Thankfully, it should work with almost any tile size, height multiplier, and minimum/maximum height. If you don't plan on changing these values over the course of development, you can shorten the code by replacing some variables with their literal values.

Concluding Remarks

Although this algorithm still has room for optimization, I think this is a great base for an isometric video game. Most (if not all) of this code can be easily translated into another programming language. I've also included a few images at the bottom of the page to show that it works for other tile sizes and height multipliers.

Thank you for making it to the end of my blog post! I look forward to making many more in the future. If you're interested in viewing the full code, you can find it on GitHub. Feel free to email me or raise an issue on GitHub if something doesn't work.