In this tutorial, I’ll give you a broad overview of what you need to know to create isometric worlds. You’ll learn what the isometric projection is, and how to represent isometric levels as 2D arrays. We’ll formulate relationships between the view and the logic, so that we can easily manipulate objects on screen and handle tile-based collision detection. We’ll also look at depth sorting and character animation.
1. The Isometric World
Isometric view is a display method used to create an illusion of 3D for an otherwise 2D game – sometimes referred to as pseudo 3D or 2.5D. These images (taken from the original Diablo and Age of Empires games) illustrate what I mean:
Implementing an isometric view can be done in many ways, but for the sake of simplicity I’ll focus on a tile-based approach, which is the most efficient and widely used method. I’ve overlaid each screenshot above with a diamond grid showing how the terrain is split up into tiles.
2. Tile-Based Games
In the tile-based approach, each visual element is broken down into smaller pieces, called tiles, of a standard size. These tiles will be arranged to form the game world according to pre-determined level data – usually a 2D array.
For example let us consider a standard top-down 2D view with two tiles – a grass tile and a wall tile – as shown here:
These tiles are each the same size as each other, and are each square, so the tile height and tile width are the same.
For a level with grassland enclosed on all sides by walls, the level data’s 2D array will look like this:
[[1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1]]
Here, 0
denotes a grass tile and 1
denotes a wall tile. Arranging the tiles according to the level data will produce the below level image:
We can enhance this by adding corner tiles and separate vertical and horizontal wall tiles, requiring five additional tiles:
[[3,1,1,1,1,4], [2,0,0,0,0,2], [2,0,0,0,0,2], [2,0,0,0,0,2], [2,0,0,0,0,2], [6,1,1,1,1,5]]
I hope the concept of the tile-based approach is now clear. This is a straightforward 2D grid implementation, which we could code like so:
for (i, loop through rows) for (j, loop through columns) x = j * tile width y = i * tile height tileType = levelData[i][j] placetile(tileType, x, y)
Here we assume that tile width and tile height are equal (and the same for all tiles), and match the tile images’ dimensions. So, the tile width and tile height for this example are both 50px, which makes up the total level size of 300x300px – that is, six rows and six columns of tiles measuring 50x50px each.
In a normal tile-based approach, we either implement a top-down view or a side view; for an isometric view we need to implement the isometric projection.
3. Isometric Projection
The best technical explanation of what “isometric projection” means, as far as I’m aware, is from this article by Clint Bellanger:
We angle our camera along two axes (swing the camera 45 degrees to one side, then 30 degrees down). This creates a diamond (rhombus) shaped grid where the grid spaces are twice as wide as they are tall. This style was popularized by strategy games and action RPGs. If we look at a cube in this view, three sides are visible (top and two facing sides).
Although it sounds a bit complicated, actually implementing this view is straightforward. What we need to understand is the relation between 2D space and the isometric space – that is, the relation between the level data and view; the transformation from top-down “Cartesian” coordinates to isometric coordinates.
(We are not considering a hexagonal tile based technique, which is another way of implementing isometric worlds.)
Placing Isometric Tiles
Let me try to simplify the relationship between level data stored as a 2D array and the isometric view – that is, how we transform Cartesian coordinates to isometric coordinates.
We will try to create the isometric view for our wall-enclosed grassland level data:
[[1,1,1,1,1,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1]]
In this scenario we can determine a walkable area by checking whether the array element is 0
at that coordinate, thereby indicating grass. The 2D view implementation of the above level was a straightforward iteration with two loops, placing square tiles offsetting each with the fixed tile height and tile width values.
for (i, loop through rows) for (j, loop through columns) x = j * tile width y = i * tile height tileType = levelData[i][j] placetile(tileType, x, y)
For the isometric view, the code remains the same, but the placeTile()
function changes.
For an isometric view we need to calculate the corresponding isometric coordinates inside the loops.
The equations to do this are as follows, where isoX
and isoY
represent isometric x- and y-coordinates, and cartX
and cartY
represent Cartesian x- and y-coordinates:
//Cartesian to isometric: isoX = cartX - cartY; isoY = (cartX + cartY) / 2;
//Isometric to Cartesian: cartX = (2 * isoY + isoX) / 2; cartY = (2 * isoY - isoX) / 2;
These functions show how you can convert from one system to another:
function isoTo2D(pt:Point):Point{ var tempPt:Point = new Point(0, 0); tempPt.x = (2 * pt.y + pt.x) / 2; tempPt.y = (2 * pt.y - pt.x) / 2; return(tempPt); }
function twoDToIso(pt:Point):Point{ var tempPt:Point = new Point(0,0); tempPt.x = pt.x - pt.y; tempPt.y = (pt.x + pt.y) / 2; return(tempPt); }
The pseudocode for the loop then looks like this:
for(i, loop through rows) for(j, loop through columns) x = j * tile width y = i * tile height tileType = levelData[i][j] placetile(tileType, twoDToIso(new Point(x, y)))
As an example, let’s see how a typical 2D position gets converted to an isometric position:
2D point = [100, 100]; // twoDToIso(2D point) will be calculated as below isoX = 100 - 100; // = 0 isoY = (100 + 100) / 2; // = 100 Iso point == [0, 100];
Similarly, an input of [0, 0]
will result in [0, 0]
, and [10, 5]
will give [5, 7.5]
.
The above method enables us to create a direct correlation between the 2D level data and the isometric coordinates. We can find the tile’s coordinates in the level data from its Cartesian coordinates using this function:
function getTileCoordinates(pt:Point, tileHeight:Number):Point{ var tempPt:Point = new Point(0, 0); tempPt.x = Math.floor(pt.x / tileHeight); tempPt.y = Math.floor(pt.y / tileHeight); return(tempPt); }
(Here, we essentially assume that tile height and tile width are equal, as in most cases.)
Hence, from a pair of screen (isometric) coordinates, we can find tile coordinates by calling:
getTileCoordinates(isoTo2D(screen point), tile height);
This screen point could be, say, a mouse click position or a pick-up position.
Tip: Another method of placement is the Zigzag model, which takes a different approach altogether.
Moving in Isometric Coordinates
Movement is very easy: you manipulate your game world data in Cartesian coordinates and just use the above functions for updating it on the screen. For example, if you want to move a character forward in the positive y-direction, you can simply increment its y
property and then convert its position to isometric coordinates:
y = y + speed; placetile(twoDToIso(new Point(x, y)))
Depth Sorting
In addition to normal placement, we will need to take care of depth sorting for drawing the isometric world. This makes sure that items closer to the player are drawn on top of items farther away.
The simplest depth sorting method is simply to use the Cartesian y-coordinate value, as mentioned in this Quick Tip: the further up the screen the object is, the earlier it should be drawn. This work well as long as we do not have any sprites that occupy more than a single tile space.
The most efficient way of depth sorting for isometric worlds is to break all the tiles into standard single-tile dimensions and not to allow larger images. For example, here is a tile which does not fit into the standard tile size – see how we can split it into multiple tiles which each fit the tile dimensions:
4. Creating the Art
Isometric art can be pixel art, but it doesn’t have to be. When dealing with isometric pixel art, RhysD’s guide tells you almost everything you need to know. Some theory can be found on Wikipedia as well.
When creating isometric art, the general rules are
- Start with a blank isometric grid and adhere to pixel perfect precision.
- Try to break art into single isometric tile images.
- Try to make sure that each tile is either walkable or non-walkable. It will be complicated if we need to accommodate a single tile that contains both walkable and non-walkable areas.
- Most tiles will need to seamlessly tile in one or more directions.
- Shadows can be tricky to implement, unless we use a layered approach where we draw shadows on the ground layer and then draw the hero (or trees, or other objects) on the top layer. If the approach you use is not multi-layered, make sure shadows fall to the front so that they won’t fall on, say, the hero when he stands behind a tree.
- In case you need to use a tile image larger than the standard isometric tile size, try to use a dimension which is a multiple of the iso tile size. It is better to have a layered approach in such cases, where we can split the art into different pieces based on its height. For example, a tree can be split into three pieces: the root, the trunk, and the foliage. This makes it easier to sort depths as we can draw pieces in corresponding layers which corresponds with their heights.
Isometric tiles that are larger than the single tile dimensions will create issues with depth sorting. Some of the issues are discussed in these links:
- Bigger tiles.
- Splitting and Painter’s algorithm.
- Openspace’s post on effective ways of splitting up larger tiles.
5. Isometric Characters
Implementing characters in isometric view is not complicated as it may sound. Character art needs to be created according to certain standards. First we will need to fix how many directions of motion are permitted in our game – usually games will provide four-way movement or eight-way movement.
For a top-down view, we could create a set of character animations facing in one direction, and simply rotate them for all the others. For isometric character art, we need to re-render each animation in each of the permitted directions – so for eight-way motion we need to create eight animations for each action. For ease of understanding we usually denote the directions as North, North-West, West, South-West, South, South-East, East, and North-East, anti-clockwise, in that order.
We place characters in the same way that we place tiles. The movement of a character is accomplished by calculating the movement in Cartesian coordinates and then converting to isometric coordinates. Let’s assume we are using the keyboard to control the character.
We will set two variables, dX
and dY
, based on the directional keys pressed. By default these variables will be 0
, and will be updated as per the chart below, where U
, D
, R
and L
denote the Up, Down, Right and Left arrow keys, respectively. A value of 1
under a key represents that key being pressed; 0
implies that the key is not being pressed.
Key Pos U D R L dX dY ================ 0 0 0 0 0 0 1 0 0 0 0 1 0 1 0 0 0 -1 0 0 1 0 1 0 0 0 0 1 -1 0 1 0 1 0 1 1 1 0 0 1 -1 1 0 1 1 0 1 -1 0 1 0 1 -1 -1
Now, using the values of dX
and dY
, we can update the Cartesian coordinates as so:
newX = currentX + (dX * speed); newY = currentY + (dY * speed);
So dX
and dY
stand for the change in the x- and y-positions of the character, based on the keys pressed.
We can easily calculate the new isometric coordinates, as we’ve already discussed:
Iso = twoDToIso(new Point(newX, newY))
Once we have the new isometric position, we need to move the character to this position. Based on the values we have for dX
and dY
, we can decide which direction the character is facing and use the corresponding character art.
Collision Detection
Collision detection is done by checking whether the tile at the calculated new position is a non-walkable tile. So, once we find the new position, we don’t immediately move the character there, but first check to see what tile occupies that space.
tile coordinate = getTileCoordinates(isoTo2D(iso point), tile height); if (isWalkable(tile coordinate)) { moveCharacter(); } else { //do nothing; }
In the function isWalkable()
, we check whether the level data array value at the given coordinate is a walkable tile or not. We must take care to update the direction in which the character is facing – even if he does not move, as in the case of him hitting a non-walkable tile.
Depth Sorting With Characters
Consider a character and a tree tile in the isometric world.
For properly understanding depth sorting, we must understand that whenever the character’s x- and y-coordinates are less than those of the tree, the tree overlaps the character. Whenever the character’s x- and y-coordinates are greater than that of the tree, the character overlaps the tree.
When they have the same x-coordinate, then we decide based on the y-coordinate alone: whichever has the higher y-coordinate overlaps the other. When they have same y-coordinate then we decide based on the x-coordinate alone: whichever has the higher x-coordinate overlaps the other.
A simplified version of this is to just sequentially draw the levels starting from the farthest tile – that is, tile[0][0]
– then draw all the tiles in each row one by one. If a character occupies a tile, we draw the ground tile first and then render the character tile. This will work fine, because the character cannot occupy a wall tile.
Depth sorting must be done every time any tile changes position. For instance, we need to do it whenever characters move. We then update the displayed scene, after performing the depth sort, to reflect the depth changes.
6. Have a Go!
Now, put your new knowledge to good use by creating a working prototype, with keyboard controls and proper depth sorting and collision detection. Here’s my demo:
Click to give the SWF focus, then use the arrow keys. Click here for the full-sized version.
You may find this utility class useful (I’ve written it in AS3, but you should be able to understand it in any other programming language):
package com.csharks.juwalbose { import flash.display.Sprite; import flash.geom.Point; public class IsoHelper { /** * convert an isometric point to 2D * */ public static function isoTo2D(pt:Point):Point{ //gx=(2*isoy+isox)/2; //gy=(2*isoy-isox)/2 var tempPt:Point=new Point(0,0); tempPt.x=(2*pt.y+pt.x)/2; tempPt.y=(2*pt.y-pt.x)/2; return(tempPt); } /** * convert a 2d point to isometric * */ public static function twoDToIso(pt:Point):Point{ //gx=(isox-isoxy; //gy=(isoy+isox)/2 var tempPt:Point=new Point(0,0); tempPt.x=pt.x-pt.y; tempPt.y=(pt.x+pt.y)/2; return(tempPt); } /** * convert a 2d point to specific tile row/column * */ public static function getTileCoordinates(pt:Point, tileHeight:Number):Point{ var tempPt:Point=new Point(0,0); tempPt.x=Math.floor(pt.x/tileHeight); tempPt.y=Math.floor(pt.y/tileHeight); return(tempPt); } /** * convert specific tile row/column to 2d point * */ public static function get2dFromTileCoordinates(pt:Point, tileHeight:Number):Point{ var tempPt:Point=new Point(0,0); tempPt.x=pt.x*tileHeight; tempPt.y=pt.y*tileHeight; return(tempPt); } } }
If you get really stuck, here’s the full code from my demo (in Flash and AS3 timeline code form):
// Uses senocular's KeyObject class // http://www.senocular.com/flash/actionscript/?file=ActionScript_3.0/com/senocular/utils/KeyObject.as import flash.display.Sprite; import com.csharks.juwalbose.IsoHelper; import flash.display.MovieClip; import flash.geom.Point; import flash.filters.GlowFilter; import flash.events.Event; import com.senocular.utils.KeyObject; import flash.ui.Keyboard; import flash.display.Bitmap; import flash.display.BitmapData; import flash.geom.Matrix; import flash.geom.Rectangle; var levelData=[[1,1,1,1,1,1], [1,0,0,2,0,1], [1,0,1,0,0,1], [1,0,0,0,0,1], [1,0,0,0,0,1], [1,1,1,1,1,1]]; var tileWidth:uint = 50; var borderOffsetY:uint = 70; var borderOffsetX:uint = 275; var facing:String = "south"; var currentFacing:String = "south"; var hero:MovieClip=new herotile(); hero.clip.gotoAndStop(facing); var heroPointer:Sprite; var key:KeyObject = new KeyObject(stage);//Senocular KeyObject Class var heroHalfSize:uint=20; //the tiles var grassTile:MovieClip=new TileMc(); grassTile.gotoAndStop(1); var wallTile:MovieClip=new TileMc(); wallTile.gotoAndStop(2); //the canvas var bg:Bitmap = new Bitmap(new BitmapData(650,450)); addChild(bg); var rect:Rectangle=bg.bitmapData.rect; //to handle depth var overlayContainer:Sprite=new Sprite(); addChild(overlayContainer); //to handle direction movement var dX:Number = 0; var dY:Number = 0; var idle:Boolean = true; var speed:uint = 5; var heroCartPos:Point=new Point(); var heroTile:Point=new Point(); //add items to start level, add game loop function createLevel() { var tileType:uint; for (var i:uint=0; i<levelData.length; i++) { for (var j:uint=0; j<levelData[0].length; j++) { tileType = levelData[i][j]; placeTile(tileType,i,j); if (tileType == 2) { levelData[i][j] = 0; } } } overlayContainer.addChild(heroPointer); overlayContainer.alpha=0.5; overlayContainer.scaleX=overlayContainer.scaleY=0.5; overlayContainer.y=290; overlayContainer.x=10; depthSort(); addEventListener(Event.ENTER_FRAME,loop); } //place the tile based on coordinates function placeTile(id:uint,i:uint,j:uint) { var pos:Point=new Point(); if (id == 2) { id = 0; pos.x = j * tileWidth; pos.y = i * tileWidth; pos = IsoHelper.twoDToIso(pos); hero.x = borderOffsetX + pos.x; hero.y = borderOffsetY + pos.y; //overlayContainer.addChild(hero); heroCartPos.x = j * tileWidth; heroCartPos.y = i * tileWidth; heroTile.x=j; heroTile.y=i; heroPointer=new herodot(); heroPointer.x=heroCartPos.x; heroPointer.y=heroCartPos.y; } var tile:MovieClip=new cartTile(); tile.gotoAndStop(id+1); tile.x = j * tileWidth; tile.y = i * tileWidth; overlayContainer.addChild(tile); } //the game loop function loop(e:Event) { if (key.isDown(Keyboard.UP)) { dY = -1; } else if (key.isDown(Keyboard.DOWN)) { dY = 1; } else { dY = 0; } if (key.isDown(Keyboard.RIGHT)) { dX = 1; if (dY == 0) { facing = "east"; } else if (dY==1) { facing = "southeast"; dX = dY=0.5; } else { facing = "northeast"; dX=0.5; dY=-0.5; } } else if (key.isDown(Keyboard.LEFT)) { dX = -1; if (dY == 0) { facing = "west"; } else if (dY==1) { facing = "southwest"; dY=0.5; dX=-0.5; } else { facing = "northwest"; dX = dY=-0.5; } } else { dX = 0; if (dY == 0) { //facing="west"; } else if (dY==1) { facing = "south"; } else { facing = "north"; } } if (dY == 0 && dX == 0) { hero.clip.gotoAndStop(facing); idle = true; } else if (idle||currentFacing!=facing) { idle = false; currentFacing = facing; hero.clip.gotoAndPlay(facing); } if (! idle && isWalkable()) { heroCartPos.x += speed * dX; heroCartPos.y += speed * dY; heroPointer.x=heroCartPos.x; heroPointer.y=heroCartPos.y; var newPos:Point = IsoHelper.twoDToIso(heroCartPos); //collision check hero.x = borderOffsetX + newPos.x; hero.y = borderOffsetY + newPos.y; heroTile=IsoHelper.getTileCoordinates(heroCartPos,tileWidth); depthSort(); //trace(heroTile); } tileTxt.text="Hero is on x: "+heroTile.x +" & y: "+heroTile.y; } //check for collision tile function isWalkable():Boolean{ var able:Boolean=true; var newPos:Point =new Point(); newPos.x=heroCartPos.x + (speed * dX); newPos.y=heroCartPos.y + (speed * dY); switch (facing){ case "north": newPos.y-=heroHalfSize; break; case "south": newPos.y+=heroHalfSize; break; case "east": newPos.x+=heroHalfSize; break; case "west": newPos.x-=heroHalfSize; break; case "northeast": newPos.y-=heroHalfSize; newPos.x+=heroHalfSize; break; case "southeast": newPos.y+=heroHalfSize; newPos.x+=heroHalfSize; break; case "northwest": newPos.y-=heroHalfSize; newPos.x-=heroHalfSize; break; case "southwest": newPos.y+=heroHalfSize; newPos.x-=heroHalfSize; break; } newPos=IsoHelper.getTileCoordinates(newPos,tileWidth); if(levelData[newPos.y][newPos.x]==1){ able=false; }else{ //trace("new",newPos); } return able; } //sort depth & draw to canvas function depthSort() { bg.bitmapData.lock(); bg.bitmapData.fillRect(rect,0xffffff); var tileType:uint; var mat:Matrix=new Matrix(); var pos:Point=new Point(); for (var i:uint=0; i<levelData.length; i++) { for (var j:uint=0; j<levelData[0].length; j++) { tileType = levelData[i][j]; //placeTile(tileType,i,j); pos.x = j * tileWidth; pos.y = i * tileWidth; pos = IsoHelper.twoDToIso(pos); mat.tx = borderOffsetX + pos.x; mat.ty = borderOffsetY + pos.y; if(tileType==0){ bg.bitmapData.draw(grassTile,mat); }else{ bg.bitmapData.draw(wallTile,mat); } if(heroTile.x==j&&heroTile.y==i){ mat.tx=hero.x; mat.ty=hero.y; bg.bitmapData.draw(hero,mat); } } } bg.bitmapData.unlock(); //add character rectangle } createLevel();
Registration Points
Give special consideration to the registration points of the tiles and the hero. (Registration points can be thought of as the origin points for each particular sprite.) These generally won’t fall inside the image, but rather will be the top left corner of the sprite’s bounding box.
We will have to alter our drawing code to fix the registration points correctly, mainly for the hero.
Collision Detection
Another interesting point to note is that we calculate collision detection based on the point where the hero is.
But the hero has volume, and cannot be accurately represented by a single point, so we need to represent the hero as a rectangle and check for collisions against each corner of this rectangle so that there are no overlaps with other tiles and hence no depth artifacts.
Shortcuts
In the demo, I simply redraw the scene again each frame based on the new position of the hero. We find the tile which the hero occupies and draw the hero on top of the ground tile when the rendering loops reach those tiles.
But if we look closer, we will find that there is no need to loop through all the tiles in this case. The grass tiles and the top and left wall tiles are always drawn before the hero is drawn, so we don’t ever need to redraw them at all. Also, the bottom and right wall tiles are always in front of the hero and hence drawn after the hero is drawn.
Essentially, then, we only need to perform depth sorting between the wall inside the active area and the hero – that is, two tiles. Noticing these kinds of shortcuts will help you save a lot of processing time, which can be crucial for performance.
Conclusion
By now, you should have a great basis for building isometric games of your own: you can render the world and the objects in it, represent level data in simple 2D arrays, convert between Cartesian and isometric coordiates, and deal with concepts like depth sorting and character animation. Enjoy creating isometric worlds!