Pathfinding Algorithm Visualizer

Build a Flutter Web app that animates BFS, DFS, and Dijkstra's on a grid.

Introduction

30 Second Summary

Think about GPS navigation. When you type in a destination, an algorithm explores possible routes using a specific strategy, and the data structure behind that strategy determines whether you get the fastest route, a scenic detour, or just any path that works.

In this project, you will build a browser-based pathfinding visualizer with Flutter Web that lets users draw mazes and watch three different algorithms find a path in real time.

What You'll Build

A Flutter Web app where users draw walls on a grid, place start and end points, and visually compare how BFS, DFS, and Dijkstra's Algorithm explore a graph differently based on the data structure each one uses.

By the end of this project, you'll have:

  • Queue, Stack, and Priority Queue (Min-Heap) data structures implemented from scratch in Dart.
  • BFS, DFS, and Dijkstra's pathfinding algorithms that use those data structures to explore a grid.
  • An interactive CustomPainter visualizer that animates each algorithm's exploration step-by-step.
  • A live deployment to GitHub Pages as a shareable portfolio piece anyone can try.
  • Secret Mission: Add A* search with a Manhattan distance heuristic and compare how it explores fewer nodes than Dijkstra's.

Are there any prerequisites?

You should be familiar with the Flutter widget system (StatefulWidget, setState) and Dart language basics. No prior experience with data structures or algorithms is required.

Create the Flutter Web Project

Every visualizer starts with an empty canvas. Before you can animate algorithms or draw grids, you need a working Flutter web project that compiles and runs in the browser.

In this step, you'll scaffold a new Flutter project, organize it with placeholder files for each component you'll build later, and confirm everything runs in Chrome.

In this step, get ready to:
  • Create a new Flutter web project.
  • Set up placeholder files for data structures, algorithms, and widgets.
  • Verify the app runs in Chrome.
Create a new Flutter project

Flutter's CLI can scaffold a complete web-ready project with a single command. You'll create the project on your Desktop so it's easy to find.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Flutter project by running this command:
flutter create pathfinding_visualizer

What does this command do?

The flutter create command generates a complete Flutter project with web support included by default. It creates a lib/ folder for your Dart code, a web/ folder for browser-specific files, and a pubspec.yaml for managing dependencies.

  • Move into your new project folder by running:
cd pathfinding_visualizer
  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the pathfinding_visualizer folder, then click Open.

Why open the folder again?

Opening the project folder as your workspace gives VS Code full access to analyze your Dart files, provide autocomplete, and show errors inline. The integrated terminal also starts in the project root, so every command runs in the right place.

  • If VS Code asks whether you trust the authors of the files in this folder, click Yes, I trust the authors.
  • Confirm the folder opened correctly by checking the Explorer sidebar on the left. You should see lib/, web/, and pubspec.yaml listed.

Don't see project files in the sidebar?

Make sure you opened the pathfinding_visualizer folder itself, not the Desktop folder containing it. The Explorer panel should show lib/ at the top level.

Help me find my Flutter project files in VS Code.

Set up the file structure

Your visualizer will have several components: data structures, algorithms, a grid model, a painter, and the main page. Creating placeholder files now gives you a clear roadmap of what you'll build in the following steps.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Create the placeholder files by running these commands:

macOS

echo '// TODO: implement data structures' > lib/data_structures.dart
echo '// TODO: implement grid model' > lib/grid_model.dart
echo '// TODO: implement algorithms' > lib/algorithms.dart
echo '// TODO: implement grid painter' > lib/grid_painter.dart
echo '// TODO: implement visualizer page' > lib/visualizer_page.dart
  • Confirm the files exist by running:
ls lib/

You should see algorithms.dart, data_structures.dart, grid_model.dart, grid_painter.dart, main.dart, and visualizer_page.dart listed.

Windows

Set-Content -Path lib/data_structures.dart -Value '// TODO: implement data structures'
Set-Content -Path lib/grid_model.dart -Value '// TODO: implement grid model'
Set-Content -Path lib/algorithms.dart -Value '// TODO: implement algorithms'
Set-Content -Path lib/grid_painter.dart -Value '// TODO: implement grid painter'
Set-Content -Path lib/visualizer_page.dart -Value '// TODO: implement visualizer page'
  • Confirm the files exist by running:
ls lib/

You should see algorithms.dart, data_structures.dart, grid_model.dart, grid_painter.dart, main.dart, and visualizer_page.dart listed.

What are these files for?

  • data_structures.dart will hold your Queue, Stack, and PriorityQueue implementations.
  • grid_model.dart will define the Node and Grid classes that represent the graph.
  • algorithms.dart will contain BFS, DFS, and Dijkstra's algorithm functions.
  • grid_painter.dart will handle rendering the grid to the screen with CustomPainter.
  • visualizer_page.dart will be the main UI page with controls and interactions.

Now you'll simplify the default main.dart so it's a clean starting point for your visualizer.

  • In the Explorer sidebar, click lib/main.dart to open it.
  • Select all the existing code by pressing Cmd+A (macOS) or Ctrl+A (Windows).
  • Delete it and replace it with the following code:
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
        useMaterial3: true,
      ),
      home: const Placeholder(),
    );
  }
}

What does this code do?

  • runApp(const MyApp()) launches the Flutter app.
  • MaterialApp sets up Material 3 theming and navigation for a web app.
  • home: const Placeholder() shows a temporary placeholder widget. You'll replace this with your visualizer page in a later step.
  • Save main.dart by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing red squiggles in main.dart?

If VS Code shows errors about missing packages, it may not have fetched your project's dependencies yet. Open the terminal and run flutter pub get to download them.

Help me fix errors in my main.dart file.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.dart before moving on.

ⓧ I'd like to double check the full code

Here's what each file should contain at this point:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
        useMaterial3: true,
      ),
      home: const Placeholder(),
    );
  }
}
// TODO: implement data structures
// TODO: implement grid model
// TODO: implement algorithms
// TODO: implement grid painter
// TODO: implement visualizer page
Run the app in Chrome

With your project structure in place, it's time to confirm everything compiles and runs in the browser.

  • Launch the app in Chrome by running this command in the integrated terminal:
flutter run -d chrome

What does this command do?

The -d chrome flag tells Flutter to compile your Dart code to JavaScript and serve it in a Chrome window. The first build takes longer because Flutter downloads web-specific tooling. Subsequent runs are faster.

After the build finishes, Chrome opens with a gray crosshatch pattern. This is Flutter's Placeholder widget confirming your app is running.

Chrome didn't open?

Make sure Chrome is installed and set as an available device. Run flutter devices to see which targets Flutter detects. Chrome should appear in the list.

If you see a compilation error, check that you saved main.dart and that it matches the code above exactly.

Help me get my Flutter web app running in Chrome.

  • Stop the running app by pressing q in the terminal.

Your Flutter web project is ready to go. Next up, you'll build the Grid model that represents the graph your algorithms will traverse.

Build the Grid Model

Every pathfinding algorithm needs a graph to search through. In this project, the graph is a 2D grid where each cell is a vertex and the edges connect each cell to its four neighbors (up, down, left, right).

In this step, you'll build the Grid model that represents this graph. The grid stores every node's state (wall, visited, start, end) and provides a getNeighbors method that returns adjacent cells. This method is the core of graph traversal.

In this step, get ready to:
  • Create the Node class with row, col, type, distance, and parent properties.
  • Create the Grid class with a getNeighbors method for 4-directional adjacency.
  • Implement reset and clearAll methods for re-running algorithms.
Create the Node class

A graph is made up of vertices and edges. In a grid-based graph, each cell is a vertex. You need a class to represent each cell and track its current state during algorithm execution.

  • Open lib/grid_model.dart in your editor.
  • Delete the placeholder comment and add the following code for the CellType enum and Node class:
enum CellType { empty, wall, start, end, visited, path }

class Node {
  final int row;
  final int col;
  CellType type;
  double distance;
  Node? parent;

  Node({
    required this.row,
    required this.col,
    this.type = CellType.empty,
    this.distance = double.infinity,
    this.parent,
  });

  @override
  bool operator ==(Object other) =>
      other is Node && other.row == row && other.col == col;

  @override
  int get hashCode => row.hashCode ^ col.hashCode;
}

