Building an HTML5 Canvas Orthogonal Tile Map

Posted .

Today, we’ll be laying down the code structure and rendering for a simple 2D tile map. This map will also feature a toggleable grid with coordinates. The result of this effort can be seen here.

Image of finished tile map

Warm Up

Just so you’re aware ahead of time, the following technologies and techniques will be used during this tutorial:

  1. HTML5 Canvas
  2. ES6 / ES2015 JavaScript
  3. OOP Inheritance

It’s almost always a good idea to abstract your thought process a bit and reduce the big-picture idea into a number of simple “objects.” With OOP programming, an “object” and all of its properties and methods are described in a class. Thankfully, classes were introduced in ES6. I’ve always enjoyed programming OOP-style because it forces you to break the code down into real life objects.

With that said, let’s get started.

Start Coding

Markup

Using CodePen, or perhaps your own local environment, we’ll need to add the markup below:

<div>
  <input id="toggle-grid" type="checkbox"><label for="toggle-grid">Show grid?</label>
</div>
<canvas id="orthogonal-map" class="canvas-map" width="704" height="576"></canvas>

The markup is super simple, mainly because the <canvas> element will be responsible for rendering the individual tiles on our map. Please notice that it is important that we set both the width and height of our <canvas> element. If you prefer, the size could also be set dynamically with JavaScript.

Styles

We’ll also need to drop in a few simple styles to lay things out more nicely.

body {
  padding: 15px;
  font-family: Verdana, sans-serif;
  background-color: #666;
}

.canvas-map {
  margin-top: 10px;
  background-color: black;
  box-shadow: 0 0 16px rgba(0, 0, 0, 0.5);
}

label {
  margin-left: 10px;
  color: #ccc;
  font-size: 14px;
  user-select: none;
}

JavaScript: The Real Part

Now we’ll jump into the fun part —the JavaScript.

Let’s begin by breaking down the environment we’re about to create into a couple of objects. First, we need an object that represents the map as a whole. So, we can create a class called Map.

/**
  Map class
 */
class Map {
  // code for Map class
}

Now, you should determine the properties and methods that could be needed for a map.

The only way to draw stuff on an HTML5 canvas is to get a reference for the graphics 2D context from the <canvas> element. With the context, various drawing commands can be used as outlined on MDN. Because the context property will be used often, it should be stored as a property on the new Map class we’ve created recently.

We will now add both canvas and ctx as properties to our Map class:

/**
  Map class
 */
class Map {
  constructor (selector) {
    this.canvas = document.getElementById(selector)
    this.ctx = this.canvas.getContext('2d')
  }
}

Since the constructor and some initial properties have been defined, it now makes sense to instantiate an instance of our new Map class when the page loads.

// Init canvas tile map on document ready
document.addEventListener('DOMContentLoaded', function () {

  // Init map
  const map = new Map('orthogonal-map')
})

The canvas element with ID of orthogonal-map gets passed to the Map class. When the new keyword is used, the class’ constructor() method is called automatically. Typically, it is best practice to define class properties within the constructor. The keyword this must be used when defining or referencing properties because this refers to the actual instance of the object. Writing code in this manner allows us to easily create multiple maps with unique properties.

Still, nothing happens when reloading your browser, apparently. Well, we haven’t told our context property on our canvas element to draw anything yet. Nor have we created any data that represents the types of tiles to be drawn on our map. Let’s do that next.

Representing the Map Data

To keep things simple, there will only be two tile types: Sea and Land. All tile types should be represented in an enum like below:

// Possible tile types
const TILE_TYPES = {
  0: { name: 'Sea', color: 'lightBlue' },
  1: { name: 'Land', color: 'wheat' }
}

This simple data structure will make it much easier to create the data for our map. All we need to do now is create a 2-dimentional array that contains 0s and 1s —where 0 represents a Sea tile, and 1 represents a Land tile. Our TILE_TYPES enum handles the association of additional data for each tile type: name and color.

