Solving Mazes with Artificial Intelligence: A Python Tutorial

Rami Jaloudi
10 min readMar 22, 2024

--

Maze solved using AI — DFS and BFS Search Algorithms

Navigating through mazes might seem like a simple task at first glance, but it introduces fundamental concepts in artificial intelligence and algorithms. This tutorial explores how we can use Python to generate and solve complex mazes with AI techniques. We’ll break down the process into generating a maze and solving it using two different search strategies: depth-first search (DFS) and breadth-first search (BFS).

Generating a Complex Maze

The first part of our journey involves generating a random, complex maze. We use a recursive backtracking algorithm, which ensures that our generated maze is solvable, with a unique path from start to finish. The essence of the algorithm lies in carving out passages within a grid of cells, which initially are all walls.

Here’s a simplified breakdown of the maze generation process:

  1. Initialize the Maze: Create a grid where every cell is initially a wall.
  2. Carve Passages: Starting from a random cell, recursively explore neighboring cells. If a neighbor has not been visited, remove the wall between the current cell and the neighbor, and continue the process from this neighbor.
  3. Mark Start and Goal: Designate two cells as the start and goal of the maze.

The generated maze is then saved to a file, ready to be solved.

import os
import random

def generate_complex_maze(width, height, start, goal):
# Initialize maze with walls
maze = [['#' for _ in range(2 * width + 1)] for _ in range(2 * height + 1)]

# Function to add passages
def add_passages(x, y):
stack = [(x, y)]

while stack:
x, y = stack[-1]
maze[2*y+1][2*x+1] = ' '

# Find unvisited neighbours
neighbours = []
for nx, ny in [(x-1, y), (x+1, y), (x, y-1), (x, y+1)]:
if 0 <= nx < width and 0 <= ny < height:
if maze[2*ny+1][2*nx+1] == '#':
neighbours.append((nx, ny))

if neighbours:
nx, ny = random.choice(neighbours)
stack.append((nx, ny))

# Remove wall between cells
maze[y+ny+1][x+nx+1] = ' '
else:
stack.pop()

# Start adding passages
add_passages(random.randint(0, width-1), random.randint(0, height-1))

# Mark the start and goal
maze[2*start[1]+1][2*start[0]+1] = 'A'
maze[2*goal[1]+1][2*goal[0]+1] = 'B'

return maze

def save_maze_to_file(maze, file_path):
# Ensure the directory exists
directory = os.path.dirname(file_path)
if not os.path.exists(directory):
os.makedirs(directory)

with open(file_path, "w") as file:
for row in maze:
file.write(''.join(row) + '\n')

# Do not set a fixed random seed to ensure different mazes are generated
# random.seed() # Removed for variability

maze_width, maze_height = 15, 10
maze_start, maze_goal = (0, 0), (maze_width - 1, maze_height - 1)

maze = generate_complex_maze(maze_width, maze_height, maze_start, maze_goal)

maze_path = r"C:\path\to\your\directory\maze.txt"
save_maze_to_file(maze, maze_path)

print(f"Maze saved to {maze_path}")

Save the above file as “randomMaze.py”. The next file described later in this tutorial should be saved as “maze.py”.

Now, open termina (i.e., cmd) in the applicable directory (where you are saving your project) and type the following command:

python randomMaze.py

Brief Explanation of the above Maze generation code:

  • **Note: Before moving to the next steps, make sure your path is set correctly, as the line above that reads as follows is generic: maze_path = r”C:\path\to\your\directory\maze.txt

Moving on….this code is designed to generate a maze and then save that maze to a text file. The process is split into two main parts: generating the maze and saving it. Here’s an in-depth explanation of each part:

Maze Generation

Import Necessary Modules: The script starts by importing the os module for interacting with the operating system and the random module for generating random numbers, which are crucial for creating a complex and unpredictable maze.

Initialize the Maze: The generate_complex_maze function initializes a 2D list (maze) that represents the maze. Each cell of the maze is initially set as a wall ('#').

Add Passages: Inside the generate_complex_maze function, there's a nested function called add_passages that uses a stack to implement a depth-first search algorithm to carve out passages (' '). It does so by:

  • Starting from a random cell and marking it as part of the passage.
  • Then, for the current cell, it looks for neighboring cells (up, down, left, right) that have not been visited (still walls).
  • If there are unvisited neighbors, it randomly selects one, removes the wall between the current cell and the selected neighbor (by setting it to ' '), and continues the process from this neighbor.
  • This process repeats until there are no unvisited neighbors, at which point the algorithm backtracks to previous cells (using the stack) until it finds cells with unvisited neighbors.

Mark Start and Goal: The function marks specified starting and ending points in the maze with 'A' and 'B', respectively, ensuring that these points are always accessible.

Return the Maze: After fully generating the maze, the function returns the 2D list representing the maze.

Saving the Maze

Ensure Directory Exists: Before saving the maze, the save_maze_to_file function checks if the directory of the specified file path exists. If not, it creates the necessary directory using os.makedirs(directory).

