exploring mazes and pathfinding with graph theory and shortest path algorithms

I chose to explore maze generation and pathfinding because the subject combines rigorous mathematics, algorithmic logic, and a surprising amount of creativity. A maze looks deliberately complex, yet many maze algorithms rely only on simple graph concepts and a bit of randomness. Likewise, pathfinding algorithms such as A* and Dijkstra’s find optimal routes even though their decisions are made one step at a time. I was drawn in by the clear real-world links, like GPS route planning, robotic navigation and procedurally generated game levels, which all use the same core ideas.

demo below, assuming Godot didn’t mess up.

While researching on this topic, I found this interesting youtube short, which was a direct reference for the demo.

Graph theory

In graph theory, we study objects called graphs, which consist of vertices (or nodes) connected by edges . A maze can be modelled with graphs. Every cell becomes a vertex, and every removed wall creates an edge between two vertices. For example:

A path is simply a sequence of adjacent vertices.

A cycle is a closed path where you can return to the start without repeating edges.

A graph is connected if there is a path between any two vertices.

A tree is a connected graph with no cycles.

The grid itself, before walls are carved, is a special type of graph called a lattice graph — specifically a 2D rectangular lattice where each node has up to 4 neighbours. This graph is regular and predictable, but once walls are removed using an algorithm, the structure becomes far more interesting.

Representing a maze as a graph matters because the algorithms I used, A* and Dijkstra, all operate on graphs. They don’t care whether the graph came from a maze, a road network, or a social network. This abstraction is what allows these ideas to apply to everything from Google Maps to neural network architectures.

Graph theory provides useful tools for analysing the behaviour of the maze:

The maze generated by DFS Backtracking is guaranteed to be a spanning tree: a subgraph that connects all vertices with no cycles. This property mathematically ensures a unique solution path between any two points.

The absence of cycles makes the complexity of pathfinding easier to analyse, because algorithms like A* operate more predictably on trees.

The structure of the graph affects the search space: long corridors reduce branching factor, while dense branching increases it. Branching factor directly affects the time complexity of pathfinding algorithms.

Understanding these properties helped me see why different algorithms behave differently even on the same maze: the mathematics of the graph strongly influences the mathematics of the search.

Maze Representation

Each cell stores:

[up, right, down, left]

with:

This is equivalent to storing the adjacency matrix of a planar grid graph but compressed per vertex.


The algorithm used is DFS Backtracking, which mathematically produces a random spanning treeof the grid graph.

A perfect maze must have:

A structure that is connected and cycle-free is, by definition, a tree.

Thus the algorithm constructs a spanning tree.

Algorithm Description (Mathematical Form)

The algorithm:

  1. Choose a start vertex ( v_0 ).
  2. Repeatedly:

    • Look for an unvisited neighbor ( u in N(v) ).
    • Choose one randomly.
    • Add edge ( (v, u) ) to the spanning tree.
    • Move to ( u ).
  3. If a vertex has no unvisited neighbors, backtrack.

This is equivalent to performing DFS on an undirected graph, but with randomized choices.

More formally:

[ T = { (v, Next(v)) during DFS traversal } ]

Because DFS never revisits a cell and never adds an edge to an already-visited vertex, cycles cannot form.


This code:

var unvisited_neighbors := []
for i in range(DIRS.size()):
    var nx = current.x + DIRS[i].x
    var ny = current.y + DIRS[i].y
    if nx >= 0 and nx < width and ny >= 0 and ny < height:
        if not visited[ny][nx]:
            unvisited_neighbors.append(i)

mathematically checks:

This is a classical neighbor set:

[ N(v) = { v + d in DIRS } ]

restricted by grid boundaries.

The “grid boundary” condition defines the domain of the graph.


This line:

var dir_index = unvisited_neighbors[rng.randi_range(0, unvisited_neighbors.size() - 1)]

implements a uniform random selection from a discrete set.

This randomness ensures the spanning tree produced is not deterministic unless seeded.


When the algorithm chooses a neighbor and moves there, it “knocks down” walls:

grid[current.y][current.x][dir_index] = false
grid[next.y][next.x][opposite] = false

The opposite direction uses modular arithmetic:

[ opposite = (i + 2) % 4 ]

This matches the fact that grid directions are symmetric:


var stack: Array = []
stack.append(start)

The DFS stack represents the recursion structure of DFS.

Whenever the algorithm backtracks:

stack.pop_back()

this is equivalent to returning from a recursive DFS call.


When the algorithm terminates:

Thus the maze is guaranteed to be a perfect maze— one path between any two points.

A* Pathfinding

A* is essentially an optimisation algorithm that solves the shortest-path problem using both real and estimated information. It is built on the idea of best-first search, but mathematically enhanced by evaluating each node using the formula: f(n)=g(n)+h(n) where g(n) is the known cost so far, h(n) is the heuristic, a mathematical estimate of future cost and f(n) is the total estimated cost of the best path passing through n.

The mathematical strength of A* comes from its use of heuristics.

  1. Admissibility
    • A heuristic h(n) is admissible if it never overestimates the true cost to reach the goal. For Manhattan distance on a grid, this is true because each move reduces the Manhattan distance by at most 1. By guaranteeing h(n)≤h*(n) (where h*(n)h^*(n)h*(n) is the true optimal cost), A* is mathematically guaranteed to find an optimal path.
  2. Consistency (Monotonicity)
    • A heuristic is consistent if:
    • h(n)≤cost(n,n′)+h(n′)h(n) - This ensures the estimated total cost f(n) never decreases along a path. Consistency means A* never needs to revisit a node once it has found the best path to it. This reduces time complexity significantly.
  3. Search-space reduction
    • One of the most mathematically powerful aspects of A* is the reduction in explored nodes. Without a heuristic, Dijkstra explores in all directions equally. A* explores a cone of directions that point roughly toward the goal. The branching factor and depth determine the theoretical complexity: Dijkstra: O(bd) in the worst case. A*: O(bd−k), where k depends on heuristic strength.