The data for our map can now be defined as such:

// Map tile data
const mapData = [
  [1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1],
  [1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1],
  [1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0],
  [1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0],
  [1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
  [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
  [1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1],
  [1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1],
  [1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1],
]

Since we now have some data for our map defined, another property can be added to our Map class and passed through the constructor. While we’re doing this, let’s also add an opts parameter to store any additional configuration options to our map. In this case, tileSize would be a useful option. We’ll pass { tileSize: 64 } so the render logic can draw 64 x 64 pixel squares.

/**
  Map class
 */
class Map {
  constructor (selector, data, opts) {
    this.canvas = document.getElementById(selector)
    this.ctx = this.canvas.getContext('2d')
    this.data = data
    this.tileSize = opts.tileSize
  }
}
// Pass map data and tileSize opts to Map constructor
const map = new Map('orthogonal-map', mapData, { tileSize: 64 })
// ...

Even after all this, you’ll still see nothing! So we will now tell our canvas context to draw some stuff!

Logic for Drawing the Tiles

In our Map class, we will now create the draw() method. We will also call the draw() method immediately after a Map instance is created by calling this.draw() in the constructor.

class Map {

  constructor (selector, data, opts) {
    this.canvas = document.getElementById(selector)
    this.ctx = this.canvas.getContext('2d')
    this.data = data
    this.tileSize = opts.tileSize

    // Actually draw the map to the canvas
    this.draw()
  }

  draw () {
    // drawing logic
  }
}

In our new draw() method, we’ll iterate through our map data using a nested for loop. The total number of columns and rows are stored by getting the length of the 2-dimentaional map data both horizontally and vertically. Because we have already declared the TILE_TYPES enum, the type of tile that will be rendered can be referenced by using tileId as the key.

draw () {
  const numCols = this.data[0].length
  const numRows = this.data.length

  // Iterate through map data and draw each tile
  for (let y = 0; y < numRows; y++) {
    for (let x = 0; x < numCols; x++) {

      // Get tile ID from map data
      const tileId = this.data[y][x]

      // Use tile ID to determine tile type from TILE_TYPES (i.e. Sea or Land)
      const tileType = TILE_TYPES[tileId]

      // TODO: Draw the tile on the canvas
    }
  }
}

Now that we are iterating through our map data and determining the tile type for each, there is now enough information to actually draw the tile. We could simply dump a bunch of context 2D drawing commands into our nested for loop, but that would result into code that is too long and difficult to follow.

So let’s now think about what we’re actually going to be drawing to our canvas. It’s quite simple in this case —we essentially just need to draw a bunch of squares. Knowing this, we should add another type of object to our world.

Create a new class called Tile. This new Tile class will be similar to our Map class in that it will be responsible for its very own draw() method. The Tile class will also have a defined set of properties. Indeed, a Tile needs to be rendered at a certain size and type.

class Tile {
  constructor (size, type) {
    this.size = size
    this.type = type
  }
}

We have created this Tile class to separate our concerns. Doing so is a great way to prevent any unnecessary clutter in our code. We could’ve had all the rendering logic for our tiles in the draw() method from the Map class. However, if we proceeded down that path, our code would’ve become more and more difficult to follow. Just imagine trying to write a complicated turn-based strategy game. Most likely, a tile map is involved, and each tile could have lots of data associated with it. For instance, a Tile would need to know what “resources” were on it such as iron, gold, cattle, etc. The Sea tile may not allow land units to cross. Logic like that needs to be handled somewhere that would make sense in the real world. All and all, there are numerous reasons why you should think about separating your concerns when architecting you code.

Back to our draw() method from the Map class, we must now instantiate a new Tile per iteration and command it to draw itself.

/**
  Map class
 */
class Map {
  // ...
  draw () {
    // Nested for loop...
    // Create tile instance and draw to our canvas
    new Tile(this.tileSize, tileType, this.ctx).draw(x, y)
    // ...
  }
}

The code changes above won’t do anything just yet. We must now go back to our Tile class and create it’s draw() method. Also, we’ll need to pass our map’s drawing context to our Tile objects.

/**
  Tile class
 */
class Tile {

  constructor (size, type, ctx) {
    this.size = size
    this.type = type
    this.ctx = ctx
  }

  draw (x, y) {
    // Store positions
    const xPos = x * this.size
    const yPos = y * this.size

    // Draw tile
    this.ctx.fillStyle = this.type.color
    this.ctx.fillRect(xPos, yPos, this.size, this.size)
  }
}

As seen in the draw() method above, the X and Y position of the tile is computed by taking the current x & y values from the nested for loop from the draw() method in class Map. These values are then multiplied by the size of the tile. When our map was initialized, we had passed tileSize: 64. So, we’ll get 64 x 64 pixel tiles drawn next to each other. The actual drawing happens when we refer to the context. In this case, this.ctx is the reference to our context and fillStyle sets the color of the tile based on it’s type. Then, fillRect draws and fills our square tile with color.

The complete map should now be drawing to the canvas:

See the Pen HTML5 Canvas Orthogonal Tile Map (no inheritance or grid) by Keenan Staffieri (@keenode) on CodePen.

Inheritance: Paving the Way for More Map Renderers

Great, we’ve got our Map class to render a basic tile map by feeding it a simple 2-dimentional array as our data. Still, the show grid checkbox doesn’t do anything. But before we dwell on that, let’s consider another situation.

Let’s say you were responsible for creating some software that allows for the creation and editing for various kinds of 2D maps. The type of map we just created is the most basic kind —an Orthogonal map. But what if someone wanted to also create Isometric, or even Hexagonal maps? Well now we would need several variations of our draw() method in our Map class to handle the calculations for laying out tiles those formats. The map data can still be the same 2-dimentional array we declared previously. The only difference between the map types is the rendering logic.

How could we cleanly handle this, without adding nasty if or switch statements to the draw() method in the Map class?

Well, that my friend could be solved with some basic OOP inheritance. We can treat the Map class as a “base” class that harbors only the most basic properties and methods that a more specific type of map would need. Knowing this, the classes could be defined as follows:

class Map {} // Our base class
class OrthogonalMap extends Map {} // Specific map type "Orthogonal" inherits properties and methods from Map
class IsometricMap extends Map {} // Specific map type "Isometric" inherits properties and methods from Map
class HexagonalMap extends Map {} // Specific map type "Hexagonal" inherits properties and methods from Map

This tutorial will not go over the required changes for displaying Isometric or Hexagonal map types, but an example of how to set up an Orthogonal map through basic inheritance will be explained. I will write up future tutorials on how to draw Isometric and Hexagonal maps, so stay tuned!

Start by creating a new class called OrthogonalMap that extends Map. Now take the current draw() method from the Map class and paste it into the OrthogonalMap class. Remove this.draw() from the constructor on class Map. Those classes should now look like below:

/**
  Map class
 */
class Map {

  constructor (selector, data, opts) {
    this.canvas = document.getElementById(selector)
    this.ctx = this.canvas.getContext('2d')
    this.data = data
    this.tileSize = opts.tileSize
  }
}

/**
  OrthogonalMap class
 */
class OrthogonalMap extends Map {

  constructor (selector, data, opts) {
    super(selector, data, opts)
    this.draw()
  }

  draw () {
    const numCols = this.data[0].length
    const numRows = this.data.length

    // Iterate through map data and draw each tile
    for (let y = 0; y < numRows; y++) {
      for (let x = 0; x < numCols; x++) {

        // Get tile ID from map data
        const tileId = this.data[y][x]

        // Use tile ID to determine tile type from TILE_TYPES (i.e. Sea or Land)
        const tileType = TILE_TYPES[tileId]

        // Create tile instance and draw to our canvas
        new Tile(this.tileSize, tileType, this.ctx).draw(x, y)
      }
    }
  }
}

Please take notice of the constructor on the OrthogonalMap class. The call, super(selector, data, opts) passes the parameters up the parent class’ constructor, which in this case is Map. The constructor in class Map simply handles storing the properties for any instance of Map.

Lastly, update the instantiation of our map object to use OrthogonalMap instead:

// Init orthogonal map
const map = new OrthogonalMap('orthogonal-map', mapData, { tileSize: 64 })

Because we added some inheritance, it’ll be easier to handle the different drawing logic that would be required for other types of maps.

Result:

See the Pen HTML5 Canvas Orthogonal Tile Map (no grid) by Keenan Staffieri (@keenode) on CodePen.

Toggling the Grid and Coordinates

Now on to the finishing touch: getting that show grid checkbox to actually do something.

First, let’s add a simple click event listener to our checkbox. Yes, it’s not the best way to do this, but we are simply adding this to demonstrate more context drawing features.

Add the following code after the OrthogonalMap instantiation:

// Bind click event to show grid checkbox toggle
const cb = document.getElementById('toggle-grid')
cb.addEventListener('click', function () {
  map.toggleGrid()
})

Since we want the ability to toggle a grid on ANY kind of map, let’s add a toggleGrid() method to the Map class.

toggleGrid () {
  // Toggle show grid option
  this.showGrid = !this.showGrid

  // Redraw map
  this.draw()
}

When toggling the grid, we’ll have to redraw the map by calling the draw() method again. It is best practice to clear the canvas using clearRect before redrawing anything else. Add a draw() method and the showGrid property to the constructor on the Map class.

/**
  Map class
 */
class Map {

  constructor (selector, data, opts) {
    // ...
    this.showGrid = false
  }

  draw () {
    // Clear canvas before redrawing
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
  }

  // toggleGrid method
}

Next, we’ll need to make some minor additions to the draw() method from the OrthogonalMap class.

class OrthogonalMap extends Map {
  // constructor

  draw () {
    super.draw() // Call draw() method from Map class

    // ...

    // Iterate through map data and draw each tile
    for (let y = 0; y < numRows; y++) {
      for (let x = 0; x < numCols; x++) {
        // ...

        // Draw an outline with coordinates on top of tile if show grid is enabled
        if (this.showGrid) {
          this.drawGridTile(x, y)
        }
	  }
    }
  }
}

super.draw() calls the draw() method from the Map class, which clears anything that was drawn on the canvas previously. Within the nested for loop, if showGrid is true, a grid block is drawn above the tile in the current iteration.

Now we need to give the context some commands to draw a square outline with text coordinates in the middle. The drawGridTile() method will be defined within the OrthogonalMap class as below:

drawGridTile (x, y) {
  // Store positions
  const xPos = x * this.tileSize
  const yPos = y * this.tileSize

  // Draw coordinate text
  this.ctx.font = '14px serif'
  this.ctx.textAlign= 'center'
  this.ctx.fillStyle = '#333'
  this.ctx.fillText(x + ', ' + y, xPos + this.tileSize / 2, yPos + this.tileSize / 2 + 5)

  // Draw grid
  this.ctx.strokeStyle = '#999'
  this.ctx.lineWidth = 0.5
  this.ctx.strokeRect(xPos, yPos, this.tileSize, this.tileSize)
}

We did not place this method inside the base Map class because the logic required for drawing the grid would be different for other map types. For example, you can’t render outlines of squares above a hexagonal map… that just wouldn’t look right.

And that’s all! The checkbox should now toggle the grid on or off.

Here’s the complete result of our efforts:

See the Pen HTML5 Canvas Orthogonal Tile Map by Keenan Staffieri (@keenode) on CodePen.



  • The One Lee

    Thanks for this. Very plain and concise.