What does this code do?

CellType is an enum that tracks what each cell currently represents. During visualization, cells transition between these states (e.g., from empty to visited to path).

The Node class stores each cell's position (row, col), its current type, the distance from the start node (used by Dijkstra's), and a parent pointer for reconstructing the final path.

The custom == and hashCode overrides let you compare nodes by position. This is essential because the algorithms use Set to track visited nodes, and sets rely on equality checks.

What is a graph in this context?

The Grid class you're about to build IS the graph. Each Node is a vertex, and edges are implicit between neighboring cells. You don't need a separate adjacency list because the grid structure itself defines connectivity.

Create the Grid class with getNeighbors

The Grid class holds the entire 2D array of nodes and provides the getNeighbors method. This method is what makes the grid a graph. Every algorithm calls it to discover which cells are reachable from the current position.

  • In lib/grid_model.dart, add the following Grid class below the Node class:
class Grid {
  final int rows;
  final int cols;
  late List<List<Node>> nodes;
  Node? startNode;
  Node? endNode;

  Grid({required this.rows, required this.cols}) {
    nodes = List.generate(
      rows,
      (r) => List.generate(cols, (c) => Node(row: r, col: c)),
    );
  }

  List<Node> getNeighbors(Node node) {
    final neighbors = <Node>[];
    final directions = [
      [-1, 0],
      [1, 0],
      [0, -1],
      [0, 1],
    ];

    for (final dir in directions) {
      final newRow = node.row + dir[0];
      final newCol = node.col + dir[1];

      if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols) {
        final neighbor = nodes[newRow][newCol];
        if (neighbor.type != CellType.wall) {
          neighbors.add(neighbor);
        }
      }
    }

    return neighbors;
  }
}

What does getNeighbors do?

The directions array defines four moves: up, down, left, right. For each direction, the method checks if the new position is within bounds and not a wall. Only valid, non-wall neighbors are returned.

This is the adjacency function of your graph. When BFS or DFS asks "what nodes can I reach from here?", getNeighbors answers that question. Walls act as removed edges.

Implement reset and clearAll methods

Users will want to run multiple algorithms on the same maze without redrawing walls each time. You need two reset methods: one that clears only the algorithm results, and one that wipes everything.

  • In lib/grid_model.dart, add the following two methods inside the Grid class, below the getNeighbors method:
  void reset() {
    for (final row in nodes) {
      for (final node in row) {
        if (node.type == CellType.visited || node.type == CellType.path) {
          node.type = CellType.empty;
        }
        node.distance = double.infinity;
        node.parent = null;
      }
    }
  }

  void clearAll() {
    for (final row in nodes) {
      for (final node in row) {
        node.type = CellType.empty;
        node.distance = double.infinity;
        node.parent = null;
      }
    }
    startNode = null;
    endNode = null;
  }

What's the difference between reset and clearAll?

reset() clears only the algorithm's output (visited cells and path cells). Walls, start, and end stay in place. This lets the user run a different algorithm on the same maze layout.

clearAll() wipes everything back to a blank grid. Both methods also reset distance and parent on every node so the next algorithm starts fresh.

  • Save lib/grid_model.dart.
  • Confirm your app is still running in Chrome without errors. You won't see any visual change yet because the grid model isn't connected to the UI. The next steps will wire it up through a painter and visualizer page.

Seeing a compile error?

Check that the closing brace } for the Grid class appears after both reset() and clearAll(). Missing or misplaced braces are the most common cause of Dart compile errors.

Also verify that CellType is defined at the top of the file. The Node and Grid classes both reference it.

Help me debug a Dart compile error in my grid_model.dart file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete lib/grid_model.dart file. Make sure yours matches exactly:

enum CellType { empty, wall, start, end, visited, path }

class Node {
  final int row;
  final int col;
  CellType type;
  double distance;
  Node? parent;

  Node({
    required this.row,
    required this.col,
    this.type = CellType.empty,
    this.distance = double.infinity,
    this.parent,
  });

  @override
  bool operator ==(Object other) =>
      other is Node && other.row == row && other.col == col;

  @override
  int get hashCode => row.hashCode ^ col.hashCode;
}

class Grid {
  final int rows;
  final int cols;
  late List<List<Node>> nodes;
  Node? startNode;
  Node? endNode;

  Grid({required this.rows, required this.cols}) {
    nodes = List.generate(
      rows,
      (r) => List.generate(cols, (c) => Node(row: r, col: c)),
    );
  }

  List<Node> getNeighbors(Node node) {
    final neighbors = <Node>[];
    final directions = [
      [-1, 0],
      [1, 0],
      [0, -1],
      [0, 1],
    ];

    for (final dir in directions) {
      final newRow = node.row + dir[0];
      final newCol = node.col + dir[1];

      if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols) {
        final neighbor = nodes[newRow][newCol];
        if (neighbor.type != CellType.wall) {
          neighbors.add(neighbor);
        }
      }
    }

    return neighbors;
  }

  void reset() {
    for (final row in nodes) {
      for (final node in row) {
        if (node.type == CellType.visited || node.type == CellType.path) {
          node.type = CellType.empty;
        }
        node.distance = double.infinity;
        node.parent = null;
      }
    }
  }

  void clearAll() {
    for (final row in nodes) {
      for (final node in row) {
        node.type = CellType.empty;
        node.distance = double.infinity;
        node.parent = null;
      }
    }
    startNode = null;
    endNode = null;
  }
}

Your graph model is complete. The grid knows its shape, every node knows its state, and getNeighbors defines the edges between cells. Next up, you'll build the data structures that power each algorithm's exploration strategy.

Implement Data Structures from Scratch

Your grid model is ready. It knows how to represent nodes, track neighbors, and reset itself. Now you need the engine parts that will actually drive the pathfinding algorithms.

Every pathfinding algorithm explores nodes in a specific order. That order is determined by the data structure managing the "frontier" of nodes to visit next. In this step, you'll build three data structures from scratch: a Queue, a Stack, and a Priority Queue.

In this step, get ready to:
  • Build a SimpleQueue with FIFO (first-in, first-out) behavior.
  • Build a SimpleStack with LIFO (last-in, first-out) behavior.
  • Build a PriorityQueue as a binary Min-Heap with insert and extract operations.
Build the SimpleQueue class

A queue follows FIFO ordering. The first item added is the first item removed. BFS uses a queue to explore nodes layer-by-layer outward from the start, which is why it guarantees the shortest path in an unweighted graph.

  • In your code editor, open lib/data_structures.dart.
  • Delete the placeholder comment // TODO: implement data structures and replace it with the following code:
/// A simple FIFO Queue implemented with a List.
class SimpleQueue<T> {
  final List<T> _items = [];

  void enqueue(T item) => _items.add(item);

  T dequeue() => _items.removeAt(0);

  bool get isEmpty => _items.isEmpty;

  int get length => _items.length;
}

What does this code do?

SimpleQueue wraps a Dart List and exposes only queue operations.

  • enqueue adds an item to the back of the list.
  • dequeue removes and returns the item at the front (index 0). This ensures FIFO order.
  • The generic type <T> lets this queue hold any type. You'll use it with Node objects later.

You won't see a change in the browser yet. The next chunk adds the Stack to the same file.

Build the SimpleStack class

A stack follows LIFO ordering. The last item added is the first item removed. DFS uses a stack to dive deep into one path before backtracking, which creates that narrow, winding exploration pattern you'll see in the visualizer.

  • In lib/data_structures.dart, add the following code below the SimpleQueue class:
/// A simple LIFO Stack implemented with a List.
class SimpleStack<T> {
  final List<T> _items = [];

  void push(T item) => _items.add(item);

  T pop() => _items.removeLast();

  bool get isEmpty => _items.isEmpty;

  int get length => _items.length;
}

What does this code do?

SimpleStack also wraps a List, but operates from the end instead of the front.

  • push adds an item to the end of the list.
  • pop removes and returns the last item. This ensures LIFO order.

Notice how similar this is to the Queue. The only real difference is which end you remove from. That one difference completely changes how the algorithm explores the grid.

You won't see a change yet. The final data structure is the most complex one.

Build the PriorityQueue as a Min-Heap