Write Maze to File: It then opens the specified file in write mode and writes each row of the maze, converting the list of cells into a string and appending a newline character to each row, effectively saving the maze’s representation to the file.

Execution

  • The script sets parameters for the maze size (maze_width and maze_height) and the start and goal positions.
  • It then generates the maze with these parameters and saves it to a specified path (maze_path).
  • Finally, it prints a message indicating that the maze has been saved to the file.

This code effectively demonstrates how to programmatically generate and save complex mazes, showcasing the application of algorithms in creating and solving puzzles or games.

Solving the Maze with AI

The second part of our tutorial focuses on solving the maze. We introduce two fundamental AI search strategies:

  • Depth-First Search (DFS): This strategy uses a stack to keep track of the path. It explores as far as possible along a branch before backtracking.
  • Breadth-First Search (BFS): In contrast, BFS uses a queue to explore the maze level by level, ensuring the shortest path is found if one exists.

Both strategies rely on the concept of a frontier — a collection of all the leaf nodes available for expansion at any given point — and an explored set, ensuring no state is visited more than once.

The AI Solution in Steps:

  1. Define the Problem State: Each position in the maze is a state the AI can be in.
  2. Expand States: From any given state, the AI can move up, down, left, or right, provided it’s not hitting a wall.
  3. Search for the Goal: Using either DFS or BFS, the AI searches for the goal, marking the path taken.
  4. Backtrack to Find the Solution: Once the goal is reached, backtrack from the goal to the start to find the solution path.

Visualization and Analysis

After solving the maze, we also demonstrate how to visualize the solution path and explored states using the Python Imaging Library (PIL). This visual representation not only makes it easier to understand the AI’s path through the maze but also allows for a comparative analysis of the DFS and BFS strategies in terms of efficiency and path length.

import sys

class Node():
def __init__(self, state, parent, action):
self.state = state
self.parent = parent
self.action = action


class StackFrontier():
def __init__(self):
self.frontier = []

def add(self, node):
self.frontier.append(node)

def contains_state(self, state):
return any(node.state == state for node in self.frontier)

def empty(self):
return len(self.frontier) == 0

def remove(self):
if self.empty():
raise Exception("empty frontier")
else:
node = self.frontier[-1]
self.frontier = self.frontier[:-1]
return node


class QueueFrontier(StackFrontier):

def remove(self):
if self.empty():
raise Exception("empty frontier")
else:
node = self.frontier[0]
self.frontier = self.frontier[1:]
return node

class Maze():

def __init__(self, filename):

# Read file and set height and width of maze
with open(filename) as f:
contents = f.read()

# Validate start and goal
if contents.count("A") != 1:
raise Exception("maze must have exactly one start point")
if contents.count("B") != 1:
raise Exception("maze must have exactly one goal")

# Determine height and width of maze
contents = contents.splitlines()
self.height = len(contents)
self.width = max(len(line) for line in contents)

# Keep track of walls
self.walls = []
for i in range(self.height):
row = []
for j in range(self.width):
try:
if contents[i][j] == "A":
self.start = (i, j)
row.append(False)
elif contents[i][j] == "B":
self.goal = (i, j)
row.append(False)
elif contents[i][j] == " ":
row.append(False)
else:
row.append(True)
except IndexError:
row.append(False)
self.walls.append(row)

self.solution = None


def print(self):
solution = self.solution[1] if self.solution is not None else None
print()
for i, row in enumerate(self.walls):
for j, col in enumerate(row):
if col:
print("█", end="")
elif (i, j) == self.start:
print("A", end="")
elif (i, j) == self.goal:
print("B", end="")
elif solution is not None and (i, j) in solution:
print("*", end="")
else:
print(" ", end="")
print()
print()


def neighbors(self, state):
row, col = state
candidates = [
("up", (row - 1, col)),
("down", (row + 1, col)),
("left", (row, col - 1)),
("right", (row, col + 1))
]

result = []
for action, (r, c) in candidates:
if 0 <= r < self.height and 0 <= c < self.width and not self.walls[r][c]:
result.append((action, (r, c)))
return result


def solve(self):
"""Finds a solution to maze, if one exists."""

# Keep track of number of states explored
self.num_explored = 0

# Initialize frontier to just the starting position
start = Node(state=self.start, parent=None, action=None)
frontier = StackFrontier()
frontier.add(start)

# Initialize an empty explored set
self.explored = set()

# Keep looping until solution found
while True:

# If nothing left in frontier, then no path
if frontier.empty():
raise Exception("no solution")

# Choose a node from the frontier
node = frontier.remove()
self.num_explored += 1

# If node is the goal, then we have a solution
if node.state == self.goal:
actions = []
cells = []
while node.parent is not None:
actions.append(node.action)
cells.append(node.state)
node = node.parent
actions.reverse()
cells.reverse()
self.solution = (actions, cells)
return

# Mark node as explored
self.explored.add(node.state)

# Add neighbors to frontier
for action, state in self.neighbors(node.state):
if not frontier.contains_state(state) and state not in self.explored:
child = Node(state=state, parent=node, action=action)
frontier.add(child)


