In this tutorial, I will try to introduce the interesting world of hexagonal tile-based games using the easiest of approaches. You will learn how to convert a two-dimensional array data to a corresponding hexagonal level layout on screen and vice versa. Using the information gained, we will be creating a hexagonal minesweeper game in two different hexagonal layouts.
This will get you started with exploring simple hexagonal board games and puzzle games and will be a good starting point to learn more complicated approaches like the axial or cubic hexagonal coordinate systems.
1. Hexagonal Tiles and Layouts
In the current generation of casual gaming, we don't see many games which use a hexagonal tile-based approach. Those we come across are usually puzzle games, board games, or strategy games. Also, most of our requirements are met by the square grid approach or isometric approach. This leads to the natural question: "Why do we need a different and obviously complicated hexagonal approach?" Let's find out.
Advantages of the Hexagonal Approach
So what makes the hexagonal tile-based approach relevant, since we already have other approaches learned and perfected? Let me list some of the reasons.
- Smaller number of neighbour tiles: When compared to a square grid, which will have eight neighbour tiles, a hexagonal tile will only have six neighbours. This reduces computations for complicated algorithms.
- All neighbour tiles are at the same distance: For a square grid, the four diagonal neighbours are far away when compared to the horizontal or vertical neighbours. Neighbours being at equal distances is a great relief when we are calculating heuristics and reduces the overhead of using two different methods to calculate something depending on the neighbour.
- Uniqueness: These days, millions of casual games are coming out and are competing for the player's time. Great games are failing to get an audience, and one thing that can be guaranteed to grab a player's attention is uniqueness. A game using a hexagonal approach will visually stand out from the rest, and the game will seem more interesting to a crowd who are bored with all the conventional gameplay mechanics.
I would say the last reason should be enough for you to master this new approach. Adding that unique gameplay element over your game logic could make all the difference and enable you to make a great game.
The other reasons are purely technical and would only come into effect once you are dealing with complicated algorithms or larger tile sets. There are many other aspects also which can be listed as advantages of the hexagonal approach, but most of them will depend on the player's personal interest.
Layouts
A hexagon is a polygon with six sides, and a hexagon with all sides having the same length is called a regular hexagon. For theory purposes, we will consider our hexagonal tiles to be regular hexagons, but they could be squashed or elongated in practice.
The interesting thing is that a hexagon can be placed in two different ways: the pointy corners could be aligned vertically or horizontally. When pointy tops are aligned vertically, it is called a horizontal layout, and when they are aligned horizontally, it is called a vertical layout. You may think that the names are misnomers with respect to the explanation provided. This is not the case as the naming is not done based on the pointy corners but the way a grid of tiles gets laid out. The image below shows the different tile alignments and corresponding layouts.
The choice of layout entirely depends on your game's visuals and gameplay. Yet your choice does not end here as each of these layouts could be implemented in two different ways.
Let's consider a horizontal hexagonal grid layout. Alternative rows of the grid would need to be horizontally offset by hexTileWidth/2
. This means we could choose to offset either the odd rows or the even rows. If we also display the corresponding row, column values, these variants would look like the image below.
Similarly, the vertical layout could be implemented in two variations while offsetting alternative columns by hexTileHeight/2
as shown below.
2. Implementing Hexagonal Layouts
From here on onwards, please start referring to the source code provided along with this tutorial for better understanding.
The images above, with the rows and columns displayed, make it easier to visualise a direct correlation with a two-dimensional array which stores the level data. Let's say we have a simple two-dimensional array levelData
as below.
var levelData= [[0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0], [0,0,0,0,0] ]
To make it easier to visualise, I will show the intended result here in both vertical and horizontal variations.
Let's start with horizontal layout, which is the image on the left side. In each row, if taken individually, the neighbour tiles are horizontally offset by hexTileWidth
. Alternative rows are horizontally offset by a value of hexTileWidth/2
. The vertical height difference between each row is hexTileHeight*3/4
.
To understand how we arrived at such a value for the height offset, we need to consider the fact that the top and bottom triangular portions of a horizontally laid out hexagon are exactly hexTileHeight/4
.
This means that the hexagon has a rectangular hexTileHeight/2
portion in the middle, a triangular hexTileHeight/4
portion on top, and an inverted triangular hexTileHeight/4
portion on the bottom. This information is enough to create the code necessary to lay out the hexagonal grid on screen.
var verticalOffset=hexTileHeight*3/4; var horizontalOffset=hexTileWidth; var startX; var startY; var startXInit=hexTileWidth/2; var startYInit=hexTileHeight/2; var hexTile; for (var i = 0; i < levelData.length; i++) { if(i%2!==0){ startX=2*startXInit; }else{ startX=startXInit; } startY=startYInit+(i*verticalOffset); for (var j = 0; j < levelData[0].length; j++) { if(levelData[i][j]!=-1){ hexTile= new HexTile(game, startX, startY, 'hex',false,i,j,levelData[i][j]); hexGrid.add(hexTile); } startX+=horizontalOffset; } }
With the HexTile
prototype, I have added some additional functionalities to the Phaser.Sprite
prototype which enables it to display the i
and j
values. The code essentially places a new hexagonal tile Sprite
at startX
and startY
. This code can be changed to display the even offset variant just by removing an operator in the if
condition like this: if(i%2===0)
.
For a vertical layout (the image on the right half), neighbour tiles in every column are vertically offset by hexTileHeight
. Each alternate column is vertically offset by hexTileHeight/2
. Applying the logic which we applied for vertical offset for the horizontal layout, we can see that the horizontal offset for the vertical layout between neighbour tiles in a row is hexTileWidth*3/4
. The corresponding code is below.
var verticalOffset=hexTileHeight; var horizontalOffset=hexTileWidth*3/4; var startX; var startY; var startXInit=hexTileWidth/2; var startYInit=hexTileHeight/2; var hexTile; for (var i = 0; i < levelData.length; i++) { startX=startXInit; startY=2*startYInit+(i*verticalOffset); for (var j = 0; j < levelData[0].length; j++) { if(j%2!==0){ startY=startY+startYInit; }else{ startY=startY-startYInit; } if(levelData[i][j]!=-1){ hexTile= new HexTile(game, startX, startY, 'hex', true,i,j,levelData[i][j]); hexGrid.add(hexTile); } startX+=horizontalOffset; } }
In the same way as with the horizontal layout, we can switch to the even offset variant just by removing the !
operator in the top if
condition. I am using a Phaser Group
to collect all the hexTiles
named hexGrid
. For simplicity, I am using the centre point of the hexagonal tile image as an anchor, or else we would need to consider the image offsets as well.
One thing to notice is that the tile width and tile height values in the horizontal layout are not equal to the tile width and tile height values in the vertical layout. But when using the same image for both layouts, we could just rotate the tile image 90 degrees and swap the values of tile width and tile height.
3. Finding the Array Index of a Hexagonal Tile
The array to screen placement logic was interestingly straightforward, but the reverse is not so easy. Consider that we need to find the array index of the hexagonal tile on which we have tapped. The code to achieve this is not pretty, and it is usually arrived at by some trial and error.
If we consider the horizontal layout, it may seem that the middle rectangular portion of the hexagonal tile can easily help us figure out the j
value as it is just a matter of dividing the x
value by hexTileWidth
and taking the integer value. But unless we know the i
value, we don't know if we are on an odd or even row. An approximate value of i
can be found by dividing the y value by hexTileHeight*3/4
.
Now come the complicated parts of the hexagonal tile: the top and bottom triangular portions. The image below will help us understand the problem at hand.
The regions 2, 3, 5, 6, 8, and 9 together form one tile. The most complicated part is to find if the tapped position is in 1/2 or 3/4 or 7/8 or 9/10. For this, we need to consider all the individual triangular regions and check against them using the slope of the slanted edge.
This slope can be found from the height and width of each triangular region, which respectively are hexTileHeight/4
and hexTileWidth/2
. Let me show you the function which does this.
function findHexTile(){ var pos=game.input.activePointer.position; pos.x-=hexGrid.x; pos.y-=hexGrid.y; var xVal = Math.floor((pos.x)/hexTileWidth); var yVal = Math.floor((pos.y)/(hexTileHeight*3/4)); var dX = (pos.x)%hexTileWidth; var dY = (pos.y)%(hexTileHeight*3/4); var slope = (hexTileHeight/4)/(hexTileWidth/2); var caldY=dX*slope; var delta=hexTileHeight/4-caldY; if(yVal%2===0){ //correction needs to happen in triangular portions & the offset rows if(Math.abs(delta)>dY){ if(delta>0){//odd row bottom right half xVal--; yVal--; }else{//odd row bottom left half yVal--; } } }else{ if(dX>hexTileWidth/2){// available values don't work for even row bottom right half if(dY<((hexTileHeight/2)-caldY)){//even row bottom right half yVal--; } }else{ if(dY>caldY){//odd row top right & mid right halves xVal--; }else{//even row bottom left half yVal--; } } } pos.x=yVal; pos.y=xVal; return pos; }
First, we find xVal
and yVal
the same way we would do for a square grid. Then we find the remaining horizontal (dX
) and vertical (dY
) values after removing the tile multiplier offset. Using these values, we try to figure out if the point is within any of the complicated triangular regions.
If found, we make corresponding changes to the initial values of xVal
and yVal
. As I have said earlier, the code is not pretty and not straightforward. The easiest way to understand this would be to call findHexTile
on mouse move, and then put console.log
inside each of those conditions and move the mouse over various regions within one hexagonal tile. This way, you can see how each intra-hexagonal region is handled.
The code changes for the vertical layout are shown below.
function findHexTile(){ var pos=game.input.activePointer.position; pos.x-=hexGrid.x; pos.y-=hexGrid.y; var xVal = Math.floor((pos.x)/(hexTileWidth*3/4)); var yVal = Math.floor((pos.y)/(hexTileHeight)); var dX = (pos.x)%(hexTileWidth*3/4); var dY = (pos.y)%(hexTileHeight); var slope = (hexTileHeight/2)/(hexTileWidth/4); var caldX=dY/slope; var delta=hexTileWidth/4-caldX; if(xVal%2===0){ if(dX>Math.abs(delta)){// even left }else{//odd right if(delta>0){//odd right bottom xVal--; yVal--; }else{//odd right top xVal--; } } }else{ if(delta>0){ if(dX<caldX){//even right top xVal--; }else{//odd mid yVal--; } }else{//current values wont help for even right bottom if(dX<((hexTileWidth/2)-caldX)){//even right bottom xVal--; } } } pos.x=yVal; pos.y=xVal; return pos; }
4. Finding Neighbours
Now that we've found the tile on which we have tapped, let's find all six neighbouring tiles. This is a very easy problem to solve once we visually analyse the grid. Let's consider the horizontal layout.
The image above shows the odd and even rows of a horizontally laid out hexagonal grid when a middle tile has the value of 0
for both i
and j
. From the image, it becomes clear that if the row is odd, then for a tile at i,j
the neighbours are i, j-1
, i-1,j-1
, i-1,j
, i,j+1
, i+1,j
, and i+1,j-1
. When the row is even, then for a tile at i,j
the neighbours are i, j-1
, i-1,j
, i-1,j+1
, i,j+1
, i+1,j+1
, and i+1,j
. This could be manually calculated easily.
Let's analyse a similar image for the odd and even columns of a vertically aligned hexagonal grid.
When we have an odd column, a tile at i,j
will have i,j-1
, i-1,j-1
, i-1,j
, i-1,j+1
, i,j+1
, and i+1,j
as neighbours. Similarly, for an even column, the neighbours are i+1,j-1
, i,j-1
, i-1,j
, i,j+1
, i+1,j+1
, and i+1,j
.
5. Hexagonal Minesweeper
With the above knowledge, we can try to make a hexagonal minesweeper game in the two different layouts. Let's break down the features of a minesweeper game.
- There will be N number of mines hidden inside the grid.
- If we tap on a tile with a mine, the game is over.
- If we tap on a tile which has a neighbouring mine, it will display the number of mines immediately around it.
- If we tap on a mine without any neighbouring mines, it would lead to the revealing of all the connected tiles which do not have mines.
- We can tap and hold to mark a tile as a mine.
- The game is finished when we reveal all tiles without mines.
We can easily store a value in the levelData
array to indicate a mine. The same method can be used to populate the value of nearby mines on the neighbouring tiles' array index.
On game start, we will randomly populate the levelData
array with N number of mines. After this, we will update the values for all the neighbouring tiles. We will use a recursive method to chain reveal all the connected blank tiles when the player taps on a tile which doesn't have a mine as neighbour.
Level Data
We need to create a nice looking hexagonal grid, as shown in the image below.
This can be done by only displaying a portion of the levelData
array. If we use -1
as the value for a non-usable tile and 0
as the value for a usable tile, then our levelData
for achieving the above result will look like this.
//horizontal tile shaped level var levelData= [[-1,-1,-1,0,0,0,0,0,0,0,-1,-1,-1], [-1,-1,0,0,0,0,0,0,0,0,-1,-1,-1], [-1,-1,0,0,0,0,0,0,0,0,0,-1,-1], [-1,0,0,0,0,0,0,0,0,0,0,-1,-1], [-1,0,0,0,0,0,0,0,0,0,0,0,-1], [0,0,0,0,0,0,0,0,0,0,0,0,-1], [0,0,0,0,0,0,0,0,0,0,0,0,0], [0,0,0,0,0,0,0,0,0,0,0,0,-1], [-1,0,0,0,0,0,0,0,0,0,0,0,-1], [-1,0,0,0,0,0,0,0,0,0,0,-1,-1], [-1,-1,0,0,0,0,0,0,0,0,0,-1,-1], [-1,-1,0,0,0,0,0,0,0,0,-1,-1,-1], [-1,-1,-1,0,0,0,0,0,0,0,-1,-1,-1]];
While looping through the array, we would only add hexagonal tiles when the levelData
has a value of 0
. For the vertical alignment, the same levelData
can be used, but we would need to transpose the array. Here is a nifty method which can do this for you.
levelData=transpose(levelData); //... function transpose(a) { return Object.keys(a[0]).map( function (c) { return a.map(function (r) { return r[c]; }); } ); }
Adding Mines and Updating Neighbours
By default, our levelData
has only two values, -1
and 0
, of which we would be using only the area with 0
. To indicate that a tile contains a mine, we can use the value of 10
.
A blank hexagonal tile can have a maximum of six mines near it as it has six neighbouring tiles. We can store this information also in the levelData
once we have added all the mines. Essentially, a levelData
index having a value of 10
has a mine, and if it contains any values from 0
to 6
, that indicates the number of neighbouring mines. After populating mines and updating neighbours, if an array element is still 0
, it indicates that it is a blank tile without any neighbouring mines.
We can use the following methods for our purposes.
function addMines(){ var tileType=0; var tempArray=[]; var newPt=new Phaser.Point(); for (var i = 0; i < levelData.length; i++) { for (var j = 0; j < levelData[0].length; j++) { tileType=levelData[i][j]; if(tileType===0){ newPt=new Phaser.Point(); newPt.x=i; newPt.y=j; tempArray.push(newPt); } } } for (var i = 0; i < numMines; i++) { newPt=Phaser.ArrayUtils.removeRandomItem(tempArray); levelData[newPt.x][newPt.y]=10;//10 is mine updateNeighbors(newPt.x,newPt.y); } } function updateNeighbors(i,j){//update neighbors around this mine var tileType=0; var tempArray=getNeighbors(i,j); var tmpPt; for (var k = 0; k < tempArray.length; k++) { tmpPt=tempArray[k]; tileType=levelData[tmpPt.x][tmpPt.y]; levelData[tmpPt.x][tmpPt.y]=tileType+1; } }
For every mine added in addMines
, we are incrementing the array value stored in all of its neighbours. The getNeighbors
method won't return a tile which is outside our effective area or if it contains a mine.
Tap Logic
When the player taps on a tile, we need to find the corresponding array element using the findHexTile
method explained earlier. If the tile index is within our effective area, then we just compare the value at the array index to find if it is a mine or blank tile.
function onTap(){ var tile= findHexTile(); if(!checkforBoundary(tile.x,tile.y)){ if(checkForOccuppancy(tile.x,tile.y)){ if(levelData[tile.x][tile.y]==10){ //console.log('boom'); var hexTile=hexGrid.getByName("tile"+tile.x+"_"+tile.y); if(!hexTile.revealed){ hexTile.reveal(); //game over } } }else{ var hexTile=hexGrid.getByName("tile"+tile.x+"_"+tile.y); if(!hexTile.revealed){ if(levelData[tile.x][tile.y]===0){ //console.log('recursive reveal'); recursiveReveal(tile.x,tile.y); }else{ //console.log('reveal'); hexTile.reveal(); revealedTiles++; } } } } infoTxt.text='found '+revealedTiles +' of '+blankTiles; }
We keep track of the total number of blank tiles using the variable blankTiles
and the number of tiles revealed using revealedTiles
. Once they are equal, we have won the game.
When we tap on a tile with an array value of 0
, we need to recursively reveal the region with all the connected blank tiles. This is done by the function recursiveReveal
, which receives the tile indices of the tapped tile.
function recursiveReveal(i,j){ var newPt=new Phaser.Point(i,j); var hexTile; var tempArray=[newPt]; var neighbors; while (tempArray.length){ newPt=tempArray[0]; var neighbors=getNeighbors(newPt.x,newPt.y); while(neighbors.length){ newPt=neighbors.shift(); hexTile=hexGrid.getByName("tile"+newPt.x+"_"+newPt.y); if(!hexTile.revealed){ hexTile.reveal(); revealedTiles++; if(levelData[newPt.x][newPt.y]===0){ tempArray.push(newPt); } } } newPt=tempArray.shift();//it seemed one point without neighbor sometimes escapes the iteration without getting revealed, catch it here hexTile=hexGrid.getByName("tile"+newPt.x+"_"+newPt.y); if(!hexTile.revealed){ hexTile.reveal(); revealedTiles++; } } }
In this function, we find the neighbours of each tile and reveal that tile's value, meanwhile adding neighbour tiles to an array. We keep repeating this with the next element in the array until the array is empty. The recursion stops when we meet array elements containing a mine, which is ensured by the fact that getNeighbors
won't return a tile with a mine.
Marking and Revealing Tiles
You must have noticed that I am using hexTile.reveal()
, which is made possible by creating a HexTile
prototype which keeps most of the attributes related to our hexagonal tile. I use the reveal
function to display the tile value text and set the tile's colour. Similarly, the toggleMark
function is used to mark the tile as a mine when we tap and hold. HexTile
also has a revealed
attribute which tracks whether it is tapped and revealed or not.
HexTile.prototype.reveal=function(){ this.tileTag.visible=true; this.revealed=true; if(this.type==10){ this.tint='0xcc0000'; }else{ this.tint='0x00cc00'; } } HexTile.prototype.toggleMark=function(){ if(this.marked){ this.marked=false; this.tint='0xffffff'; }else{ this.marked=true; this.tint='0x0000cc'; } }
Check out the hexagonal minesweeper with horizontal orientation below. Tap to reveal tiles, and tap-hold to mark mines. There is no game over as of now, but if you reveal a value of 10
, then it is hasta la vista baby!
Changes for the Vertical Version
As I am using the same image of hexagonal tile for both orientations, I rotate the Sprite for the vertical alignment. The below code in the HexTile
prototype does this.
if(isVertical){ this.rotation=Math.PI/2; }
The minesweeper logic remains the same for the vertically aligned hexagonal grid with the difference for findHextile
and getNeighbors
logic which now need to accommodate the alignment difference. As mentioned earlier, we also need to use the transpose of the level array with corresponding layout loop.
Check out the vertical version below.
The rest of the code in the source is simple and straightforward. I would like you to try and add the missing restart, game win, and game over functionality.
Conclusion
This approach of a hexagonal tile-based game using a two-dimensional array is more of a layman's approach. More interesting and functional approaches involve altering the coordinate system to different types using equations.
The most important ones are axial coordinates and cubic coordinates. There will be a follow-up tutorial series which will discuss these approaches. Meanwhile, I would recommend reading Amit's incredibly thorough article on hexagonal grids.