Technical Report · C++14 · Pathfinding

A* Pathfinding
Algorithm

A grid-based shortest-path implementation using the A* search algorithm with Manhattan distance heuristic, written in modern C++14.

Andrew FoxC++14Visual Studio 2022

01

Project Overview

This project implements the A* pathfinding algorithm in modern C++14, as specified in the project brief. The application finds the shortest path between two points on a configurable grid while navigating around obstacles, producing a colour-coded ASCII representation of the result in the console.

Grid Pathfinding
Configurable grid with adjustable dimensions, start, goal, and obstacles
Dual Obstacle Modes
Fixed maze layout or random generation with auto-retry if unsolvable
Manhattan Heuristic
Admissible heuristic guaranteeing optimal paths on a 4-directional grid
Colour Output
ANSI-coloured console with box-drawing borders and legend
Modular Design
Separated across 6 files for clean separation of concerns
Test Suite
10 validation checks that gate the pathfinding execution

02

The A* Algorithm

A* is a heuristic-based search algorithm that finds the shortest path between two points. It maintains an open list of candidate nodes, always expanding the node with the lowest total estimated cost f = g + h, where g is the actual cost from the start and h is a heuristic estimate of the remaining cost to the goal.

What makes A* effective is that it combines the thoroughness of Dijkstra's algorithm with the directional focus of greedy best-first search. By using an admissible heuristic (one that never overestimates), A* is guaranteed to find the optimal path while exploring significantly fewer nodes than an exhaustive search.

Algorithm Property

A* is complete (always finds a path if one exists) and optimal (the path found is guaranteed to be shortest) when the heuristic is admissible.

The five snapshots below show A* searching a 9x7 grid with two walls (columns 3 and 6, gaps at rows 3 and 5). Start is at (0,0), goal at (8,6). Each explored cell shows its f value with g and h below. Teal-bordered cells are in the open list (frontier). All values were computed by running the actual algorithm.

Step 6 - Early exploration
Sg:0 h:14
f=14g:1 h:13
f=14g:2 h:12
#
.
.
#
.
.
f=14g:1 h:13
f=14g:2 h:12
f=14g:3 h:11
#
.
.
#
.
.
f=14g:2 h:12
f=14g:3 h:11
.
#
.
.
#
.
.
f=14g:3 h:11
.
.
.
.
.
#
.
.
.
.
.
#
.
.
#
.
.
.
.
.
#
.
.
.
.
.
.
.
.
#
.
.
#
.
G
The search expands outward from S. The first wall blocks direct progress eastward.
Step 18 - Filling the left chamber
Sg:0 h:14
f=14g:1 h:13
f=14g:2 h:12
#
.
.
#
.
.
f=14g:1 h:13
f=14g:2 h:12
f=14g:3 h:11
#
.
.
#
.
.
f=14g:2 h:12
f=14g:3 h:11
f=14g:4 h:10
#
.
.
#
.
.
f=14g:3 h:11
f=14g:4 h:10
f=14g:5 h:9
f=14g:6 h:8
.
.
#
.
.
f=14g:4 h:10
f=14g:5 h:9
f=14g:6 h:8
#
.
.
#
.
.
f=14g:5 h:9
f=14g:6 h:8
f=14g:7 h:7
#
.
.
.
.
.
f=14g:6 h:8
f=14g:7 h:7
.
#
.
.
#
.
G
The entire left chamber is explored. The gap at row 3 has been found and the open list is about to push through.
Step 30 - Through both walls
Sg:0 h:14
f=14g:1 h:13
f=14g:2 h:12
#
.
.
#
.
.
f=14g:1 h:13
f=14g:2 h:12
f=14g:3 h:11
#
.
.
#
.
.
f=14g:2 h:12
f=14g:3 h:11
f=14g:4 h:10
#
f=16g:8 h:8
f=16g:9 h:7
#
.
.
f=14g:3 h:11
f=14g:4 h:10
f=14g:5 h:9
f=14g:6 h:8
f=14g:7 h:7
f=14g:8 h:6
#
.
.
f=14g:4 h:10
f=14g:5 h:9
f=14g:6 h:8
#
f=14g:8 h:6
f=14g:9 h:5
#
.
.
f=14g:5 h:9
f=14g:6 h:8
f=14g:7 h:7
#
f=14g:9 h:5
f=14g:10 h:4
f=14g:11 h:3
.
.
f=14g:6 h:8
f=14g:7 h:7
f=14g:8 h:6
#
f=14g:10 h:4
f=14g:11 h:3
#
.
G
The f=16 nodes at (4,2) and (5,2) are detours that A* correctly deprioritises. The gap at (6,5) has been reached.
Step 35 - Goal reached
Sg:0 h:14
f=14g:1 h:13
f=14g:2 h:12
#
.
.
#
.
.
f=14g:1 h:13
f=14g:2 h:12
f=14g:3 h:11
#
.
.
#
.
.
f=14g:2 h:12
f=14g:3 h:11
f=14g:4 h:10
#
f=16g:8 h:8
f=16g:9 h:7
#
.
.
f=14g:3 h:11
f=14g:4 h:10
f=14g:5 h:9
f=14g:6 h:8
f=14g:7 h:7
f=14g:8 h:6
#
.
.
f=14g:4 h:10
f=14g:5 h:9
f=14g:6 h:8
#
f=14g:8 h:6
f=14g:9 h:5
#
f=16g:13 h:3
f=16g:14 h:2
f=14g:5 h:9
f=14g:6 h:8
f=14g:7 h:7
#
f=14g:9 h:5
f=14g:10 h:4
f=14g:11 h:3
f=14g:12 h:2
f=14g:13 h:1
f=14g:6 h:8
f=14g:7 h:7
f=14g:8 h:6
#
f=14g:10 h:4
f=14g:11 h:3
#
f=14g:13 h:1
Gg:14 h:0
The goal at (8,6) has been reached with g=14 (14 steps). The f=16 open nodes were never needed. A* found the optimal path without exploring them.
Path reconstructed (14 steps)
S
f=14
f=14
#
.
.
#
.
.
*
f=14
f=14
#
.
.
#
.
.
*
f=14
f=14
#
.
.
#
.
.
*
*
*
*
*
f=14
#
.
.
f=14
f=14
f=14
#
*
f=14
#
.
.
f=14
f=14
f=14
#
*
*
*
*
f=14
f=14
f=14
f=14
#
f=14
f=14
#
*
G
The optimal path is reconstructed by following parent pointers from G back to S. Path: (0,0)→(0,1)→(0,2)→(0,3)→(1,3)→(2,3)→(3,3)→(4,3)→(4,4)→(4,5)→(5,5)→(6,5)→(7,5)→(7,6)→(8,6)
Start
Goal
Closed
Open
Path
Wall
Unexplored

