Create Your Own Isometric Tile-Based Game: Part 1

iso_examples_1200x630

Clash of Clans, Diablo, Age of Empires, Bastion. All of these popular games use isometric projection. Well, technically, it’s dimetric but it seems isometric is the more common term used amongst developers. Essentially, what this method does, is take pre-rendered 3D graphics and displays them in-game as 2D sprites. So you get a simulated 3D environment but you’re still developing within a 2D framework. If you’d like to know more about the theory, this wiki article‘s not a bad place to start. However, in this tutorial I’ll be focusing on the practical component, so you can get your own isometric 2.5D game up and running ASAP. This is part 1 of an ongoing series. In this installment, we will build a foundation to render our level in both 2D and isometric views.

add_droid

This tut is an adaption of a Flash tutorial by Juwal Bose. I’ve just translated it into Swift and made the necessary adjustments for an iOS conversion.

Assumed is some basic level of Swift programming knowledge. If you’re new to Swift, I would reccomend first getting familiar with the language. Check out Apple’s reference guide and have a look at the introductory Swift tutorials on raywenderich.com.

Getting Started

First thing’s first. Download this package containing the resources you’ll need for this tutorial:

Start up xCode 6, Click File > New > Project, select iOS > Application > Game and click Next.

Fill out the options as follows:

  • Product Name: IsoGame
  • Language: Swift
  • Game Technology: SpriteKit
  • Devices: iPhone

Click Next, choose a folder for your project and click Create.

This game will be designed for landscape orientation, so click on your IsoGame project in the Project Navigator to open the Target Settings, then in the General tab, uncheck the Portrait Device Orientation:

Screen Shot 2015-01-16 at 5.08.51 pm

We’re not going to need the GameScene.sks file so delete that from your Project Navigator and select Move to trash when prompted.

Unzip the resources you just downloaded. Drag the sprites.atlas folder into the IsoGame folder in your Project Navigator. Make sure Destination: Copy items if needed is checked.

Screen Shot 2015-02-02 at 4.57.04 pm

Click on GameViewController.swift and replace the contents of the file with this:

import UIKit
import SpriteKit

class GameViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let scene = GameScene(size: view.bounds.size)
        let skView = view as! SKView
        skView.showsFPS = true
        skView.showsNodeCount = true
        skView.ignoresSiblingOrder = true
        scene.scaleMode = .ResizeFill
        skView.presentScene(scene)
    }

    override func prefersStatusBarHidden() -> Bool {
        return true
    }
}

All we’re doing here, is getting rid of all the clutter we don’t need and just leaving in some standard code that presents the scene in the skView.

That’s the boring stuff out of the way, time to write some code. Select GameScene.swift and replace the contents of the file with this initial code:

import SpriteKit

class GameScene: SKScene {

    //1
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    //2
    let view2D:SKSpriteNode
    let viewIso:SKSpriteNode

    //3
    let tiles = [
                [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]
                ]
    let tileSize = (width:32, height:32)

    //4
    override init(size: CGSize) {

        view2D = SKSpriteNode()
        viewIso = SKSpriteNode()

        super.init(size: size)
        self.anchorPoint = CGPoint(x:0.5, y:0.5)
    }

    //5
    override func didMoveToView(view: SKView) {

        let deviceScale = self.size.width/667

        view2D.position = CGPoint(x:-self.size.width*0.45, y:self.size.height*0.17)
        view2D.xScale = deviceScale
        view2D.yScale = deviceScale
        addChild(view2D)

        viewIso.position = CGPoint(x:self.size.width*0.12, y:self.size.height*0.12)
        viewIso.xScale = deviceScale
        viewIso.yScale = deviceScale
        addChild(viewIso)
    }
}

Ok, so let’s go over what we’ve done here:

  1. This is just mandatory code that must be included when subclassing SKScene. Otherwise, you’ll get an error.
  2. Declaration of your constants. Their values will be assigned in the class initialisation. We’ll be setting up 2 views of the same scene. view2D will be a top down view so we can easily see mapping of coordinates in a simple 2D grid. viewIso will be the isometric view that we would use for our games final rendering.
  3. These constants can be declared and set right away, as they are not instances. tiles is a nested array of ids that we will use to generate our tile map. Formatting the array like this allows us to visualise how the map will display. 1 represents a wall and 0 represents ground (walkable area), so this map indicates we are creating a simple, square area enclosed by 4 walls. tileSize is what it seems, the constant width and height of each tile.
  4. Our class initialisation. Assigning SKSpriteNode instances to our view constants, the standard super.init code (required when subclassing SKScene) and then we centre our scenes anchorPoint (this is just a preference).
  5. As the view is loaded we position our 2 sub views so we can easily see and interact with either/or. The deviceScale constant adjusts the scale to fit dynamically to the screen size of whatever device you’re testing on.

