Pathfinding with A*

Pathfinding with A*

by Charles Calkins, Principal Software Engineer

October 2014

Introduction

A number of algorithms exist for finding paths in graphs, and a venerable one from 1968, useful in applications such as game development and robotics, is A*. This article describes the A* algorithm and graphically demonstrates paths that it finds.

A*

Given a graph of nodes connected by edges, the A* search algorithm finds a "least cost" path from a starting node to a destination node, along the edges that connect them. For a given node, the least cost path to it is maintained, and, at each iteration of the algorithm, the path with the current least cost is extended by evaluating nearby nodes, to see which of them can extend the path in the most optimal way. When evaluating a node to extend a path, a heuristic cost function is used, composed of a known cost to the node, plus a cost which estimates the distance to the destination node. As long as the heuristic is "admissible" - that is, it never overestimates the cost to the destination node - then an appropriate path will be found.

Eric Lippert, in his blog series, illustrated an implementation of A* in C# for arbitrary graphs. While finding the shortest path in a graph is an abstract concept, finding the shortest path on a map is much easier to visualize, and, indeed, A* is often used in tile-based video games for a computer player to direct units from one place on the game map to another. So, to demonstrate A* in a more entertaining way, we'll show how it can be used for finding paths in the style of an old-school fantasy adventure game.

The Map

A typical fantasy game of the Ultima era consists of a collection of tiles, often retangular or hexagonal in shape, forming a large map. Each tile can contain terrain, architectural or other features which affect how difficult it is for a player to move across it. The features of each tile are shown graphically, providing an environment that that user can move around in.

While some games provide a top-down view, we will show an isometric one, thanks to the availability of tile graphics provided under the Creative Commons license. In this particular tile set, each tile is 54x54 pixels in size, with pink (RGB 255,0,255) indicating a transparent area, allowing tiles to be overlaid atop one another. A grass tile, for example, is as follows:

L1_Terrain001.png

In the AStar project, in the attached code archive, all of the tiles we need are in the Images directory, and we will add them as resources to the executable. This is easily done in the project's MPC file:

  1. // AStar\AStar.mpc
  2. Embedded_Resource_Files {
  3. Images\*.PNG
  4. }

The tiles are loaded as Bitmap objects by opening each resource as a stream.

  1. // AStar\MapRenderer.cs
  2. static Bitmap LoadBitmap(string name)
  3. {
  4. using (Stream file = Assembly.GetExecutingAssembly()
  5. .GetManifestResourceStream("AStar.Images." + name))
  6. return new Bitmap(Image.FromStream(file));
  7. }

For proper blending, pixels colored pink must be converted to true transparency by setting the alpha channel of the pixel to zero. While this can be done before the images are added as resources, it is straightforward to do so after the bitmap has been loaded. While the GetPixel() and SetPixel() methodes of Bitmap can be used to read and write pixel values, it is faster to manipulate the image bits directly, provided unsafe code blocks are allowed in the project. It is important to note that the color channels for each pixel are in BGRA order, rather than RGBA. In the code below, if the RGB value matches what is supplied in the function arguments, then the alpha channel value is set to 0.

  1. // AStar\MapRenderer.cs
  2. static Bitmap MakeTransparent(Bitmap bmp, byte r, byte g, byte b)
  3. {
  4. BitmapData data = bmp.LockBits(
  5. new Rectangle(0, 0, bmp.Width, bmp.Height),
  6. ImageLockMode.ReadOnly, bmp.PixelFormat);
  7. unsafe
  8. {
  9. for (int x = 0; x < data.Width; x++)
  10. {
  11. int i = x * 4;
  12. for (int y = 0; y < data.Height; y++)
  13. {
  14. byte* row = (byte*)data.Scan0 + (y * data.Stride);
  15. // BGRA order
  16. if ((row[i] == b) &&
  17. (row[i + 1] == g) &&
  18. (row[i + 2] == r))
  19. row[i + 3] = 0; // transparent
  20. }
  21. }
  22. }
  23. bmp.UnlockBits(data);
  24. return bmp;
  25. }

Allowing unsafe code blocks is done in MPC using this syntax:

  1. // AStar\AStar.mpc
  2. specific {
  3. allowunsafeblocks = true
  4. }