Dijkstra's algorithm needs to always process the node with the smallest distance next. A regular queue or stack can't do this. A Priority Queue backed by a binary Min-Heap efficiently keeps the smallest item at the top.

  • In lib/data_structures.dart, add the following code below the SimpleStack class:
/// A Min-Heap Priority Queue.
class PriorityQueue<T> {
  final List<T> _heap = [];
  final int Function(T a, T b) compare;

  PriorityQueue(this.compare);

  void insert(T item) {
    _heap.add(item);
    _bubbleUp(_heap.length - 1);
  }

  T extractMin() {
    final min = _heap.first;
    final last = _heap.removeLast();
    if (_heap.isNotEmpty) {
      _heap[0] = last;
      _bubbleDown(0);
    }
    return min;
  }

  bool get isEmpty => _heap.isEmpty;

  int get length => _heap.length;

  void _bubbleUp(int index) {
    while (index > 0) {
      final parent = (index - 1) ~/ 2;
      if (compare(_heap[index], _heap[parent]) < 0) {
        _swap(index, parent);
        index = parent;
      } else {
        break;
      }
    }
  }

  void _bubbleDown(int index) {
    while (true) {
      int smallest = index;
      final left = 2 * index + 1;
      final right = 2 * index + 2;

      if (left < _heap.length && compare(_heap[left], _heap[smallest]) < 0) {
        smallest = left;
      }
      if (right < _heap.length && compare(_heap[right], _heap[smallest]) < 0) {
        smallest = right;
      }

      if (smallest != index) {
        _swap(index, smallest);
        index = smallest;
      } else {
        break;
      }
    }
  }

  void _swap(int i, int j) {
    final temp = _heap[i];
    _heap[i] = _heap[j];
    _heap[j] = temp;
  }
}

How does the Min-Heap work?

A binary heap stores items in a flat list but maintains a tree-like ordering. The smallest item always sits at index 0.

  • insert adds the new item at the end, then _bubbleUp swaps it upward until it's no longer smaller than its parent.
  • extractMin removes the smallest item (index 0), moves the last item to the top, then _bubbleDown pushes it downward until heap order is restored.
  • The compare function is passed in at construction time. This lets you define what "smallest" means for any type.

The parent-child relationship uses index math: parent = (index - 1) ~/ 2, left child = 2 * index + 1, right child = 2 * index + 2.

Why build these from scratch?

Dart's standard library includes collection utilities, but implementing these yourself proves you understand the underlying mechanics. Interviewers test whether you know WHY a queue gives BFS its shortest-path guarantee, not just how to import one.

  • Save lib/data_structures.dart.
  • Check your terminal where flutter run is active. You should see no error messages. The app still shows the default placeholder since nothing imports this file yet.

Seeing a compile error?

  • Check that all three classes are in the same file (lib/data_structures.dart) and that none are accidentally nested inside another class.
  • Verify the ~/ integer division operator in _bubbleUp is a tilde followed by a forward slash, not a regular division.
  • Make sure you deleted the placeholder comment before pasting the new code.

Help me fix a compile error in my data_structures.dart file.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete lib/data_structures.dart file for comparison:

/// A simple FIFO Queue implemented with a List.
class SimpleQueue<T> {
  final List<T> _items = [];

  void enqueue(T item) => _items.add(item);

  T dequeue() => _items.removeAt(0);

  bool get isEmpty => _items.isEmpty;

  int get length => _items.length;
}

/// A simple LIFO Stack implemented with a List.
class SimpleStack<T> {
  final List<T> _items = [];

  void push(T item) => _items.add(item);

  T pop() => _items.removeLast();

  bool get isEmpty => _items.isEmpty;

  int get length => _items.length;
}

/// A Min-Heap Priority Queue.
class PriorityQueue<T> {
  final List<T> _heap = [];
  final int Function(T a, T b) compare;

  PriorityQueue(this.compare);

  void insert(T item) {
    _heap.add(item);
    _bubbleUp(_heap.length - 1);
  }

  T extractMin() {
    final min = _heap.first;
    final last = _heap.removeLast();
    if (_heap.isNotEmpty) {
      _heap[0] = last;
      _bubbleDown(0);
    }
    return min;
  }

  bool get isEmpty => _heap.isEmpty;

  int get length => _heap.length;

  void _bubbleUp(int index) {
    while (index > 0) {
      final parent = (index - 1) ~/ 2;
      if (compare(_heap[index], _heap[parent]) < 0) {
        _swap(index, parent);
        index = parent;
      } else {
        break;
      }
    }
  }

  void _bubbleDown(int index) {
    while (true) {
      int smallest = index;
      final left = 2 * index + 1;
      final right = 2 * index + 2;

      if (left < _heap.length && compare(_heap[left], _heap[smallest]) < 0) {
        smallest = left;
      }
      if (right < _heap.length && compare(_heap[right], _heap[smallest]) < 0) {
        smallest = right;
      }

      if (smallest != index) {
        _swap(index, smallest);
        index = smallest;
      } else {
        break;
      }
    }
  }

  void _swap(int i, int j) {
    final temp = _heap[i];
    _heap[i] = _heap[j];
    _heap[j] = temp;
  }
}

Your three data structures are ready. Next up, you'll use them to implement BFS, DFS, and Dijkstra's algorithm. You'll see exactly how swapping one data structure for another changes the entire exploration behavior.

Implement the Pathfinding Algorithms

Your data structures are built and ready. Now it's time to put them to work.

Each pathfinding algorithm follows the same general pattern: start at a node, explore neighbors, track where you've been, and stop when you reach the goal. The critical difference is which neighbor gets explored next. That decision is controlled entirely by the data structure managing the frontier.

In this step, get ready to:
  • Implement BFS using your SimpleQueue for guaranteed shortest paths.
  • Implement DFS using your SimpleStack for deep exploration.
  • Implement Dijkstra's using your PriorityQueue for heap-ordered traversal.
Set up the shared algorithm infrastructure

All three algorithms need to return the same two pieces of information: the list of nodes they visited (for animation) and the shortest path they found (for highlighting). They also share a helper function that traces back through parent pointers to reconstruct the final path.

  • Open lib/algorithms.dart in your editor.
  • Replace the placeholder comment with the following imports, result class, and path helper:
import 'data_structures.dart';
import 'grid_model.dart';

/// Holds the algorithm's output: visited nodes for animation, shortest path for highlighting.
class PathfindingResult {
  final List<Node> visitedInOrder;
  final List<Node> shortestPath;

  PathfindingResult({required this.visitedInOrder, required this.shortestPath});
}

/// Traces parent pointers from the end node back to the start, then reverses.
List<Node> _reconstructPath(Node? endNode) {
  final path = <Node>[];
  Node? current = endNode;
  while (current != null) {
    path.add(current);
    current = current.parent;
  }
  return path.reversed.toList();
}

What does this code do?

PathfindingResult bundles two lists. visitedInOrder records every node the algorithm checked, in order. The visualizer will animate through this list to show how the algorithm explores.

_reconstructPath follows each node's parent pointer backwards from the end to the start, then reverses the list so it reads start-to-end. Every algorithm sets these parent pointers as it explores.

  • Save lib/algorithms.dart.
  • Check your terminal running Flutter. You should see a successful hot reload with no errors.

Seeing import errors?

Make sure the import paths match your filenames exactly: data_structures.dart and grid_model.dart. Dart imports are case-sensitive and must match the filename on disk.

Help me fix import errors in my algorithms.dart file

Implement BFS using SimpleQueue

BFS explores nodes layer by layer, like ripples spreading outward from a stone dropped in water. Because it uses a Queue (first in, first out), it always finishes exploring nodes at distance 1 before moving to distance 2. This guarantees the shortest path in an unweighted graph.

  • Add the bfs function below _reconstructPath in lib/algorithms.dart:
PathfindingResult bfs(Grid grid) {
  // Return empty result if start or end isn't placed
  if (grid.startNode == null || grid.endNode == null) {
    return PathfindingResult(visitedInOrder: [], shortestPath: []);
  }

  // FIFO queue ensures layer-by-layer exploration
  final queue = SimpleQueue<Node>();
  final visited = <Node>{};
  final visitedInOrder = <Node>[];

  // Initialize: distance 0 at start, mark as visited before enqueuing
  grid.startNode!.distance = 0;
  queue.enqueue(grid.startNode!);
  visited.add(grid.startNode!);

  while (!queue.isEmpty) {
    final current = queue.dequeue();
    visitedInOrder.add(current);

    // Found the goal - reconstruct and return
    if (current == grid.endNode) {
      return PathfindingResult(
        visitedInOrder: visitedInOrder,
        shortestPath: _reconstructPath(current),
      );
    }

    // Explore all unvisited neighbors
    for (final neighbor in grid.getNeighbors(current)) {
      if (!visited.contains(neighbor)) {
        visited.add(neighbor);
        neighbor.parent = current;
        neighbor.distance = current.distance + 1;
        queue.enqueue(neighbor);
      }
    }
  }

  // Exhausted all reachable nodes without finding end
  return PathfindingResult(visitedInOrder: visitedInOrder, shortestPath: []);
}

What does this code do?

The key line is queue.enqueue(neighbor). Because SimpleQueue is FIFO, neighbors discovered at distance 1 are processed before neighbors discovered at distance 2. This is what guarantees BFS finds the shortest path.

Notice that BFS marks nodes as visited before adding them to the queue. This prevents the same node from being enqueued multiple times by different neighbors.

  • Save lib/algorithms.dart.
  • Check your terminal. You should see a successful hot reload with no compile errors.

Seeing type errors on Node?

Confirm that your grid_model.dart has the parent and distance fields on your Node class. The algorithms depend on these properties from the previous step.

Help me fix Node type errors in my BFS function

Implement DFS using SimpleStack

DFS explores as deep as possible down one path before backtracking. Because it uses a Stack (last in, first out), the most recently discovered neighbor gets explored next. This creates a narrow, winding exploration pattern.

Does DFS find the shortest path?

No. DFS finds a path, but not necessarily the shortest one. It commits to a direction and only backtracks when it hits a dead end. This is intentional and educational. When you run the visualizer, you'll clearly see how DFS wanders compared to BFS's uniform wave.

  • Add the dfs function below the bfs function in lib/algorithms.dart:
PathfindingResult dfs(Grid grid) {
  // Return empty result if start or end isn't placed
  if (grid.startNode == null || grid.endNode == null) {
    return PathfindingResult(visitedInOrder: [], shortestPath: []);
  }

  // LIFO stack ensures deep-first exploration
  final stack = SimpleStack<Node>();
  final visited = <Node>{};
  final visitedInOrder = <Node>[];

  grid.startNode!.distance = 0;
  stack.push(grid.startNode!);

  while (!stack.isEmpty) {
    final current = stack.pop();

    // Skip if already visited (a node can be pushed multiple times)
    if (visited.contains(current)) continue;
    visited.add(current);
    visitedInOrder.add(current);

    // Found the goal - reconstruct and return
    if (current == grid.endNode) {
      return PathfindingResult(
        visitedInOrder: visitedInOrder,
        shortestPath: _reconstructPath(current),
      );
    }

    // Push all unvisited neighbors onto the stack
    for (final neighbor in grid.getNeighbors(current)) {
      if (!visited.contains(neighbor)) {
        neighbor.parent = current;
        stack.push(neighbor);
      }
    }
  }

  // Exhausted all reachable nodes without finding end
  return PathfindingResult(visitedInOrder: visitedInOrder, shortestPath: []);
}

How does DFS differ from BFS in code?

The structure is almost identical. The only difference is stack.push instead of queue.enqueue and stack.pop instead of queue.dequeue. This tiny swap completely changes the exploration behavior.

DFS also checks visited after popping rather than before pushing. A node might be pushed multiple times by different neighbors, so the check happens at processing time.

  • Save lib/algorithms.dart.
  • Check your terminal for a clean hot reload with no errors.

Hot reload shows an error?

Make sure you placed the dfs function after the closing brace of bfs, not inside it. Each function should be at the top level of the file.

Help me fix a compile error in my DFS implementation

Implement Dijkstra's using PriorityQueue

Dijkstra's Algorithm always explores the node with the lowest accumulated distance next. It uses a Priority Queue (Min-Heap) to efficiently extract the minimum-distance node. This guarantees the shortest path even in weighted graphs.

  • Add the dijkstra function below the dfs function in lib/algorithms.dart:
PathfindingResult dijkstra(Grid grid) {
  // Return empty result if start or end isn't placed
  if (grid.startNode == null || grid.endNode == null) {
    return PathfindingResult(visitedInOrder: [], shortestPath: []);
  }

  // Min-Heap ordered by distance - always processes the closest node next
  final pq = PriorityQueue<Node>((a, b) => a.distance.compareTo(b.distance));
  final visited = <Node>{};
  final visitedInOrder = <Node>[];

  grid.startNode!.distance = 0;
  pq.insert(grid.startNode!);

  while (!pq.isEmpty) {
    final current = pq.extractMin();

    // Skip if already finalized
    if (visited.contains(current)) continue;
    visited.add(current);
    visitedInOrder.add(current);

    // Found the goal - reconstruct and return
    if (current == grid.endNode) {
      return PathfindingResult(
        visitedInOrder: visitedInOrder,
        shortestPath: _reconstructPath(current),
      );
    }

    // Relax edges: update neighbor distances if a shorter path is found
    for (final neighbor in grid.getNeighbors(current)) {
      if (!visited.contains(neighbor)) {
        final newDist = current.distance + 1;
        if (newDist < neighbor.distance) {
          neighbor.distance = newDist;
          neighbor.parent = current;
          pq.insert(neighbor);
        }
      }
    }
  }

  // Exhausted all reachable nodes without finding end
  return PathfindingResult(visitedInOrder: visitedInOrder, shortestPath: []);
}

What makes Dijkstra's different from BFS?

The critical difference is the comparator passed to PriorityQueue: (a, b) => a.distance.compareTo(b.distance). This ensures extractMin always returns the node with the smallest total distance from the start.

Dijkstra's also performs edge relaxation: it only updates a neighbor if the new path is shorter than any previously found path. In our unweighted grid, BFS and Dijkstra's produce identical results. The difference becomes visible when edges have varying weights.

  • Save lib/algorithms.dart.
  • Check your terminal for a successful hot reload with no compile errors.

Seeing errors about the compare function?

Make sure the comparator uses a.distance.compareTo(b.distance). The distance property on Node is a double, and compareTo returns an int which matches the PriorityQueue's expected signature.

Help me fix the PriorityQueue comparator in my Dijkstra function

Why all three algorithms look so similar

BFS, DFS, and Dijkstra's share the same loop structure: pull a node from the frontier, check if it's the goal, explore neighbors, repeat. The only difference is the data structure managing the frontier. Queue gives you BFS. Stack gives you DFS. Min-Heap gives you Dijkstra's.

This is the core insight the visualizer will make obvious: swapping one data structure completely changes how the algorithm explores space.

✔️ Awesome, I've got everything!

Great. Double check you've saved lib/algorithms.dart and your terminal shows no errors.

ⓧ I'd like to double check the full code

Here is the complete lib/algorithms.dart file. Compare it against yours:

import 'data_structures.dart';
import 'grid_model.dart';

class PathfindingResult {
  final List<Node> visitedInOrder;
  final List<Node> shortestPath;

  PathfindingResult({required this.visitedInOrder, required this.shortestPath});
}

List<Node> _reconstructPath(Node? endNode) {
  final path = <Node>[];
  Node? current = endNode;
  while (current != null) {
    path.add(current);
    current = current.parent;
  }
  return path.reversed.toList();
}

PathfindingResult bfs(Grid grid) {
  if (grid.startNode == null || grid.endNode == null) {
    return PathfindingResult(visitedInOrder: [], shortestPath: []);
  }

  final queue = SimpleQueue<Node>();
  final visited = <Node>{};
  final visitedInOrder = <Node>[];

  grid.startNode!.distance = 0;
  queue.enqueue(grid.startNode!);
  visited.add(grid.startNode!);

  while (!queue.isEmpty) {
    final current = queue.dequeue();
    visitedInOrder.add(current);

    if (current == grid.endNode) {
      return PathfindingResult(
        visitedInOrder: visitedInOrder,
        shortestPath: _reconstructPath(current),
      );
    }

    for (final neighbor in grid.getNeighbors(current)) {
      if (!visited.contains(neighbor)) {
        visited.add(neighbor);
        neighbor.parent = current;
        neighbor.distance = current.distance + 1;
        queue.enqueue(neighbor);
      }
    }
  }

  return PathfindingResult(visitedInOrder: visitedInOrder, shortestPath: []);
}

