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:
The algorithm will start at the orange tile at the bottom of the diagonal strip.
The blue represents tiles that our algorithm will skip.
The red X is the location of the mouse.
The magenta tile is the 2D map position that the mouse resides within.
(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.