We can represent a map as two arrays of strings, the first for a base and the second for an overlay, where each character in a string represents a terrain feature. For instance, "G" represents grass, "R" a road, and "W" a wall, and an example encoding is below.

  1. new string[] { new string[] {
  2. "GGGGGGGGGG", " ",
  3. "GGGGGGGGGG", " R ",
  4. "GGGGGGGGGG", " RRRRR ",
  5. "GGGGGGGGGG", " R W ",
  6. "GGGGGGGGGG", " R W ",
  7. "GGGGGGGGGG", " 1 R W 2",
  8. "GGGGGGGGGG", " W ",
  9. "GGGGGGGGGG", " WWWWW ",
  10. "GGGGGGGGGG", " W ",
  11. "GGGGGGGGGG" " "
  12. } }

Class Map contains both the base and overlay string arrays, and is drawn using the Render() method. The width and height of the map are determined by the number of characters in a string, and the number of strings in the base tile string array. A blank image, bmpMap, is created as the canvas for the fully rendered map, and tiles are drawn in a sequence allowing background tiles to be occluded by foreground ones, and map overlay features to be drawn on top of base map features.

  1. // AStar\MapRenderer.cs
  2. static public Bitmap Render(Map map, Pos[] path)
  3. {
  4. int mapWidth = map.MapData[0].Length;
  5. int mapHeight = map.MapData.Length;
  6.  
  7. Bitmap bmpMap = new Bitmap(
  8. tileWidth * mapWidth + tileWidth / 2,
  9. tileHeight * (mapHeight + 1));
  10. Graphics gMap = Graphics.FromImage((Image)bmpMap);
  11.  
  12. int x, y, cx, cy, px, py, pcx, pcy;
  13.  
  14. for (int i = 0; i < mapHeight; i++)
  15. for (int j = mapWidth - 1; j >= 0; j--)
  16. {
  17. MapToXY(mapHeight, i, j, out x, out y, out cx, out cy);
  18.  
  19. using (Bitmap b = LoadTile(map.MapData, i, j))
  20. if (b != null) gMap.DrawImage(b, x, y);
  21.  
  22. using (Bitmap b = LoadTile(map.OverlayData, i, j))
  23. if (b != null) gMap.DrawImage(b, x, y);
  24. }
  25.  
  26. // ...path rendering not shown here
  27. }

Coordinates for tiles are calculated based on an isometric mapping, where north is to the upper left. Although the tile images are 54x54 pixels, a tile size of 52x26 is used for rendering. A one pixel overlap ensures that no gaps are shown between tiles, and the tile image area is predominantly in the lower half of the tile, with the upper half used for larger features, such as mountains, that extend well above ground level.

  1. // AStar\MapRenderer.cs
  2. const int tileWidth = 52;
  3. const int tileHeight = 26;
  4.  
  5. static void MapToXY(int mapHeight, int i, int j,
  6. out int x, out int y, out int cx, out int cy)
  7. {
  8. x = (j * tileWidth / 2) + (i * tileWidth / 2);
  9. y = (i * tileHeight / 2) - (j * tileHeight / 2) +
  10. ((mapHeight - 1) * tileHeight / 2);
  11. cx = x + tileWidth / 2;
  12. cy = y + tileHeight + tileHeight / 2;
  13. }

The specific image to render for a given tile is determined within the LoadTile() method. Some tiles, such as grass or snow tiles, can be rendered without regard to neighboring tiles, so only one tile image is needed to represent them. Other features, such as roads and walls, must be drawn based on the content of neighboring tiles, so require one out of many images to be selected.

For example, consider a road tile that is to be rendered, where road tiles are also to the east and west of it. The image to select for the tile must be of a road that runs from east to west, connecting the neighboring road segments. If there was also a road tile to the north of it, than rather than an east to west road tile image, the appropriate image to select would be that of a T-junction, connecting the east, west and north road segments.