03

Design Choices

Structure-first Approach

Rather than jumping straight into the A* algorithm, I chose to build the project from the outside in, starting with the grid configuration and visualisation, then the test suite, and only then the pathfinding logic itself. This was a deliberate decision: I wanted to fully understand how the code would run and how the different components would interact before tackling the most complex part.

The gridParameters Struct

The gridParameters struct serves as a centralised settings hub for the entire project. It holds the grid dimensions, start and goal positions, the obstacle list, and the random generation toggle. All of these parameters are configurable. The algorithm, visualiser, and test suite all operate on this single struct, meaning any configuration change propagates everywhere automatically.

Memory Management with Smart Pointers

As part of the project requirements, smart pointers were to be used where possible. Nodes created during the A* search are managed using std::unique_ptr via a std::vector<std::unique_ptr<Node>> container. This ensures all dynamically allocated nodes are automatically cleaned up when the pathfinding function returns, without needing manual delete calls.

Dual Obstacle Modes

The project supports two obstacle modes. The default is a fixed maze layout with vertical walls at regular intervals, each with a gap at a different row to force a zigzag path. The second mode generates random obstacles, controlled by the randomObstaclesEnabled flag and randomObstaclesCount parameter. When random mode is active, the program automatically regenerates the obstacle layout if no valid path exists, retrying up to 20 attempts before reporting failure.


04

Development Process

This section documents how the project was built iteratively, from the first lines of code through to the final implementation.

Phase 1 - Grid Structure & Visualisation

I began by focusing on the gridParameters struct and the grid display, before writing any pathfinding code. The very first version used a std::vector<std::vector<int>> to represent the grid:

Early output - int grid
0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0

Changing the grid type from std::vector<std::vector<int>> to std::vector<std::vector<std::string>> let me use meaningful characters:

Revised output - string grid
. . # . . . # . . . . . . . . # . . . . # . . . # . . . . # . . . # . . . . # . . . # . . . . # . . . . . . . . # . . . . . .
Design Insight

The switch from int to string was a small type change, but it unlocked a much more expressive grid. Each cell could hold not just a symbol but also ANSI colour codes, opening the door to the entire colour-coded visualisation system.

