From Grids To Graph: Transforming Map Representations For A* Path Finding
Grids & Graphs
Grids are a special cases of Graphs called Lattice Graphs, and they show up and time and time again, particularly in Game Development. Certain categories of games, such as Rogue like games are heavily grid based.
For the purposes of this article we are going to generate a simple "maze like, Rogue like" map grid, transform it into an adjacency list representation of a Graph, and then process that graph with the A* search to find the shortest path from a designated start node to a stop node.
The Grid
For the sake of simplicity in this article (as its meant to be an introduction) we will be processing rectangular grids, One of the benefits of transforming grid representations to graph representations is that it makes processing triangular or hexagonal grids easier as well.
The smallest building block for our grid is going to be the node, which is often referred to as a "tile" in a Roguelike, which speaks to how intertwined grids and Roguelike games are, as grids can also be called "tiled graphs".
Each node will have an X/Y coordinate, a travel "cost", a blocking value representing if the tile can be traversed or not, and a label, in this case, simply a tile number. Our grid will be a two dimensional array of MxN size of these nodes:
struct node {
int label;
int x, y;
bool blocking;
int cost;
node(int X, int Y, int L, int C, bool B) : x(X), y(Y), label(L), cost(C), blocking(B) { }
node(int X, int Y, int L) : x(X), y(Y), label(L) { }
node(int X, int Y) : x(X), y(Y), label(0) { }
node() : x(0), y(0), label(0) { }
};
class Grid {
public:
vector<vector<node> > grid;
int M;
int N;
Grid(int m, int n)
{
M = m;
N = n;
grid.resize(N,vector<node>(M));
int _node = 0;
for (int i = 0; i < N; i++) {
for (int j = 0; j < M; j++) {
grid[i][j] = node(j, i, _node++); //assign x, y, and label to graph
if (rand() % 100 > 90)
grid[i][j].blocking = true; //place a wall
grid[i][j].cost = ((rand() % 100) > 65) ? 20 : 10;
}
}
}
};
During construction of the grid i randomly assign blocking tiles as well as assign traversal costs, this will result in a random maze with obstacles that our A* algorithm will find the shortest path through.
The Graph
As I've mentioned in previous articles, a graph is comprised of vertexes and edges, and in the case of a weighted graph, edge costs. When converting a grid to a graph representation, each node of the grid is a vertex, and its adjacent vertexes are the edges created by the nodes immediately above, below, to the left, and to the right. Because each grid node has an x/y coordinate associated with it, this translates to the nodes at (+1, 0), (-1, 0), (0,-1), and (0,1). The edge costs are the traversal cost of each node. This allows us to translate the grid to a standard graph form very easily:
struct Edge {
int from;
int to;
Edge(int f, int t) : from(f), to(t) { }
Edge() { from = 0; to = 0; }
};
class Graph {
public:
std::map<int, set<int> > adjList;
std::map<int, node> verts;
Graph(Grid& gridRep)
{
int m = gridRep.M * gridRep.N;
for (int i = 0; i < m; i++)
{
adjList[i] = std::set<int>();
}
std::vector<node> dirs = { node{-1, 0}, node{1, 0}, node{0, 1}, node{0, -1} };
for (int i = 0; i < gridRep.N; i++)
{
for (int j = 0; j < gridRep.M; j++)
{
for (auto d : dirs)
{
verts[gridRep.grid[i][j].label] = node(j, i, gridRep.grid[i][j].label, gridRep.grid[i][j].cost, gridRep.grid[i][j].blocking);
node gridPos(d.x + j, d.y + i);
if (gridPos.x >= 0 && gridPos.x < gridRep.M && gridPos.y >= 0 && gridPos.y < gridRep.N)
{
adjList[gridRep.grid[i][j].label].insert(gridRep.grid[gridPos.y][gridPos.x].label);
}
}
}
}
}
void addEdge(int f, int t)
{
adjList[f].insert(t);
adjList[t].insert(f);
}
void showGraph()
{
for (auto vert : adjList) {
cout<<vert.first<<": ";
for (auto n : vert.second)
{
cout<<n<<" ";
}
cout<<endl;
}
}
int V()
{
return adjList.size();
}
};
One of the most important features of the Graph class is the std::map which maps node labels to grid nodes. This allows us to very quickly look up the x/y coordinates of each vertex whilst traversing the adjacency list during later operations.
Combining The Grid and Graph, and Visualization
I'll be using the opensource BearLibTerminal which is commonly used among Roguelike developers to visualize the grid "Map" we created. The client code used in this example is:
#include <BearLibTerminal.h>
#include <iostream>
#include "gridgraph.h"
#include "astar.h"
#include "dfs.h"
using namespace std;
void draw(Grid& grid)
{
terminal_clear();
char symbol;
for (int i = 0; i < grid.N; i++)
{
for (int j = 0; j < grid.M; j++)
{
node vert = grid.grid[i][j];
if (vert.blocking) symbol = '#';
else symbol = ' ';
terminal_color(color_from_name("draker grey"));
if (vert.cost > 9)
terminal_bkcolor(color_from_name("lighter blue"));
if (vert.cost > 19)
terminal_bkcolor(color_from_name("light blue"));
terminal_put(vert.x, vert.y, symbol);
}
}
terminal_refresh();
}
int main()
{
srand(time(0));
char k;
terminal_open();
terminal_set("window: size=80x40");
Grid grid(80, 40);
Graph G(grid);
bool found = false;
int start = 0;
start = G.verts[G.verts.size()-1].label;
while (true)
{
if (terminal_has_input())
{
if ((k = terminal_read()) == TK_Q)
{
break;
}
}
draw(grid);
cout<<"Starting from: "<<start<<endl;
AStar ast(G, start, 1);
}
terminal_close();
return 0;
}
displaying the grid is as easy as iterating over the 2D vector of nodes and displaying them with a call to terminal_print(). I'm not going to go deep into the use of BearLibTerminal because that is outside the scope of this article, more information on the BearLibTerminal API can be found at: http://foo.wyrd.name/en:bearlibterminal
With BearLibTerminal installed in your build environment, the above code can be compiled and run by typing the following comman in the terminal:
g++ --std=c++17 grid.cpp -o grid -lBearLibTerminal && ./grid
This will cause a window to open that looks something like this:
The white '#' are blocking tiles that cannot be "stepped" on, the dark blue areas are tiles with edge costs that are higher to travel to than the light colored tiles. With out "maze" constructed, we can now start implementing the Path finding algorithms.
Path Finding Algorithms
There are many pathfinding algorithms that can be used for traversing this type of map. The simplest "maze solving" path finding algorithm is Depth First Search, which will find a path from a start node to a goal node if one exists, but it is not guaranteed to be a shortest path, nor does it take edge cost into consideration, so its not the "cheapest" path either. Depth First Search can be implemented either recursively or, by using a stack data structure to process iteratively.
The next step up is standard Breadth First Search, which WILL produce a shortest path with the caveat that it does not consider edge cost, so it is only a shortest path in terms of number of edges traversed. This is managed by the use of a FIFO Queue data structure.
The two best options for path finding in this case are Dijkstra's algorithm which i have covered in previous articles in relation to general graphs, but not grids, and A* search. A* search is a heuristically guided adaptation of Dijkstra's Algorithm, or put differently, Dijkstra's Algorithm is a special case of A* where the value of the heuristic is 0.
What makes transforming a grid into an adjacency list graph is that because we have the spatial information from the grid, we can easily incorporate a manhattan distance based heuristic, which when added to the edge cost priority based search of dijkstra's algorithm yields an efficient A* search algorithm.
Just as in Dijkstra we will be using a min-heap priority queue, and std::map to keep track of our path.
Because A* and dijkstra's algorithm are so closesly related, a standard dijkstra implementation is a good place to start, and in truth, we only need to add less than five lines of code:
class AStar {
public:
std::map<int, int> camefrom;
std::map<int, int> dist;
std::priority_queue<pair<int,int>, vector<pair<int,int>>, std::greater<pair<int,int> > > pq;
AStar(Graph& G, int from, int to)
{
search(G, from, to);
}
int heuristic(Graph& G, int u, int v)
{
node a = G.verts[u];
node b = G.verts[v];
int distx = abs(a.x - b.x);
int disty = abs(a.y - b.y);
return 5 * (distx + disty);
}
void search(Graph& G, int from, int to)
{
int examined = 0;
bool found = false;
dist[from] = 0;
camefrom[from] = -1;
pq.push(make_pair(0, from));
while (!pq.empty())
{
int current = pq.top().second;
pq.pop();
if (current == to)
{
found = true;
break;
}
for (auto adj : G.adjList[current])
{
examined++;
if (!G.verts[adj].blocking)
{
int new_cost = dist[current] + G.verts[adj].cost;
if (dist.find(adj) == dist.end() || new_cost < dist[adj])
{
dist[adj] = new_cost;
camefrom[adj] = current;
int priority = new_cost + heuristic(G, adj, to);
pq.push(make_pair(priority, adj));
terminal_bkcolor(color_from_name("darker grey"));
terminal_put(G.verts[current].x, G.verts[current].y, ' ');
terminal_refresh();
}
}
}
}
if (found)
{
showPath(G, from, to);
}
}
void showPath(Graph& G, int from, int to)
{
std::vector<int> path;
int crawl = to;
while (crawl != from)
{
path.push_back(crawl);
crawl = camefrom[crawl];
}
path.push_back(from);
reverse(path.begin(), path.end());
for (auto p : path)
{
terminal_color(color_from_name("green"));
terminal_put(G.verts[p].x, G.verts[p].y, '*');
cout<<p<<" -> ";
terminal_refresh();
}
cout<<"Found!\n";
}
};
The heuristic function is at lines 10 through 17, It simply computes the manhattan distance from the current vertex to the goal vertex, and multiplies it by the lowest edge cost. The search() procedure functions exactly the same as dijkstras algorithm, with one small addition: when adding vertexes to the priority queue, the priority is new_cost + heuristic instead of soley new_cost. Thats the only Difference! but what a difference it is, as it has the potential to reduce the number of nodes examined by a large margin - so long as we use a valid heuristic.
As it stands our algorithm definitely finds a short path, but it is still exploring far too many nodes than we would like. So what should we do? The first step would be to try a different heuristic. A simple change to implement would be using euclidean distance instead of manhattan distance.
int heuristic(Graph& G, int u, int v)
{
node a = G.verts[u];
node b = G.verts[v];
int distx = abs(b.x - a.x);
int disty = abs(b.y - a.y);
distx *= distx;
disty *= disty;
int dist = sqrt(distx+disty);
return dist;
}
Lets see how we did:
Hmm. That didnt appear to do... anything. Let's try ratcheting up the heuristic by adding a delta value. A delta value is a number we multiply the distance by in the heuristic this has the effect of weighting the values.
int heuristic(Graph& G, int u, int v)
{
int delta = 5;
node a = G.verts[u];
node b = G.verts[v];
int distx = abs(b.x - a.x);
int disty = abs(b.y - a.y);
distx *= distx;
disty *= disty;
int dist = sqrt(distx+disty);
return delta * dist;
}
Lets try 5:
Ok, now we're trimming the search space! Take a look around the edges: theres fewer nodes being explored to find our solution. Let's see what a delta of 15 yields:
Wow! Clearly proper dialing in of a heuristic is required to adapt A* to whatever the problem at hand is, but once tuned it is an amazingly efficient search algorithm. And there are many variants that can be adapted for all kinds of uses, as demonstrated here it can be used for path finding in video games very easily. It's original use was for robotic AI path finding as part of the "Shakey" project at Stanford university. Regardless of what you use it for, I'm sure you will find it as cool and interesting as just about everybody else.
Full code for the implementation as shown here is available on my github at:
Leave A Comment