Calling the Neighbor() method for each of the cardinal directions builds a string containing the letters NSE and W, based on whether the position of the neighbor under consideration is within the bounds of the map, and the tile in the corresponding direction is of the same type as the tile being rendered.

  1. // AStar\MapRenderer.cs
  2. static string Neighbor(string[] map, char dir, int y, int x)
  3. {
  4. char current = map[y][x];
  5. if (dir == 'N') y = y - 1;
  6. if (dir == 'S') y = y + 1;
  7. if (dir == 'W') x = x - 1;
  8. if (dir == 'E') x = x + 1;
  9. if ((y >= 0) && (y < map.Length) && (x >= 0) && (x < map[y].Length))
  10. if (map[y][x] == current)
  11. return dir.ToString();
  12. return string.Empty;
  13. }
  14.  
  15. static Bitmap LoadTile(string[] map, int y, int x)
  16. {
  17. string name = string.Empty;
  18. char current = map[y][x];
  19. string suffix =
  20. Neighbor(map, 'N', y, x) +
  21. Neighbor(map, 'E', y, x) +
  22. Neighbor(map, 'S', y, x) +
  23. Neighbor(map, 'W', y, x);

The string built by the calls to Neighbor() is used as a suffix of the image name. For the example above, the east to west road tile would have a suffix of EW, while the T-junction would have a suffix of NEW. Tiles of the tile set were renamed for consistency to follow this pattern.

The image name suffix is used as needed. As mentioned previously, as grass and snow can be represented by just one tile each (L1_Terrain001.PNG and L1_Terrain035.PNG), the suffix is not necessary.

L1_Terrain001.png
L1_Terrain035.png

Appropriate road and wall tiles must be selected from a set of tiles, however. For the above road examples, the east-west road L2_RoadDirtEW.PNG and the T-junction L2_RoadDirtNEW.PNG are required.

L2_RoadDirtEW.png
L2_RoadDirtNEW.png

Incidentally, this tile set has a naming convention where a tile's layer is identified by the first characters of the tile image: L1_L2_ or L3_. A tile with a higher layer number is designed to be overlaid on top of a tile with a lower layer number, such as a road tile (layer 2) on a grass or snow tile (layer 1).

The tile selection is performed by a list of if statements.

  1. if (current == 'G')
  2. name = "L1_Terrain001";
  3.  
  4. if (current == 'S')
  5. name = "L1_Terrain035";
  6.  
  7. if (current == 'R')
  8. name = "L2_RoadDirt" + suffix;
  9.  
  10. if (current == 'W')
  11. name = "L2_WallStone" + suffix;
  12.  
  13. // and more tile selections...

Lastly, the bitmap is made transparent, and returned to the Render() method which called it. The bitmap that is returned is drawn on the main map canvas via the call to gMap.DrawImage().

  1. if (!string.IsNullOrEmpty(name))
  2. return MakeTransparent(LoadBitmap(name + ".PNG"), 255, 0, 255);
  3.  
  4. return null;
  5. }

For the map defined by the base and overlay strings shown previously, the process above produces:

Road Walls A No Path

Pathfinding

On this map, we wish to find the lowest-cost path from the tent to the castle, taking into consideration features of the terrain. We shall consider grass as having a cost of 1 to move across, snow a cost of 3 to move across, a road reducing the cost of whatever it is over by 1/2 (so a road, on grass, has a cost of 1/2), and walls being impassable. We will also allow movement in the cardinal directions (N,S,E, and W), as well as diagonal movement (NE, SE, NW and SW).

Applying Lippert's algorithm to our 2-D map yields the following:

  1. // AStar\AStar.cs
  2. public Pos[] FindPath(Pos startPos, Pos destinationPos)
  3. {
  4. var start = new Node(this, startPos.X, startPos.Y);
  5. var destination = new Node(this, destinationPos.X, destinationPos.Y);
  6. var closed = new HashSet<Node>();
  7. var open = new PriorityQueue<double, Path>();
  8. open.Enqueue(0, new Path(start));
  9. while (!open.IsEmpty)
  10. {
  11. var path = open.Dequeue();
  12. if (closed.Contains(path.LastStep))
  13. continue;
  14. if (path.LastStep.Equals(destination))
  15. return path.Select(node =>
  16. new Pos(node.X, node.Y)).ToArray();
  17. closed.Add(path.LastStep);
  18. foreach (Node n in path.LastStep.Neighbours)
  19. {
  20. double d = NeighborDistance(path.LastStep, n);
  21. var newPath = path.AddStep(n, d);
  22. open.Enqueue(newPath.TotalCost +
  23. EstimatedDistance(n, destination), newPath);
  24. }
  25. }
  26. return null;
  27. }

Node represents a tile on the map, and a Path is a sequence of zero or more Nodes which maintains the order of traversal. If the last Node in a Path is the destination node, then the coordinates of the path are returned to the caller.

The A* algorithm is based on two collections. The first is the open list, a collection of partial Paths that have been explored, ordered by the path length, so obtaining the lowest cost partial path currently known is a fast operation. The second is the closed list, a collection of Nodes that have already been examined. Initially, the open list contains a Pathcontaining only the starting position, and the closed list is empty.

The algorithm proceeds as follows:

So, for this map, there are eight neighboring grass tiles of the tent tile that do not contain impassable features, so in the first iteration of the algorithm, eight new paths are added to the open list to explore.