PathfindingResult dfs(Grid grid) {
  if (grid.startNode == null || grid.endNode == null) {
    return PathfindingResult(visitedInOrder: [], shortestPath: []);
  }

  final stack = SimpleStack<Node>();
  final visited = <Node>{};
  final visitedInOrder = <Node>[];

  grid.startNode!.distance = 0;
  stack.push(grid.startNode!);

  while (!stack.isEmpty) {
    final current = stack.pop();

    if (visited.contains(current)) continue;
    visited.add(current);
    visitedInOrder.add(current);

    if (current == grid.endNode) {
      return PathfindingResult(
        visitedInOrder: visitedInOrder,
        shortestPath: _reconstructPath(current),
      );
    }

    for (final neighbor in grid.getNeighbors(current)) {
      if (!visited.contains(neighbor)) {
        neighbor.parent = current;
        stack.push(neighbor);
      }
    }
  }

  return PathfindingResult(visitedInOrder: visitedInOrder, shortestPath: []);
}

PathfindingResult dijkstra(Grid grid) {
  if (grid.startNode == null || grid.endNode == null) {
    return PathfindingResult(visitedInOrder: [], shortestPath: []);
  }

  final pq = PriorityQueue<Node>((a, b) => a.distance.compareTo(b.distance));
  final visited = <Node>{};
  final visitedInOrder = <Node>[];

  grid.startNode!.distance = 0;
  pq.insert(grid.startNode!);

  while (!pq.isEmpty) {
    final current = pq.extractMin();

    if (visited.contains(current)) continue;
    visited.add(current);
    visitedInOrder.add(current);

    if (current == grid.endNode) {
      return PathfindingResult(
        visitedInOrder: visitedInOrder,
        shortestPath: _reconstructPath(current),
      );
    }

    for (final neighbor in grid.getNeighbors(current)) {
      if (!visited.contains(neighbor)) {
        final newDist = current.distance + 1;
        if (newDist < neighbor.distance) {
          neighbor.distance = newDist;
          neighbor.parent = current;
          pq.insert(neighbor);
        }
      }
    }
  }

  return PathfindingResult(visitedInOrder: visitedInOrder, shortestPath: []);
}

Your algorithms are complete. They can find paths, but you can't see them yet. Next up, you'll build the visual layer that paints the grid and animates each algorithm's exploration in real time.

Build the Visualizer UI with CustomPainter

You have three algorithms ready to find paths, but no way to actually see them work.

In this step, you will build the visual layer that brings your data structures to life. Flutter's CustomPainter gives you direct canvas access to draw each grid cell with color-coding, and a GestureDetector will let users draw walls and place start/end points by clicking and dragging.

In this step, get ready to:
  • Create a GridPainter that renders each cell with a distinct color based on its type.
  • Build the VisualizerPage widget with interactive wall drawing and node placement.
  • Wire up Timer-based animation that steps through visited nodes one at a time.
Create GridPainter that draws each cell with color-coding

The grid needs to look different depending on what each cell represents. Walls should be dark, visited cells light blue, the final path yellow, and start/end nodes green and red.

  • Open lib/grid_painter.dart in your editor and replace the placeholder comment with this code:
import 'package:flutter/material.dart';
import 'grid_model.dart';

class GridPainter extends CustomPainter {
  final Grid grid;
  final double cellSize;

  GridPainter({required this.grid, required this.cellSize});

  @override
  void paint(Canvas canvas, Size size) {
    for (int r = 0; r < grid.rows; r++) {
      for (int c = 0; c < grid.cols; c++) {
        final node = grid.nodes[r][c];
        final rect = Rect.fromLTWH(
          c * cellSize,
          r * cellSize,
          cellSize,
          cellSize,
        );

        final paint = Paint()..style = PaintingStyle.fill;

        switch (node.type) {
          case CellType.empty:
            paint.color = Colors.white;
          case CellType.wall:
            paint.color = Colors.grey.shade900;
          case CellType.start:
            paint.color = Colors.green;
          case CellType.end:
            paint.color = Colors.red;
          case CellType.visited:
            paint.color = Colors.lightBlue.shade200;
          case CellType.path:
            paint.color = Colors.yellow.shade600;
        }

        canvas.drawRect(rect, paint);

        final borderPaint = Paint()
          ..style = PaintingStyle.stroke
          ..color = Colors.grey.shade300
          ..strokeWidth = 0.5;
        canvas.drawRect(rect, borderPaint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant GridPainter oldDelegate) => true;
}

What does this code do?

GridPainter extends CustomPainter, which gives you a paint() method with direct canvas access.

  • The nested loop iterates every cell in the grid and draws a colored rectangle using canvas.drawRect.
  • The switch on node.type maps each CellType to a distinct color so learners can see algorithms at work.
  • A thin border paint draws grid lines between cells for visual clarity.
  • Setting shouldRepaint to true means the canvas redraws every frame, which is required for animation.
  • Save lib/grid_painter.dart.

Seeing import errors?

Make sure the import path grid_model.dart matches the filename you created in Step 2 exactly.

help me fix import errors in my grid_painter.dart file

Build the VisualizerPage with GestureDetector for wall drawing and node placement

The VisualizerPage is the main widget that holds the grid, handles user input, and orchestrates everything. You will build it up in stages across this substep and the next two.

  • Open lib/visualizer_page.dart and replace the placeholder comment with the following code:
import 'dart:async';
import 'package:flutter/material.dart';
import 'grid_model.dart';
import 'grid_painter.dart';
import 'algorithms.dart';

enum PlacementMode { start, end, wall }

enum Algorithm { bfs, dfs, dijkstra }

class VisualizerPage extends StatefulWidget {
  const VisualizerPage({super.key});

  @override
  State<VisualizerPage> createState() => _VisualizerPageState();
}

class _VisualizerPageState extends State<VisualizerPage> {
  static const int rows = 20;
  static const int cols = 35;
  static const double cellSize = 28.0;

  late Grid grid;
  PlacementMode placementMode = PlacementMode.wall;
  Algorithm selectedAlgorithm = Algorithm.bfs;
  bool isRunning = false;
  Timer? animationTimer;
  double speed = 30;

  @override
  void initState() {
    super.initState();
    grid = Grid(rows: rows, cols: cols);
  }

  @override
  void dispose() {
    animationTimer?.cancel();
    super.dispose();
  }

  void _handleCellTap(int row, int col) {
    if (isRunning) return;

    setState(() {
      final node = grid.nodes[row][col];

      switch (placementMode) {
        case PlacementMode.start:
          if (grid.startNode != null) {
            grid.startNode!.type = CellType.empty;
          }
          node.type = CellType.start;
          grid.startNode = node;
        case PlacementMode.end:
          if (grid.endNode != null) {
            grid.endNode!.type = CellType.empty;
          }
          node.type = CellType.end;
          grid.endNode = node;
        case PlacementMode.wall:
          if (node.type == CellType.empty) {
            node.type = CellType.wall;
          } else if (node.type == CellType.wall) {
            node.type = CellType.empty;
          }
      }
    });
  }

  void _handleDrag(Offset localPosition) {
    if (isRunning) return;
    if (placementMode != PlacementMode.wall) return;

    final col = (localPosition.dx / cellSize).floor();
    final row = (localPosition.dy / cellSize).floor();

    if (row >= 0 && row < rows && col >= 0 && col < cols) {
      final node = grid.nodes[row][col];
      if (node.type == CellType.empty) {
        setState(() {
          node.type = CellType.wall;
        });
      }
    }
  }

What does this code do?

  • Two enums define the modes: PlacementMode controls whether taps place a start node, end node, or wall. Algorithm selects which pathfinding algorithm to run.
  • The grid is 20 rows by 35 columns, with each cell being 28 pixels. These constants control the canvas size.
  • _handleCellTap uses the current placementMode to decide whether to place a start/end node or toggle a wall.
  • _handleDrag converts pointer coordinates into grid row/col and paints walls as the user drags across cells.

The file is not complete yet. You will add the animation methods and build method in the next substeps.

Add controls: algorithm selector, placement mode, speed slider, and action buttons

The user interface needs controls to select an algorithm, switch between placement modes, adjust animation speed, and trigger the visualization.

  • In lib/visualizer_page.dart, add the following methods inside the _VisualizerPageState class, below the _handleDrag method. Leave room above these for animation methods you will add next:
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey.shade100,
      appBar: AppBar(
        title: const Text('Pathfinding Visualizer'),
        backgroundColor: Colors.blueGrey.shade800,
        foregroundColor: Colors.white,
      ),
      body: Column(
        children: [
          _buildControls(),
          Expanded(
            child: Center(
              child: GestureDetector(
                onTapDown: (details) {
                  final col = (details.localPosition.dx / cellSize).floor();
                  final row = (details.localPosition.dy / cellSize).floor();
                  if (row >= 0 && row < rows && col >= 0 && col < cols) {
                    _handleCellTap(row, col);
                  }
                },
                onPanUpdate: (details) {
                  _handleDrag(details.localPosition);
                },
                child: CustomPaint(
                  size: Size(cols * cellSize, rows * cellSize),
                  painter: GridPainter(grid: grid, cellSize: cellSize),
                ),
              ),
            ),
          ),
          _buildLegend(),
        ],
      ),
    );
  }

  Widget _buildControls() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      color: Colors.white,
      child: Wrap(
        spacing: 16,
        runSpacing: 8,
        alignment: WrapAlignment.center,
        crossAxisAlignment: WrapCrossAlignment.center,
        children: [
          SegmentedButton<PlacementMode>(
            segments: const [
              ButtonSegment(value: PlacementMode.start, label: Text('Start')),
              ButtonSegment(value: PlacementMode.end, label: Text('End')),
              ButtonSegment(value: PlacementMode.wall, label: Text('Wall')),
            ],
            selected: {placementMode},
            onSelectionChanged: (set) {
              setState(() => placementMode = set.first);
            },
          ),
          DropdownButton<Algorithm>(
            value: selectedAlgorithm,
            items: const [
              DropdownMenuItem(value: Algorithm.bfs, child: Text('BFS (Queue)')),
              DropdownMenuItem(value: Algorithm.dfs, child: Text('DFS (Stack)')),
              DropdownMenuItem(
                value: Algorithm.dijkstra,
                child: Text("Dijkstra's (Heap)"),
              ),
            ],
            onChanged: isRunning
                ? null
                : (val) {
                    if (val != null) setState(() => selectedAlgorithm = val);
                  },
          ),
          SizedBox(
            width: 150,
            child: Row(
              children: [
                const Text('Speed:'),
                Expanded(
                  child: Slider(
                    value: speed,
                    min: 5,
                    max: 100,
                    onChanged: isRunning
                        ? null
                        : (val) {
                            setState(() => speed = val);
                          },
                  ),
                ),
              ],
            ),
          ),
          FilledButton.icon(
            onPressed: isRunning ? null : _runAlgorithm,
            icon: const Icon(Icons.play_arrow),
            label: const Text('Visualize'),
          ),
          OutlinedButton.icon(
            onPressed: _resetGrid,
            icon: const Icon(Icons.refresh),
            label: const Text('Reset Path'),
          ),
          OutlinedButton.icon(
            onPressed: _clearAll,
            icon: const Icon(Icons.delete),
            label: const Text('Clear All'),
          ),
        ],
      ),
    );
  }

  Widget _buildLegend() {
    return Container(
      padding: const EdgeInsets.all(12),
      color: Colors.white,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _legendItem(Colors.green, 'Start'),
          _legendItem(Colors.red, 'End'),
          _legendItem(Colors.grey.shade900, 'Wall'),
          _legendItem(Colors.lightBlue.shade200, 'Visited'),
          _legendItem(Colors.yellow.shade600, 'Path'),
        ],
      ),
    );
  }

  Widget _legendItem(Color color, String label) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8),
      child: Row(
        children: [
          Container(width: 16, height: 16, color: color),
          const SizedBox(width: 4),
          Text(label, style: const TextStyle(fontSize: 12)),
        ],
      ),
    );
  }
}