def output_image(self, filename, show_solution=True, show_explored=False):
from PIL import Image, ImageDraw
cell_size = 50
cell_border = 2

# Create a blank canvas
img = Image.new(
"RGBA",
(self.width * cell_size, self.height * cell_size),
"black"
)
draw = ImageDraw.Draw(img)

solution = self.solution[1] if self.solution is not None else None
for i, row in enumerate(self.walls):
for j, col in enumerate(row):

# Walls
if col:
fill = (40, 40, 40)

# Start
elif (i, j) == self.start:
fill = (255, 0, 0)

# Goal
elif (i, j) == self.goal:
fill = (0, 171, 28)

# Solution
elif solution is not None and show_solution and (i, j) in solution:
fill = (220, 235, 113)

# Explored
elif solution is not None and show_explored and (i, j) in self.explored:
fill = (212, 97, 85)

# Empty cell
else:
fill = (237, 240, 252)

# Draw cell
draw.rectangle(
([(j * cell_size + cell_border, i * cell_size + cell_border),
((j + 1) * cell_size - cell_border, (i + 1) * cell_size - cell_border)]),
fill=fill
)

img.save(filename)


if len(sys.argv) != 2:
sys.exit("Usage: python maze.py maze.txt")

m = Maze(sys.argv[1])
print("Maze:")
m.print()
print("Solving...")
m.solve()
print("States Explored:", m.num_explored)
print("Solution:")
m.print()
m.output_image("maze.png", show_explored=True)

Save the above file as “maze.py”. The file we described earlier in this tutorial was saved as “randomMaze.py”.

Now, open termina (i.e., cmd) in the applicable directory (where you saved your project and first file) and type the following command:

python maze.py maze.txt

You will see in the command line that your randomly generated Maze was solved by AI. In the same directory, you will also find a maze.png file that looks something like this image, demonstrating how the algorithms found the optimal solution under the algorithms built within the code:

Maze solved using AI

Brief Explanation of the above AI Maze Solution Code:

This code is a comprehensive solution to navigating through a maze represented in a text file, employing artificial intelligence techniques. It defines classes for nodes, frontiers (both stack and queue implementations), and the maze itself, incorporating methods to parse, solve, and visualize the maze. Here’s a breakdown:

Core Components

  • Node Class: Represents a position in the maze with attributes for the current state (position), the parent node (previous step), and the action taken to reach this state. This class is fundamental for tracing the path from the start to the goal.
  • StackFrontier Class: Implements a frontier using a stack data structure (LIFO — Last In, First Out), suitable for depth-first search (DFS). It includes methods to add nodes, check for the existence of a state, verify if the frontier is empty, and remove a node.
  • QueueFrontier Class: Inherits from StackFrontier and overrides the remove method to implement a queue data structure (FIFO — First In, First Out), making it suitable for breadth-first search (BFS). This variation ensures that the earliest added nodes are explored first.

Maze Class

  • Initialization: Reads a maze from a given file, identifying the start (A), goal (B), walls, and open spaces. It constructs a 2D list (walls) representing the maze, where each cell indicates whether it's a wall or not.
  • Print Function: Outputs the current state of the maze, marking the start, goal, and, if available, the solution path and explored nodes.
  • Neighbors Function: Given a state (position), it calculates and returns a list of valid moves (up, down, left, right) that do not lead into walls.
  • Solve Function: Attempts to find a path from the start to the goal using the frontier (either stack or queue, depending on the desired search strategy). It tracks explored states to avoid revisiting them, and upon reaching the goal, it reconstructs the path taken.
  • Output Image Function: Uses the Pillow library to generate an image of the maze. It visually distinguishes walls, the start and goal positions, the solution path (if found), and the states explored during the search. This function allows for a more intuitive understanding of the maze-solving process and the algorithm’s performance.

Execution and Visualization

The script is designed to be run from the command line, taking a single argument: the path to the maze file. It reads the maze, prints its initial state, attempts to solve it, and then prints the solution along with the number of states explored. Finally, it generates an image file (maze.png) depicting the maze and the search results.

This code demonstrates practical applications of DFS and BFS in solving mazes, highlighting differences in their search patterns and efficiency. It provides a clear example of how AI algorithms can be implemented to navigate complex environments.

Solving mazes with AI introduces fundamental concepts in algorithmic thinking, such as recursion, backtracking, and graph search strategies. By implementing these concepts in Python, we gain a deeper understanding of how AI can tackle complex problems. Whether you’re a beginner in programming or AI, this tutorial offers valuable insights into algorithm design and problem-solving strategies.

By exploring and implementing the code to generate and solve mazes, you’ll have taken a significant step towards understanding the intricacies of artificial intelligence and its applications. Happy coding, and may you find your way through many more mazes in the future!

--

--

Rami Jaloudi

I'm a Tech Enthusiast, including Computer Science, Data Analytics, eDiscovery, Data Mining & Blockchain. I write about things that interest me.