When a Path is added to the open list, a cost function is computed for it, which consists of the known cost of the path so far, plus an estimate to the goal. Here, NeighborDistance() reflects the terrain features, and EstimatedDistance() is the Euclidian distance to the goal, to ensure that the cost is not overestimated. For a standard map, implemented by class StandardMap, the neighbor cost is calculated as:

  1. // AStar\Main.cs
  2. protected override double Distance(Node n1, Node n2)
  3. {
  4. // cost is interpreted as the terrain cost
  5. // of the square being moved on to
  6. char m = MapData[n2.Y][n2.X];
  7. char o = OverlayData[n2.Y][n2.X];
  8.  
  9. // Grass=1, Snow=3
  10. // Road reduces the cost by 1/2
  11. double distance = 1.0;
  12. if (m == 'S')
  13. distance = 3.0;
  14. if (o == 'R')
  15. distance /= 2.0;
  16.  
  17. // if both X and Y change, then moving in a diagonal,
  18. // so the distance is greater
  19. if ((n1.X != n2.X) && (n1.Y != n2.Y))
  20. distance *= 1.414;
  21.  
  22. return distance;
  23. }

Executing the above finds this path:

Road Walls A

As expected, the A* algorithm determines that following the road as much as possible produces the lowest-cost path. If the wall is extended slightly, however, even the reduced cost of the road does not compensate for the extra distance.

Road Walls B

The cost function can be manipulated in many ways. Consider the desire for the "warmest path" when walking in snow. If the temperature is constant, then the path from the tent to the castle is a straight line, corresponding to the shortest distance between the two locations.

Volcano Dormant

Suppose the volcano erupts, which increases the ambient temperature in the vicinity of the volcano. The path found now bends toward the volcano, in a compromise between travelling farther and being warm.

Volcano Erupting

The cost function for the above looks like the following, where a lowercase "v" in the map definition string array represents a dormant volcano, and an uppercase "V" represents an erupting one:

  1. // AStar\Main.cs
  2. class VolcanoMap : StandardMap
  3. {
  4. public VolcanoMap(string[] mapData, string[] overlayData) :
  5. base(mapData, overlayData) { }
  6.  
  7. protected override double Distance(Node n1, Node n2)
  8. {
  9. double distance = base.Distance(n1, n2);
  10.  
  11. for (int j = 0; j < OverlayData.Length; j++)
  12. for (int i = 0; i < OverlayData[0].Length; i++)
  13. if (OverlayData[j][i] == 'V')
  14. {
  15. double dV = Math.Sqrt(((i - n2.X) * (i - n2.X)) +
  16. ((j - n2.Y) * (j - n2.Y)));
  17. if (dV <= 4.5)
  18. distance /= ((5.0-dV)*2.0);
  19. }
  20.  
  21. return distance;
  22. }
  23. }

The cost function can also be manipulated to produce repulsive as well as attractive features. The desire to stay away from a tombstone generates a path to the south. The stairstep in the path is likely due to the hard effect cutoff at a distance of 2.5 from the tombstone. The effect of the tombstone isn't experienced until a move is made within its radius, which then results in the next step of the path moving back out of the affected area again.

Volcano Dormant Tombstone

A lowercase "t" in the map definition indicates a tombstone, and its repulsive effect can be produced as follows:

  1. // AStar\Main.cs
  2. class VolcanoTombstoneMap : VolcanoMap
  3. {
  4. public VolcanoTombstoneMap(string[] mapData, string[] overlayData) :
  5. base(mapData, overlayData) { }
  6.  
  7. protected override double Distance(Node n1, Node n2)
  8. {
  9. double distance = base.Distance(n1, n2);
  10.  
  11. for (int j = 0; j < OverlayData.Length; j++)
  12. for (int i = 0; i < OverlayData[0].Length; i++)
  13. if (OverlayData[j][i] == 't')
  14. {
  15. double dV = Math.Sqrt(((i - n2.X) * (i - n2.X)) +
  16. ((j - n2.Y) * (j - n2.Y)));
  17. if (dV <= 2.5)
  18. distance *= (((3.0 - dV)+0.5)*1.1);
  19. }
  20.  
  21. return distance;
  22. }
  23. }

The path with an erupting volcano and a tombstone is even closer to the volcano than with the volcano alone, where the desire to stay away from the tombstone contributes to the added path distance.

Volcano Erupting Tombstone

Summary

The A* algorithm is straightfoward to implement, yet is very useful in finding paths in a variety of scenarios. Originally designed for robot navigation, examples of its use range from route planning to natural language parsing. It also has been shown to often perform better than other search algorithms, such as Dijkstra's Algorithm or Greedy Best-First-Search.

References

Software Engineering Tech Trends (SETT) is a regular publication featuring emerging trends in software engineering.


secret