What do the controls do?

  • SegmentedButton lets users toggle between placing a start node, end node, or wall.
  • DropdownButton selects which algorithm to run. Each option shows the data structure it uses.
  • The Slider controls animation speed (the Timer interval in milliseconds).
  • GestureDetector wraps the CustomPaint widget and converts pointer events into grid coordinates for cell taps and wall-drawing drags.

Why use CustomPaint instead of a grid of widgets?

A 20x35 grid means 700 cells. Rebuilding 700 individual widgets every animation frame would be slow. CustomPainter draws directly to a canvas in a single pass, which is far more efficient for frequent repaints.

The file references three methods that do not exist yet: _runAlgorithm, _resetGrid, and _clearAll. You will add them next.

Wire up Timer-based animation that steps through visited nodes one at a time

The animation is what makes the visualizer educational. Instead of showing the final result instantly, a Timer steps through each visited node one at a time so you can see how different data structures drive exploration.

  • In lib/visualizer_page.dart, add the following five methods between _handleDrag and the build method:
  void _runAlgorithm() {
    if (isRunning) return;
    if (grid.startNode == null || grid.endNode == null) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Place a start and end node first!')),
      );
      return;
    }

    grid.reset();

    PathfindingResult result;
    switch (selectedAlgorithm) {
      case Algorithm.bfs:
        result = bfs(grid);
      case Algorithm.dfs:
        result = dfs(grid);
      case Algorithm.dijkstra:
        result = dijkstra(grid);
    }

    _animateResult(result);
  }

  void _animateResult(PathfindingResult result) {
    setState(() => isRunning = true);
    int step = 0;

    animationTimer = Timer.periodic(
      Duration(milliseconds: speed.toInt()),
      (timer) {
        if (step < result.visitedInOrder.length) {
          setState(() {
            final node = result.visitedInOrder[step];
            if (node != grid.startNode && node != grid.endNode) {
              node.type = CellType.visited;
            }
          });
          step++;
        } else {
          timer.cancel();
          _animatePath(result.shortestPath);
        }
      },
    );
  }

  void _animatePath(List<Node> path) {
    if (path.isEmpty) {
      setState(() => isRunning = false);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('No path found!')),
      );
      return;
    }

    int step = 0;
    animationTimer = Timer.periodic(
      const Duration(milliseconds: 40),
      (timer) {
        if (step < path.length) {
          setState(() {
            final node = path[step];
            if (node != grid.startNode && node != grid.endNode) {
              node.type = CellType.path;
            }
          });
          step++;
        } else {
          timer.cancel();
          setState(() => isRunning = false);
        }
      },
    );
  }

  void _resetGrid() {
    animationTimer?.cancel();
    setState(() {
      grid.reset();
      isRunning = false;
    });
  }

  void _clearAll() {
    animationTimer?.cancel();
    setState(() {
      grid.clearAll();
      isRunning = false;
    });
  }

How does the animation work?

  • _runAlgorithm calls grid.reset() to clear previous results, then runs the selected algorithm to get a PathfindingResult with both the visited order and the shortest path.
  • _animateResult uses Timer.periodic to mark one visited node per tick. Each tick calls setState, which triggers a canvas repaint.
  • Once all visited nodes are shown, the timer cancels and _animatePath highlights the shortest path in yellow at a fixed 40ms interval.
  • _resetGrid clears visited/path cells but keeps walls. _clearAll wipes the entire board.

Now update lib/main.dart to launch the VisualizerPage instead of the default placeholder.

  • Replace the entire contents of lib/main.dart with this code:
import 'package:flutter/material.dart';
import 'visualizer_page.dart';

void main() {
  runApp(const PathfinderApp());
}

class PathfinderApp extends StatelessWidget {
  const PathfinderApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pathfinding Visualizer',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.blueGrey,
        useMaterial3: true,
      ),
      home: const VisualizerPage(),
    );
  }
}

What changed in main.dart?

  • The app class is now PathfinderApp with a blueGrey color scheme and Material 3 enabled.
  • The home widget points to VisualizerPage instead of the default Placeholder.
  • The debug banner is hidden with debugShowCheckedModeBanner: false for a cleaner look.
  • Save all three files: lib/grid_painter.dart, lib/visualizer_page.dart, and lib/main.dart.
  • Run the app in Chrome by running:
flutter run -d chrome
  • You should see a white grid with a toolbar at the top containing a segmented button, a dropdown, a speed slider, and three action buttons.
  • Click cells to draw walls (dark squares appear).
  • Switch to Start mode and click a cell to place a green start node.
  • Switch to End mode and click another cell to place a red end node.
  • Click Visualize and watch the blue visited cells spread across the grid, followed by a yellow path.

Grid not showing or app crashes?

  • Check that the closing brace } of _VisualizerPageState comes AFTER all the methods (including _legendItem). A misplaced brace causes methods to be outside the class.
  • Make sure the animation methods (_runAlgorithm through _clearAll) are placed between _handleDrag and build.
  • If you see a blank screen, confirm that main.dart imports visualizer_page.dart and uses const VisualizerPage() as the home widget.

help me debug why my Flutter pathfinding visualizer grid is not showing

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and the app runs in Chrome.

ⓧ I'd like to double check the full code

Here is the complete lib/visualizer_page.dart:

import 'dart:async';
import 'package:flutter/material.dart';
import 'grid_model.dart';
import 'grid_painter.dart';
import 'algorithms.dart';

enum PlacementMode { start, end, wall }

enum Algorithm { bfs, dfs, dijkstra }

class VisualizerPage extends StatefulWidget {
  const VisualizerPage({super.key});

  @override
  State<VisualizerPage> createState() => _VisualizerPageState();
}

class _VisualizerPageState extends State<VisualizerPage> {
  static const int rows = 20;
  static const int cols = 35;
  static const double cellSize = 28.0;

  late Grid grid;
  PlacementMode placementMode = PlacementMode.wall;
  Algorithm selectedAlgorithm = Algorithm.bfs;
  bool isRunning = false;
  Timer? animationTimer;
  double speed = 30;

  @override
  void initState() {
    super.initState();
    grid = Grid(rows: rows, cols: cols);
  }

  @override
  void dispose() {
    animationTimer?.cancel();
    super.dispose();
  }

  void _handleCellTap(int row, int col) {
    if (isRunning) return;

    setState(() {
      final node = grid.nodes[row][col];

      switch (placementMode) {
        case PlacementMode.start:
          if (grid.startNode != null) {
            grid.startNode!.type = CellType.empty;
          }
          node.type = CellType.start;
          grid.startNode = node;
        case PlacementMode.end:
          if (grid.endNode != null) {
            grid.endNode!.type = CellType.empty;
          }
          node.type = CellType.end;
          grid.endNode = node;
        case PlacementMode.wall:
          if (node.type == CellType.empty) {
            node.type = CellType.wall;
          } else if (node.type == CellType.wall) {
            node.type = CellType.empty;
          }
      }
    });
  }

  void _handleDrag(Offset localPosition) {
    if (isRunning) return;
    if (placementMode != PlacementMode.wall) return;

    final col = (localPosition.dx / cellSize).floor();
    final row = (localPosition.dy / cellSize).floor();

    if (row >= 0 && row < rows && col >= 0 && col < cols) {
      final node = grid.nodes[row][col];
      if (node.type == CellType.empty) {
        setState(() {
          node.type = CellType.wall;
        });
      }
    }
  }

  void _runAlgorithm() {
    if (isRunning) return;
    if (grid.startNode == null || grid.endNode == null) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Place a start and end node first!')),
      );
      return;
    }

    grid.reset();

    PathfindingResult result;
    switch (selectedAlgorithm) {
      case Algorithm.bfs:
        result = bfs(grid);
      case Algorithm.dfs:
        result = dfs(grid);
      case Algorithm.dijkstra:
        result = dijkstra(grid);
    }

    _animateResult(result);
  }

  void _animateResult(PathfindingResult result) {
    setState(() => isRunning = true);
    int step = 0;

    animationTimer = Timer.periodic(
      Duration(milliseconds: speed.toInt()),
      (timer) {
        if (step < result.visitedInOrder.length) {
          setState(() {
            final node = result.visitedInOrder[step];
            if (node != grid.startNode && node != grid.endNode) {
              node.type = CellType.visited;
            }
          });
          step++;
        } else {
          timer.cancel();
          _animatePath(result.shortestPath);
        }
      },
    );
  }

  void _animatePath(List<Node> path) {
    if (path.isEmpty) {
      setState(() => isRunning = false);
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('No path found!')),
      );
      return;
    }

    int step = 0;
    animationTimer = Timer.periodic(
      const Duration(milliseconds: 40),
      (timer) {
        if (step < path.length) {
          setState(() {
            final node = path[step];
            if (node != grid.startNode && node != grid.endNode) {
              node.type = CellType.path;
            }
          });
          step++;
        } else {
          timer.cancel();
          setState(() => isRunning = false);
        }
      },
    );
  }

  void _resetGrid() {
    animationTimer?.cancel();
    setState(() {
      grid.reset();
      isRunning = false;
    });
  }

  void _clearAll() {
    animationTimer?.cancel();
    setState(() {
      grid.clearAll();
      isRunning = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey.shade100,
      appBar: AppBar(
        title: const Text('Pathfinding Visualizer'),
        backgroundColor: Colors.blueGrey.shade800,
        foregroundColor: Colors.white,
      ),
      body: Column(
        children: [
          _buildControls(),
          Expanded(
            child: Center(
              child: GestureDetector(
                onTapDown: (details) {
                  final col = (details.localPosition.dx / cellSize).floor();
                  final row = (details.localPosition.dy / cellSize).floor();
                  if (row >= 0 && row < rows && col >= 0 && col < cols) {
                    _handleCellTap(row, col);
                  }
                },
                onPanUpdate: (details) {
                  _handleDrag(details.localPosition);
                },
                child: CustomPaint(
                  size: Size(cols * cellSize, rows * cellSize),
                  painter: GridPainter(grid: grid, cellSize: cellSize),
                ),
              ),
            ),
          ),
          _buildLegend(),
        ],
      ),
    );
  }

  Widget _buildControls() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      color: Colors.white,
      child: Wrap(
        spacing: 16,
        runSpacing: 8,
        alignment: WrapAlignment.center,
        crossAxisAlignment: WrapCrossAlignment.center,
        children: [
          SegmentedButton<PlacementMode>(
            segments: const [
              ButtonSegment(value: PlacementMode.start, label: Text('Start')),
              ButtonSegment(value: PlacementMode.end, label: Text('End')),
              ButtonSegment(value: PlacementMode.wall, label: Text('Wall')),
            ],
            selected: {placementMode},
            onSelectionChanged: (set) {
              setState(() => placementMode = set.first);
            },
          ),
          DropdownButton<Algorithm>(
            value: selectedAlgorithm,
            items: const [
              DropdownMenuItem(value: Algorithm.bfs, child: Text('BFS (Queue)')),
              DropdownMenuItem(value: Algorithm.dfs, child: Text('DFS (Stack)')),
              DropdownMenuItem(
                value: Algorithm.dijkstra,
                child: Text("Dijkstra's (Heap)"),
              ),
            ],
            onChanged: isRunning
                ? null
                : (val) {
                    if (val != null) setState(() => selectedAlgorithm = val);
                  },
          ),
          SizedBox(
            width: 150,
            child: Row(
              children: [
                const Text('Speed:'),
                Expanded(
                  child: Slider(
                    value: speed,
                    min: 5,
                    max: 100,
                    onChanged: isRunning
                        ? null
                        : (val) {
                            setState(() => speed = val);
                          },
                  ),
                ),
              ],
            ),
          ),
          FilledButton.icon(
            onPressed: isRunning ? null : _runAlgorithm,
            icon: const Icon(Icons.play_arrow),
            label: const Text('Visualize'),
          ),
          OutlinedButton.icon(
            onPressed: _resetGrid,
            icon: const Icon(Icons.refresh),
            label: const Text('Reset Path'),
          ),
          OutlinedButton.icon(
            onPressed: _clearAll,
            icon: const Icon(Icons.delete),
            label: const Text('Clear All'),
          ),
        ],
      ),
    );
  }

  Widget _buildLegend() {
    return Container(
      padding: const EdgeInsets.all(12),
      color: Colors.white,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _legendItem(Colors.green, 'Start'),
          _legendItem(Colors.red, 'End'),
          _legendItem(Colors.grey.shade900, 'Wall'),
          _legendItem(Colors.lightBlue.shade200, 'Visited'),
          _legendItem(Colors.yellow.shade600, 'Path'),
        ],
      ),
    );
  }

  Widget _legendItem(Color color, String label) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8),
      child: Row(
        children: [
          Container(width: 16, height: 16, color: color),
          const SizedBox(width: 4),
          Text(label, style: const TextStyle(fontSize: 12)),
        ],
      ),
    );
  }
}