Next we will add the code to define the tile data. Paste this snippet at the top of your GameScene just after the import SpriteKit line but before the class GameScene definition.

enum Tile: Int {

    case Ground
    case Wall

    var description:String {
        switch self {
        case Ground:
            return "Ground"
        case Wall:
            return "Wall"
        }
    }

    var image:String {
        switch self {
        case Ground:
            return "ground"
        case Wall:
            return "wall"

        }
    } 
}

The enum (enumeration) data type has been extended during Apples transition from Obj-C to Swift. There’s now all sorts of cool stuff you can do with them. Be sure to read up on the documentation, so you can take full advantage of the advanced functionality.

In the code above we created a new enum Tile definition and gave it the option to either be assigned as a Ground tile or a Wall tile with respective description and image variables. Note that the image string is the name of the file that we’ll get from the sprite.atlas folder in our project navigator. You don’t need to include the .png file extension.

Rendering the Top Down 2D View

Next, add this function after the didMoveToView method, but still inside the GameScene class

func placeTile2D(image:String, withPosition:CGPoint) {

    let tileSprite = SKSpriteNode(imageNamed: image)

    tileSprite.position = withPosition

    tileSprite.anchorPoint = CGPoint(x:0, y:0)

    view2D.addChild(tileSprite)

}

This function creates and places a sprite in the view2D instance. It’s important to set the anchorPoint to 0,0 (bottom, left). We’ll use this method in the next step, to place our tiles.

Directly after the last function add this code:

func placeAllTiles2D() {

        for i in 0..<tiles.count {

            let row = tiles[i];

            for j in 0..<row.count {
                let tileInt = row[j]

                //1
                let tile = Tile(rawValue: tileInt)!

                //2
                var point = CGPoint(x: (j*tileSize.width), y: -(i*tileSize.height))

                placeTile2D(tile.image, withPosition:point)
            }

        }

}

In the above code, we loop through the tiles array and do the following for each iteration:

  1. Assign a new Tile enum, setting it’s type via the id value, e.g. because we used an enum for our Tile, 0 = Ground, 1 = Wall
  2. We then stack each tileSprite in a grid, left to right, then top to bottom. Note: We need to invert the y value because in the SpriteKit coordinate system, y values increase as you move up the screen and decrease as you move down.

And Finally, we need to call the methods we just created, so add the line placeAllTiles2D() at the base of the didMoveToView method so it now looks like this:

override func didMoveToView(view: SKView) {

        let deviceScale = self.size.width/667

        view2D.position = CGPoint(x:-self.size.width*0.45, y:self.size.height*0.17)
        view2D.xScale = deviceScale
        view2D.yScale = deviceScale
        addChild(view2D)

        viewIso.position = CGPoint(x:self.size.width*0.12, y:self.size.height*0.12)
        viewIso.xScale = deviceScale
        viewIso.yScale = deviceScale
        addChild(viewIso)

        placeAllTiles2D()

}

Go ahead and run your app, you should see something like this:

view2d

See how the tiles array is identical to the tile arrangement in the scene?

let tiles = [
                [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]
                ]

If you want, go ahead and change around some of the ground and wall tile ids in the array and watch how the scene reflects the updates. Remember to put it back the way you found it, when you’re done :)

Rendering the Isometric View

Setting up the Iso view is very similar to the way you went about constructing the 2D view. The main difference being that there’s a little bit of math used to convert the placement coordinates and the tiles themselves are drawn in an isometric projection. E.g. the tile images we used for the 2D view look like this:

Ground
ground@2x
Wall
wall@2x

Where the isometric view tile images look like this:

Ground
iso_ground@2x
Wall
iso_wall@2x