Thus, even a simple heuristic dramatically improves efficiency.

  1. Relationship to optimisation
    • A* can be viewed as minimising a cost function. In optimisation terms, it finds the path that minimises the energy f(n). This parallels gradient descent and other optimisation algorithms, showing how deep the mathematical connections run.

This was done with the below code.


extends Node
class_name Solver

signal path_updated(path: Array)
signal open_closed_updated(open_set: Array, closed_set: Array)

What this does


var astar: AStar2D
var width: int
var height: int

var start_id: int
var goal_id: int

var open_set: Array = []
var closed_set: Array = []
var came_from: Dictionary = {}
var g_score: Dictionary = {}
var f_score: Dictionary = {}
var path_computed: bool = false
var current_path: Array = []

State / data structures


func setup(astar_ref: AStar2D, w: int, h: int) -> void:
	astar = astar_ref
	width = w
	height = h

setup


func start_solver(start: Vector2i, goal: Vector2i) -> void:
	start_id = start.y * width + start.x
	goal_id = goal.y * width + goal.x

	open_set.clear()
	closed_set.clear()
	came_from.clear()
	g_score.clear()
	f_score.clear()
	current_path.clear()
	path_computed = false

	initialize_open_set()
	emit_signal("open_closed_updated", open_set, closed_set)

start_solver


# Override this in child classes
func initialize_open_set() -> void:
	pass

initialize_open_set


func step_solver() -> bool:
	if path_computed or open_set.is_empty():
		path_computed = true
		return true

	var current := pick_next_node()
	if current == goal_id:
		reconstruct_path(current)
		path_computed = true
		return true

	move_current_to_closed(current)

	process_neighbors(current)

	emit_signal("open_closed_updated", open_set, closed_set)
	return false

step_solver — single-step driver

This design makes it easy to run one solver step per frame to visualize progress.


func pick_next_node() -> int:
	return open_set[0]

pick_next_node (base)


func move_current_to_closed(current: int) -> void:
	open_set.erase(current)
	closed_set.append(current)

move_current_to_closed


func process_neighbors(current: int) -> void:
	for neighbor in astar.get_point_connections(current):
		if neighbor in closed_set:
			continue
		var tentative_g = g_score.get(current, 999999) + 1
		if neighbor not in open_set:
			open_set.append(neighbor)
		elif tentative_g >= g_score.get(neighbor, 999999):
			continue
		came_from[neighbor] = current
		g_score[neighbor] = tentative_g
		f_score[neighbor] = tentative_g + get_heuristic(neighbor)

process_neighbors — relaxing edges


func get_heuristic(id: int) -> int:
	return 0  # default Dijkstra/BFS has no heuristic

get_heuristic


func reconstruct_path(current: int) -> void:
	var temp := current
	var id_path: Array = [temp]
	while came_from.has(temp):
		temp = came_from[temp]
		id_path.insert(0, temp)

	var path_vec: Array = []
	for id in id_path:
		var v := astar.get_point_position(id)
		path_vec.append(Vector2i(int(v.x), int(v.y)))
	current_path = path_vec
	emit_signal("path_updated", current_path)

reconstruct_path


func get_current_path() -> Array:
	return current_path

getter


AStar subclass

extends Solver
class_name AStarSolver

func get_heuristic(id: int) -> int:
	var pos := astar.get_point_position(id)
	var goal_pos := astar.get_point_position(goal_id)
	return int(abs(pos.x - goal_pos.x) + abs(pos.y - goal_pos.y))  # Manhattan

func pick_next_node() -> int:
	var best = open_set[0]
	for id in open_set:
		if f_score.get(id, 999999) < f_score.get(best, 999999):
			best = id
	return best

func initialize_open_set() -> void:
	open_set.append(start_id)
	g_score[start_id] = 0
	f_score[start_id] = get_heuristic(start_id)

What AStarSolver changes

Dijkstra’s

Dijkstra’s Algorithm solves the single-source shortest path problem on graphs with non-negative edge weights . It does so by repeatedly selecting the node with the smallest tentative distance and “relaxing” its neighbours.

  1. Distance relaxation updates the estimated distance to a node. This iterative refinement is essentially dynamic programming: each update narrows the gap between the tentative and true shortest path values.

  2. The algorithm is correct because once a node is extracted from the priority queue, its tentative distance is guaranteed to be the true shortest distance. That happens because all edge weights are non-negative, so no later path can “shortcut” back to this node. This property fails if negative edges exist, which explains why Dijkstra cannot handle them.

  3. Special case: unweighted grids In an unweighted maze, every edge has weight of 1, which simplifies Dijkstra to:

    • Equivalent to BFS

    • Expands nodes in concentric layers

    • Uses a queue instead of a priority queue

Thus, Dijkstra’s behaviour becomes mathematically predictable and symmetric. This is because A* reduces to Dijkstra when h(n)=0. In other words, Dijkstra = A with no heuristic. This mathematical relationship shows how closely the two algorithms are connected.

Reflections

This project deepened my appreciation for how relatively simple mathematical ideas can power complex systems like traffic routing, public transport scheduling, and even evacuation planning, which use the same principles of graph modelling and shortest-path optimisation. Cool stuff, would not do again.