Phase 2 - Start and Goal Markers

Once the grid displayed obstacles clearly, I added S for start and G for goal:

Output with start and goal markers
S . # . . . # . . . . . . . . # . . . . # . . . # . . . . # . . . # . . . . # . . . # . . . . # . . . . . . . . # . . . . . G

Phase 3 - Test Suite (Before the Algorithm)

Before writing any A* code, I built the test suite. The runTests() function checks 10 conditions and the main function only proceeds if every test passes.

Test suite output
--- Grid Parameter Tests --- [PASS] Test 1: Grid dimensions are valid (25x25) [PASS] Test 2: Start is within bounds (0, 0) [PASS] Test 3: Goal is within bounds (24, 24) [PASS] Test 4: All obstacles are within bounds [PASS] Test 5: Start is not on an obstacle [PASS] Test 6: Goal is not on an obstacle [PASS] Test 7: Start and goal are different positions [PASS] Test 8: randomObstaclesCount is non-negative [PASS] Test 9: randomObstaclesCount is feasible for grid size [PASS] Test 10: Obstacles are unique --- Results: 10 passed, 0 failed ---

Phase 4 - Understanding A* (Theory)

Before writing the algorithm, I studied the theory by watching the video "A* Pathfinding Algorithm" (linked in References). This gave me a clear understanding of the Node struct, specifically the cost values g, h, and f = g + h.

Phase 5 - A* Implementation

With the theory understood and a validated, visual grid in place, I implemented the A* algorithm. Having the grid display already working meant I could immediately see the path output and verify it visually.

Phase 6 - Random Obstacle Generation

After A* was working with the fixed maze, I implemented random obstacle generation using std::mt19937. Obstacles are never placed on or adjacent to start and goal. If no path exists, the program regenerates and retries up to 20 times.

Phase 7 - Console Colours & Styling

The visual styling was the last thing I added. I created ConsoleColors.h for ANSI escape codes, added enableColors() for Windows, and implemented box-drawing borders with a colour-coded legend.

Final output - colour-coded grid with path
═══ Path Visualization ═══ ╔═══════════════════╗ ║ S . # . . . # . . ║ ║ * * * * . . # . . ║ ║ . . # * . . # . . ║ ║ . . # * . . # . . ║ ║ . . # * . . # . . ║ ║ . . # * * * * * . ║ ║ . . # . . . # * G ║ ╚═══════════════════╝ S = Start G = Goal # = Obstacle * = Path

05

Data Structures

StructurePurpose
gridParametersCentralised settings: dimensions, start/goal, obstacle list, random generation config
NodeSearch cell with position, cost values (g, h, f), and parent pointer
AStarAlgorithmPathfinding logic, open/closed list management, and grid visualisation

Node Cost Values

g
Cost from Start
Actual steps taken to reach this node from the start
h
Heuristic to Goal
Manhattan distance estimate to the goal
f
Total Estimated Cost
f = g + h - priority queue expands the lowest f

File Structure

FileResponsibility
AStarAlgorithm.hDeclarations for gridParameters, Node, and AStarAlgorithm
AStarAlgorithm.cppA* implementation, grid population, and rendering
GridUtils.cppRandom obstacle generation logic
Tests.cpp10 validation checks for grid configuration
ConsoleColors.hANSI colour constants and Windows console setup
main.cppEntry point - runs tests, then pathfinding and display

06

Heuristic: Manhattan Distance

The project brief specified Manhattan distance as the required heuristic. For a grid with only horizontal and vertical movement:

// Manhattan distance formula h(n) = |n.x - goal.x| + |n.y - goal.y|

This is admissible because no path through adjacent cells can ever be shorter than the Manhattan distance. It is also consistent (monotone), avoiding redundant node re-expansion.

Diagonal Note

If diagonal movement were added in a future iteration, the heuristic would need to be updated to Euclidean or Chebyshev distance to preserve admissibility.


07

Grid Visualization

The grid visualisation evolved significantly during development (see Section 04). The final version uses ANSI escape codes for colour, box-drawing characters for borders, and a legend at the bottom.

Grid Symbols

SStart
GGoal
#Obstacle
*Path
.Empty

Two Display Modes

The populateGrid() function is overloaded with two versions: one that shows the grid before pathfinding, and one that overlays the discovered path using * symbols.

Colour System

ANSI escape codes are defined in ConsoleColors.h and embedded directly into each grid cell string. On Windows, virtual terminal processing is enabled at startup via enableColors(). The colours are: green for start, cyan for goal, red for obstacles, and yellow for the path.