And the complete lib/main.dart:

import 'package:flutter/material.dart';
import 'visualizer_page.dart';

void main() {
  runApp(const PathfinderApp());
}

class PathfinderApp extends StatelessWidget {
  const PathfinderApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pathfinding Visualizer',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.blueGrey,
        useMaterial3: true,
      ),
      home: const VisualizerPage(),
    );
  }
}

Your visualizer is fully interactive. Try running BFS and DFS on the same maze to see how the Queue creates a uniform wave while the Stack dives deep into corridors. Next up, you will deploy this app to GitHub Pages so anyone can try it.

Deploy to GitHub Pages

Your pathfinding visualizer is fully functional on your local machine. Now it is time to put it on the internet so anyone can try it.

GitHub Pages lets you host static web apps directly from a repository for free. Flutter's web build produces exactly the kind of static assets that GitHub Pages can serve.

In this step, get ready to:
  • Build the Flutter web release with the correct base-href.
  • Push the build output to a gh-pages branch on GitHub.
  • Verify the live URL works in your browser.
Build the Flutter web release

Flutter Web apps deployed to a GitHub Pages subpath need the --base-href flag during the build. Without it, the app tries to load assets from the domain root and nothing renders.

  • First, create a new repository on GitHub. Go to github.com/new in your browser.
  • Give your repository a name (for example, pathfinding-visualizer).
  • Leave the repository set to Public (GitHub Pages requires public repos on the free plan).
  • Click Create repository.
  • Do not add a README, .gitignore, or license. Leave the repo empty.

Why does the repo need to be empty?

You will push the build output directly to this repo in a moment. An empty repo avoids merge conflicts with auto-generated files like a README.

  • Switch back to your terminal inside your Flutter project folder.
  • Build the web release with the correct base-href by running this command:
flutter build web --base-href /[[REPO_NAME="your-repository-name"]]/

What does this command do?

flutter build web compiles your Dart code into optimized JavaScript and generates all the static assets needed to run the app in a browser.

The --base-href flag tells Flutter where the app will be hosted relative to the domain. Since GitHub Pages serves project sites at username.github.io/repo-name/, the base-href must match your repository name with a leading and trailing slash.

  • Confirm the build succeeded by checking that the build/web directory exists:
ls build/web

You should see files like index.html, main.dart.js, and a flutter_service_worker.js among others.

Build failed?

Make sure you are running the command from your Flutter project's root folder (the one containing pubspec.yaml).

Check that your repository name in the --base-href flag matches the exact name you gave the repo on GitHub, including casing.

Help me debug my Flutter web build error.

Push to a gh-pages branch on GitHub

GitHub Pages can serve a site directly from a branch called gh-pages. You will push only the contents of build/web to that branch so the repo stays clean.

  • Move into the build output directory by running:
cd build/web
  • Initialize a new Git repository and commit the build files by running these commands:
git init
git add .
git commit -m "Deploy to GitHub Pages"
  • Connect to your GitHub repository and push to the gh-pages branch by running:
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/[[REPO_NAME="your-repository-name"]].git
git branch -M main
git push -f origin main:gh-pages

What do these commands do?

  • git init creates a fresh Git repository inside build/web.
  • git add . stages all the build output files.
  • git commit saves a snapshot of those files.
  • git remote add origin connects this local repo to your GitHub repository.
  • git push -f origin main:gh-pages force-pushes your local main branch to the remote gh-pages branch, creating it if it does not exist.

Authentication error when pushing?

GitHub no longer accepts passwords for Git operations. If prompted for credentials, use a personal access token instead of your password.

You can generate a token at github.com/settings/tokens.

Help me fix a Git authentication error when pushing to GitHub.

Enable GitHub Pages and verify the live URL

The build files are on GitHub now. You need to tell GitHub to serve them as a website.

  • Go to your repository on GitHub in your browser.
  • Click Settings in the top navigation bar of your repository.
  • In the Code and automation section of the left sidebar, click Pages.
  • Under Build and deployment, set Source to Deploy from a branch.
  • In the Branch dropdown, select gh-pages and leave the folder set to / (root).
  • Click Save.

How long does deployment take?

GitHub Pages typically takes 1-2 minutes to build and publish your site after you save. You can watch the progress under the Actions tab of your repository.

  • Wait about a minute, then open your live site in a new browser tab:
https://[[GITHUB_USERNAME="your-github-username"]].github.io/[[REPO_NAME="your-repository-name"]]/
  • Verify that the Pathfinding Visualizer loads with the grid, controls, and legend visible.
  • Try placing start and end nodes, drawing walls, and running an algorithm to confirm everything works on the live site.

✔️ I see my visualizer running

Your pathfinding visualizer is live on the internet. Copy the URL and share it with anyone you want to impress.

ⓧ I see a blank page

A blank page usually means the --base-href value does not match your repository name exactly.

  • Double check that the repository name in your build command matches the repo name on GitHub (case-sensitive).
  • Make sure you included the leading and trailing slashes in the --base-href value (for example, /pathfinding-visualizer/).
  • Rebuild with the corrected value and re-push to gh-pages.

My GitHub Pages Flutter app shows a blank page. Help me debug.

Secret mission

Add A* Search

Dijkstra's algorithm finds the shortest path, but it explores in every direction equally. What if you could guide the search toward the goal? Add A* search with a Manhattan distance heuristic and watch it find the same shortest path while exploring significantly fewer nodes.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally and deploys to GitHub Pages for free, so there are no ongoing costs.

Resources you used:

  • Local Flutter project folder on your Desktop.
  • GitHub repository (includes the gh-pages branch and live GitHub Pages deployment).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your GitHub Pages site live as a portfolio piece.

  • Your local project folder stays on your Desktop for future editing.
  • Your GitHub Pages site remains live at its URL for anyone to visit.
  • GitHub Pages hosting is free, so there is nothing to pay.

Pause - I'll come back to this later

Disable the live site but keep your code intact so you can redeploy later.

  • Go to your repository on GitHub.
  • Click Settings.
  • Scroll down to the Pages section in the left sidebar.
  • Under Build and deployment, change the source to None to unpublish the site.
  • Your code and repository remain intact. When you want to redeploy, set the source back to the gh-pages branch.

Delete - I don't want to use this again

Remove all project resources completely.

  • Delete your local project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf pathfinder_visualizer

On Windows, run:

cd ~\Desktop
rmdir /s /q pathfinder_visualizer

Use your actual folder name

Replace pathfinder_visualizer with whatever you named your Flutter project when you ran flutter create.

  • Delete the GitHub repository:
  • Go to your repository page on GitHub.
  • Click Settings.
  • Scroll to the bottom of the General page.
  • Click Delete this repository under the Danger Zone.
  • Follow the confirmation prompt to permanently delete the repository and its GitHub Pages deployment.

Nice Work!

Nice Work!

You just built a pathfinding algorithm visualizer with Flutter Web and deployed it live to GitHub Pages.

Anyone can click your link, draw a maze, and watch your hand-written data structures power three different search algorithms in real time.

You've learned how to:

  • Implement three core data structures from scratch: Queue (FIFO), Stack (LIFO), and Min-Heap Priority Queue with bubbleUp/bubbleDown operations.
  • Build BFS, DFS, and Dijkstra's algorithms that demonstrate how data structure choice drives search behavior.
  • Create an interactive grid visualizer using Flutter's CustomPainter with Timer-based animation that steps through exploration one node at a time.
  • Added A* search with a Manhattan distance heuristic to prove how informed search explores fewer nodes while still finding the shortest path.

Ready to quiz yourself?