Before we start placing tiles, let’s overload some operators to help streamline our code later on. Operator overloading is another great feature that ships with Swift. Essentially it’s configuring standard operators like +,-,/ or * to work with any given data type. There’s a great tut here that explains the basics, if you’re not familiar with the concept.

Paste this code at the top of your GameScene.swift file, just after the import SpriteKit line but before the Tile Enum definition.

func + (left: CGPoint, right: CGPoint) -> CGPoint {
    return CGPoint(x: left.x + right.x, y: left.y + right.y)
}

func - (left: CGPoint, right: CGPoint) -> CGPoint {
    return CGPoint(x: left.x - right.x, y: left.y - right.y)
}

func * (point: CGPoint, scalar: CGPoint) -> CGPoint {
    return CGPoint(x: point.x * scalar.x, y: point.y * scalar.y)
}

func / (point: CGPoint, scalar: CGPoint) -> CGPoint {
    return CGPoint(x: point.x / scalar.x, y: point.y / scalar.y)
}

So basically, we’ve just defined a set of operator overloads that will operate on CGPoint data types. Don’t think too much on it now, it will make more sense when we utilise them later on.

The next thing we’ll need to do, is create the coordinate conversion methods. For any Isometric game, you’ll need to convert point coordinates from 2D to Isometric and from Isometric back to 2D. Add this code to the bottom of your GameScene class:

func point2DToIso(p:CGPoint) -> CGPoint {

        //invert y pre conversion
        var point = p * CGPoint(x:1, y:-1)

        //convert using algorithm
        point = CGPoint(x:(point.x - point.y), y: ((point.x + point.y) / 2))

        //invert y post conversion
        point = point * CGPoint(x:1, y:-1)

        return point

}
func pointIsoTo2D(p:CGPoint) -> CGPoint {

        //invert y pre conversion
        var point = p * CGPoint(x:1, y:-1)

        //convert using algorithm
        point = CGPoint(x:((2 * point.y + point.x) / 2), y: ((2 * point.y - point.x) / 2))

        //invert y post conversion
        point = point * CGPoint(x:1, y:-1)

        return point

}

These functions would ideally be executed in 1 line of code as a single algorithm. However, my math is pretty rudimentary, so I’ve just taken the algorithms from Juwal Bose’s Flash tutorial. The 1 inconsistency being that SpriteKits coordinate system has an inverted y-axis when compared to Flash e.g…

coordinate_systems_spritekitcoordinate_systems_flash

…so I’ve added the extra y-axis inversion code to accommodate this discrepancy.

Also, if you didn’t notice, we just made use of our operator overloading. In the last line of each function, before the return statement, you can see we’ve multiplied 2 CGPoints together e.g.

point = point * CGPoint(x:1, y:-1)

Without using our custom * CGPoint operator, we would need to process the x and y values separately, then reconstruct a CGPoint from the result but this is all taken care of with 1 key stroke!

great_job
With the conversion functions taken care of, we can now create the tile placing functions, just like we did for the 2D view. Add this code to your GameScene class. Put it just above the 2 conversion functions we just wrote.

func placeTileIso(image:String, withPosition:CGPoint) {

    let tileSprite = SKSpriteNode(imageNamed: image)

    tileSprite.position = withPosition

    tileSprite.anchorPoint = CGPoint(x:0, y:0)

    viewIso.addChild(tileSprite)
}

It’s exactly the same as the placeTile2D method, except the tileSprite gets added to viewIso instead of view2D.

Now add this method directly under the placeTileIso func you just added

func placeAllTilesIso() {

        for i in 0..<tiles.count {

            let row = tiles[i];

            for j in 0..<row.count {
                let tileInt = row[j]

                let tile = Tile(rawValue: tileInt)!

                //1
                var point = point2DToIso(CGPoint(x: (j*tileSize.width), y: -(i*tileSize.height)))

                //2
                placeTileIso(("iso_"+tile.image), withPosition:point)

            }
        }
}

Once again, this is very similar to the placeAllTiles2D method you added earlier. The 2 significant differences are:

  1. We convert the point information from the tiles array, using our new point2DToIso conversion function.
  2. Also, we prefix the image name string with iso_. This is just to comply with the way I named the files. you can see in the project navigator, all the art for the isometric rendering is prefixed with iso_.

Almost there! Just add the line placeAllTilesIso() at the base of the didMoveToView method so it now looks like this:

override func didMoveToView(view: SKView) {

        let deviceScale = self.size.width/667

        view2D.position = CGPoint(x:-self.size.width*0.45, y:self.size.height*0.17)
        view2D.xScale = deviceScale
        view2D.yScale = deviceScale
        addChild(view2D)

        viewIso.position = CGPoint(x:self.size.width*0.12, y:self.size.height*0.12)
        viewIso.xScale = deviceScale
        viewIso.yScale = deviceScale
        addChild(viewIso)

        placeAllTiles2D()
        placeAllTilesIso()
}

That’s It. Run your app.

both_views

Which way’s North?

We now have both our views rendering the tile map. Now we want to add some directional data.

Update your Tile enum code so it looks like this:

enum Tile: Int {

    case Ground //0
    case Wall_n //1
    case Wall_ne //2
    case Wall_e //3
    case Wall_se //4
    case Wall_s //5
    case Wall_sw //6
    case Wall_w //7
    case Wall_nw //8

    var description:String {
        switch self {
        case Ground:
            return "Ground"
        case Wall_n:
            return "Wall North"
        case Wall_ne:
            return "Wall North East"
        case Wall_e:
            return "Wall East"
        case Wall_se:
            return "Wall South East"
        case Wall_s:
            return "Wall South"
        case Wall_sw:
            return "Wall South West"
        case Wall_w:
            return "Wall West"
        case Wall_nw:
            return "Wall North West"
        }
    }

    var image:String {
        switch self {
        case Ground:
            return "ground"
        case Wall_n:
            return "wall_n"
        case Wall_ne:
            return "wall_ne"
        case Wall_e:
            return "wall_e"
        case Wall_se:
            return "wall_se"
        case Wall_s:
            return "wall_s"
        case Wall_sw:
            return "wall_sw"
        case Wall_w:
            return "wall_w"
        case Wall_nw:
            return "wall_nw"
        }
    } 
}

As you can see, we’ve removed the default wall cases and replaced them with specific cases for each of our possible directions. As mentioned earlier, Enums generate raw values automatically, Ground = 0, Wall_n = 1, Wall_ne = 3 etc. Our tile placing functions already utilise these raw values, so all we have to do is update the tiles array.

In GameScene.swift, modify your tiles array to look like this:

let tiles = [
            [8, 1, 1, 1, 1, 2],
            [7 ,0, 0, 0, 0, 3],
            [7 ,0, 0, 0, 0, 3],
            [7 ,0, 0, 0, 0, 3],
            [7 ,0, 0, 0, 0, 3],
            [6, 5, 5, 5, 5, 4]
            ]

So if we refer back to our Enum definition, we can see the first element in the tiles array has a value of 8, which will produce a North West Wall Tile, the next value is 1, which will give us a North Wall Tile and so on.

Run your app and see how this has affected our rendering.

directions
Now you have a clear sense of orientation for your isometric projection and unique ids for each wall type. That’s all very good but it’s not looking very inspiring. Let’s substitute in some 3D artwork.

In the placeAllTilesIso() method, change this line:

placeTileIso(("iso_"+tile.image), withPosition:point)

To this:

placeTileIso(("iso_3d_"+tile.image), withPosition:point)

Run your app.

art3d

Alright, our isometric view is starting to look like a real level. All we did here was swap the placeholder images:

tile_comparison_iso

For the pre-rendered 3D images:

tile_comparison_iso_3d

Adding a Character

In the Tile enum, add these cases after the Wall_nw case:

    case Droid_n
    case Droid_ne
    case Droid_e
    case Droid_se
    case Droid_s
    case Droid_sw
    case Droid_w
    case Droid_nw

Then add the respective cases for the description variable:

        case Droid_n:
            return "Droid North"
        case Droid_ne:
            return "Droid North East"
        case Droid_e:
            return "Droid East"
        case Droid_se:
            return "Droid South East"
        case Droid_s:
            return "Droid South"
        case Droid_sw:
            return "Droid South West"
        case Droid_w:
            return "Droid West"
        case Droid_nw:
            return "Droid North West"