08

Testing & Validation

The test suite runs automatically before any pathfinding occurs. If any test fails, the program halts immediately.

#Test CaseDescriptionStatus
1Grid dimensionsWidth and height must be positive✓ Pass
2Start in boundsStart position within grid extents✓ Pass
3Goal in boundsGoal position within grid extents✓ Pass
4Obstacles in boundsAll obstacles within grid extents✓ Pass
5Start not on obstacleStart cell is not blocked✓ Pass
6Goal not on obstacleGoal cell is not blocked✓ Pass
7Start ≠ GoalStart and goal are different positions✓ Pass
8Random count validrandomObstaclesCount is non-negative✓ Pass
9Random count feasibleCount does not exceed total grid cells✓ Pass
10No duplicatesAll obstacle coordinates are unique✓ Pass
Design Decision

Building the test suite before the A* algorithm was a deliberate choice. It meant the algorithm was always tested against validated configurations from day one.


09

Limitations & Future Improvements

Current Limitations

No diagonal movementNo weighted terrainUniform path colour

Movement is restricted to four cardinal directions, and all traversable cells share a uniform cost of 1. The path is displayed in a single colour, which gives no indication of progress along the route.

Planned Enhancements

Weighted terrain would let different cells have different movement costs, so the algorithm could model things like rough ground or roads. This would require changing the cost calculation in findPath() from a flat +1 to a lookup based on cell type, and swapping the heuristic if diagonal movement were also added. A path colour gradient (fading from green to yellow along the route) would give a visual sense of progress and distance from the start, which the current single-colour path does not communicate.

Weighted terrainPath colour gradient

10

Development Timeline

Phase 1
Project setup, gridParameters struct, initial int grid display
Phase 2
Switched grid from int to string - introduced readable symbols
Phase 3
Built the test suite - 10 validation checks as a gatekeeper
Phase 4
Studied A* theory: g/h/f values, algorithm formula via reference video
Phase 5
Implemented A* algorithm, path reconstruction, and visualisation overlay
Phase 6
Random obstacle generation with auto-retry for unsolvable grids
Phase 7
ANSI colours, box-drawing borders, legend, and final polish

Tools Used

ToolPurpose
Visual Studio 2022Primary IDE and debugger
C++14 StandardLanguage specification

11

Reflection

Challenges Encountered

The biggest early challenge was deciding how to represent the grid. Starting with a std::vector<std::vector<int>> worked for proving the basic structure, but the output was difficult to interpret. Switching to std::string solved the readability problem and opened up the ability to embed ANSI colour codes directly into each cell.

Building the test suite before the algorithm required discipline, as it felt like delaying the "real work", but it paid off significantly. Having validated inputs meant that when A* produced unexpected results, I could be confident the issue was in the algorithm, not the grid setup.

Key Learnings

Understanding A* required grasping not just the code but the mathematics behind it: why f = g + h works, why the heuristic must be admissible, and how the priority queue ensures the optimal node is always expanded next.

Working with std::unique_ptr for node management was a practical lesson in modern C++ memory management. Automatic cleanup via the allNodes vector eliminated the risk of memory leaks.

What I Would Do Differently

Looking back, the main thing I would change is better alignment with the C++ Core Guidelines from the start. The two populateGrid() overloads share most of their logic, with the only difference being the path overlay. Following F.51 (prefer default arguments over overloading), a single function with a default empty path parameter would remove that duplication. The grid dimensions are also static const int, meaning any size change requires recompiling. Making them regular members would allow runtime configuration without touching the header.

Inside findPath(), the isObstacle lambda scans the full obstacle list on every neighbour check. Building a std::vector<std::vector<bool>> once at the start would be simpler and faster. These are small things individually, but they add up, and catching them earlier would have saved time during later phases.

AI Assistance

I used AI tools (Claude) during this project for coding assistance and for formatting and styling the report. All AI-assisted code was limited in scope and adapted to fit my own design. I made sure I had a full understanding of everything in the codebase by building the project structure first, getting the grid, tests, and visualisation working before tackling the A* algorithm itself. This meant that by the time AI was involved in any coding help, I already understood how the components fit together and could verify that any suggestions were correct.


12

References

  1. A* Search Algorithm - Wikipedia
  2. Manhattan Distance (Taxicab Geometry) - Wikipedia
  3. std::pair - cppreference.com
  4. ANSI Escape Code - Wikipedia
  5. A* Pathfinding Algorithm - YouTube
  6. C++ Core Guidelines - Stroustrup & Sutter