and image variable:

        case Droid_n:
            return "droid_n"
        case Droid_ne:
            return "droid_ne"
        case Droid_e:
            return "droid_e"
        case Droid_se:
            return "droid_se"
        case Droid_s:
            return "droid_s"
        case Droid_sw:
            return "droid_sw"
        case Droid_w:
            return "droid_w"
        case Droid_nw:
            return "droid_nw"

We’re going to face the droid character east, so when placing the character, the enum raw value we want to use is 11 (Droid_e).
Update your tiles array to include the new id:

    let tiles = [
        [8, 1, 1, 1, 1, 2],
        [7 ,0, 0, 0, 0, 3],
        [7 ,0, 11, 0, 0, 3],
        [7 ,0, 0, 0, 0, 3],
        [7 ,0, 0, 0, 0, 3],
        [6, 5, 5, 5, 5, 4]
    ]

Now, if we were to render at this point, the droid character would render but the ground tile underneath it would not (because the droid tile has taken the place of the ground tile in the array). So we need to add a bit of code to the tile placement methods.

Add this snippet of code to your placeAllTiles2D() method, just above the placeTile2D... line:

if (tile == Tile.Droid_e) {
    placeTile2D(Tile.Ground.image, withPosition:point)
}

This will add an extra Ground tile at the same position as the Droid tile.

Then do the same in the placeAllTilesIso() method, e.g.

if (tile == Tile.Droid_e) {
    placeTileIso(("iso_3d_"+Tile.Ground.image), withPosition:point)
}

Render your app.

add_droid
Now it may look like a Christmas themed Roomba, but that right there, is a droid.

Conclusion

Congratulations! You’ve just completed part 1 of the ongoing isometric tutorial series. Here is a sample project with all of the code to this point:

In part 2, we’ll go over some basic interactivity and animation. We’ll tackle depth sorting and we’ll spend some time optimising and structuring our code, to improve efficiency and performance. Don’t forget, you can sign up to the newsletter or follow us on Twitter/Facebook to be notified of future episodes in the series. Cheers!

Go to part 2 in the isometric series now >

Related Links:

Dave Longbottom

Dave is the indie dev behind Big Sprite Games. Read more on the About page.

13 thoughts on “Create Your Own Isometric Tile-Based Game: Part 1

  1. Hey I just downloaded the zip file ran it and I don’t know if it’s a bug in iOS 9 or what but, four of the South Wall tiles are not rendering for some odd reason. Only the first “S” wall tile is rendered.

    If I change the last row of array values to 6,5,4,3,2,4 they all render properly though although obviously, they’re not the correct tiles.

    Anyone else seeing the same?

  2. Seems like a bug in iOS 9 not honouring draw orders and zPositions.

    Change the array 11 (Droid_e tile) back to 0 (Ground tile) fixed it.

  3. Hi there
    first: great tutorial! :)

    Can someone explain me why using 667 for scaling factor?
    “let deviceScale = self.size.width/667”
    thanks in advance

    lukas

    1. Hi Lukas, It’s not really important (it’s the iPhone 6 screen size in points). It’s just used so when the app is built/run on devices with other screen sizes, it will scale to retain the general layout/ratio as seen on the iPhone 6. Cheers

  4. Hey Dave, Thanks for the great tutorial!

    I was wondering if you could give me some tips / ideas on how you create these isometric tiles.

    Thanks,

    Eli

  5. Hi Dave,

    You can ignore the previous comment about the best way to create isometric tiles, I have worked a nice way by creating grids in photoshop.

    I am however having rather strange issues with the tiles and there layout and I’m not sure whats causing them.
    I copied your code completely and this issue was happening before I changed the images.

    If you could view these images http://imgur.com/a/kYJop

    I have created an album to explain the issues i’m having but basically the titles are not lining up correctly & I don’t know why

    I’m using Xcode 8.1
    Swift 3
    Deployment 9.0

    Thanks in advance for any help.

    Eli

    1. Hey Eli, looks like a problem with the depth sorting. There may have been some changes in Swift since I published this post that are causing it. The next tutorial (part 2) in this series covers depth sorting. I’d start there.

Leave a Reply

Your email address will not be published. Required fields are marked *

Use Markdown backticks to wrap your code blocks e.g.

```
// This is my example code block
func myFunction( ) -> Bool {
    return true
}
```

You may also use these HTML tags and attributes: <a href="" title=""> <blockquote cite=""> <pre> <cite> <code> <em> <strong>