{"slug": "how-i-implemented-a-pathfinding-in-unity-c-with-dfs-maze-generation", "title": "How I implemented A* pathfinding in Unity C# (with DFS maze generation)", "summary": "Unity game developer Abdul Fahad Shahbaz implemented A* pathfinding and DFS maze generation in C# for an AI-driven 3D maze game. The procedural maze is generated using iterative Depth-First Search, and a custom A* pathfinder navigates the grid-based maze in real time, avoiding Unity's built-in NavMesh which cannot handle runtime-generated geometry.", "body_md": "Posted by Abdul Fahad Shahbaz — Unity game developer based in Lahore, Pakistan. Portfolio: fahadshahbaz.fun\n\nIn my latest Unity project — an AI-driven 3D maze game — I needed two things to work together: a maze generator that creates a unique, fully-connected layout every run, and an enemy AI that could navigate those mazes intelligently in real time.\n\nThe challenge? The maze is procedural. That means I can't bake a navmesh ahead of time or rely on Unity's built-in navigation system. Every time the player starts a run, a brand new maze is generated — and the enemy has to find its way through it from scratch.\n\nI solved this with two algorithms working in tandem:\n\nDepth-First Search (DFS) to generate the maze\n\nA* pathfinding to navigate it\n\nIn this post I'll explain both implementations with working C# code taken directly from the project. By the end you'll have a self-contained AStarPathfinder class you can drop into any Unity maze project.\n\nMy portfolio (see the full project): [https://fahadshahbaz.fun/works/AI-Maze-Game](https://fahadshahbaz.fun/works/AI-Maze-Game)\n\nThe problem with Unity's built-in NavMesh\n\nUnity's NavMesh is excellent for static environments. But procedural mazes create geometry at runtime — walls are instantiated, corridors are carved, the whole layout is different every time. By the time the maze is ready, it's too late to bake a navmesh without adding significant frame time.\n\nI needed a pathfinder that:\n\nWorks on a grid, not a mesh\n\nKnows which cells have walls between them (not just which cells exist)\n\nConverts grid coordinates back to world-space Vector3 positions the enemy can walk to\n\nA custom A* implementation turned out to be the cleanest fit.\n\nPart 1: Generating the maze with DFS\n\nBefore we can pathfind through a maze, we need a maze. I used iterative Depth-First Search (also called \"recursive backtracker\") because it produces long, winding corridors that feel natural for a 3D game — not a grid of perfect squares.\n\nThe algorithm works like this:\n\nStart at a random cell, mark it visited\n\nPick a random unvisited neighbour\n\nRemove the wall between the current cell and that neighbour\n\nMove to the neighbour and repeat\n\nIf no unvisited neighbours exist, backtrack to the previous cell\n\nRepeat until all cells are visited\n\nEvery cell in the grid is a MazeCell — a simple data structure that tracks which of its four walls (left, right, front, back) are still standing.\n\ncsharp\n\n```\npublic class MazeCell\n{\n    private bool rightWall = true;\n    private bool leftWall  = true;\n    private bool frontWall = true;\n    private bool backWall  = true;\n\n    public bool HasRightWall() => rightWall;\n    public bool HasLeftWall()  => leftWall;\n    public bool HasFrontWall() => frontWall;\n    public bool HasBackWall()  => backWall;\n\n    public void RemoveRightWall() => rightWall = false;\n    public void RemoveLeftWall()  => leftWall  = false;\n    public void RemoveFrontWall() => frontWall = false;\n    public void RemoveBackWall()  => backWall  = false;\n}\n```\n\nThe generator keeps a Stack to track the path for backtracking. When it carves a passage between cell A and cell B, it removes A's wall facing B and B's wall facing A — so both cells agree on the opening.\n\ncsharp// Example: carving a passage to the RIGHT\n\ncurrentCell.RemoveRightWall();\n\ngrid[next.x, next.y].RemoveLeftWall();\n\nThe result is a MazeCell[,] grid where every cell knows exactly which of its walls are still standing. This is the data structure the A* pathfinder reads directly.\n\nWhy DFS over Prim's or Kruskal's? DFS produces mazes with a single long main path and fewer dead ends — which feels more fun to race through. Prim's produces mazes with many short dead ends, which is better for puzzle games. For a time-pressure race game, DFS was the right call.\n\nPart 2: A* pathfinding through a wall-aware grid\n\nHere is the full AStarPathfinder class from the project.\n\ncsharp\n\n```\nusing System.Collections.Generic;\nusing UnityEngine;\n\npublic class AStarPathfinder\n{\n    private MazeCell[,] grid;\n    private int width;\n    private int depth;\n    private float cellSizeX;\n    private float cellSizeZ;\n    private Vector3 originOffset;\n\n    public AStarPathfinder(\n        MazeCell[,] grid,\n        int width,\n        int depth,\n        float cellSizeX,\n        float cellSizeZ,\n        Vector3 originOffset = default)\n    {\n        this.grid         = grid;\n        this.width        = width;\n        this.depth        = depth;\n        this.cellSizeX    = cellSizeX;\n        this.cellSizeZ    = cellSizeZ;\n        this.originOffset = originOffset;\n    }\n```\n\nI pass in cellSizeX, cellSizeZ, and originOffset so the pathfinder can convert grid coordinates back to Unity world space at the end. The maze cells live in a logical grid (integer indices), but the enemy AI needs real Vector3 positions to move to.\n\nThe Node class\n\ncsharp\n\n```\nclass Node\n    {\n        public int x, z;\n        public float g, h;\n        public float f => g + h;\n        public Node parent;\n\n        public Node(int x, int z)\n        {\n            this.x = x;\n            this.z = z;\n        }\n    }\n```\n\nEach node holds:\n\nx, z — grid coordinates (I use Z for depth, matching Unity's coordinate system)\n\ng — cost from the start node to this node\n\nh — heuristic estimate to the goal (Manhattan distance)\n\nf — total estimated cost (g + h); this is what A* uses to prioritise nodes\n\nparent — the node we came from, used to retrace the path at the end\n\nThe main search loop\n\ncsharp\n\n```\npublic List<Vector3> FindPath(Vector2Int start, Vector2Int end)\n    {\n        List<Node> open = new List<Node>();\n        HashSet<(int, int)> closed = new HashSet<(int, int)>();\n\n        Node startNode = new Node(start.x, start.y);\n        Node endNode   = new Node(end.x, end.y);\n\n        open.Add(startNode);\n\n        while (open.Count > 0)\n        {\n            // Pick the node with the lowest f score\n            Node current = open[0];\n            foreach (var n in open)\n                if (n.f < current.f)\n                    current = n;\n\n            open.Remove(current);\n            closed.Add((current.x, current.z));\n\n            // Goal reached — retrace the path\n            if (current.x == endNode.x && current.z == endNode.z)\n                return Retrace(current);\n\n            foreach (var neighbor in GetNeighbors(current))\n            {\n                if (closed.Contains((neighbor.x, neighbor.z)))\n                    continue;\n\n                float newG     = current.g + 1;\n                Node  existing = open.Find(n => n.x == neighbor.x && n.z == neighbor.z);\n\n                if (existing == null)\n                {\n                    neighbor.g      = newG;\n                    neighbor.h      = Manhattan(neighbor, endNode);\n                    neighbor.parent = current;\n                    open.Add(neighbor);\n                }\n                else if (newG < existing.g)\n                {\n                    existing.g      = newG;\n                    existing.parent = current;\n                }\n            }\n        }\n\n        return null; // No path found\n    }\n```\n\nA few things worth noting:\n\nThe open list is a plain List. A textbook A* implementation uses a priority queue (min-heap) for O(log n) extraction of the lowest-cost node. For my maze sizes (up to 20×20 = 400 cells), a linear scan is fast enough and keeps the code simple. If you're pathfinding on large grids (100×100+), swap this for a SortedSet or a proper priority queue.\n\nThe closed set is a HashSet<(int, int)>. This gives O(1) lookups — much faster than a list for checking whether a cell has already been processed.\n\nAll step costs are 1. Since we're moving on a uniform grid (each cell is the same size), every step costs the same. If you had terrain weights (mud = 2, road = 0.5), you'd add those here.\n\nFindPath returns null if no path exists. The caller should always check for null before using the result.\n\nThe heuristic: Manhattan distance\n\ncsharp\n\n```\nprivate float Manhattan(Node a, Node b)\n    {\n        return Mathf.Abs(a.x - b.x) + Mathf.Abs(a.z - b.z);\n    }\n```\n\nManhattan distance counts how many steps it would take to reach the goal if there were no walls — purely horizontal and vertical moves. It's the correct heuristic for a grid where you can only move in four directions (no diagonals).\n\nWhy not Euclidean distance? Euclidean distance (sqrt(dx² + dz²)) would also work, but it can overestimate the true cost on a grid that doesn't allow diagonal movement. Manhattan distance is admissible — it never overestimates — which guarantees A* finds the optimal path.\n\nThe key insight: wall-aware neighbour detection\n\nThis is the part that makes this implementation specific to maze navigation. Standard grid A* checks whether adjacent cells exist and aren't obstacles. Maze A* needs to check whether the wall between two cells has been removed.\n\ncsharp\n\n```\nprivate List<Node> GetNeighbors(Node node)\n    {\n        List<Node> neighbors = new List<Node>();\n        MazeCell cell = grid[node.x, node.z];\n\n        // RIGHT — can we move to (x+1, z)?\n        if (node.x + 1 < width && !cell.HasRightWall())\n            neighbors.Add(new Node(node.x + 1, node.z));\n\n        // LEFT — can we move to (x-1, z)?\n        if (node.x - 1 >= 0 && !cell.HasLeftWall())\n            neighbors.Add(new Node(node.x - 1, node.z));\n\n        // FRONT — can we move to (x, z+1)?\n        if (node.z + 1 < depth && !cell.HasFrontWall())\n            neighbors.Add(new Node(node.x, node.z + 1));\n\n        // BACK — can we move to (x, z-1)?\n        if (node.z - 1 >= 0 && !cell.HasBackWall())\n            neighbors.Add(new Node(node.x, node.z - 1));\n\n        return neighbors;\n    }\n```\n\nEach direction check has two parts:\n\nBounds check — don't walk off the edge of the grid\n\nWall check — only add the neighbour if the wall between them has been removed\n\nThis is why the MazeCell wall data is so important. The A* algorithm doesn't know the maze was generated with DFS — it just asks each cell \"which of your walls are open?\" and treats open walls as valid moves.\n\nConverting back to world space\n\ncsharp\n\n```\nprivate List<Vector3> Retrace(Node endNode)\n    {\n        List<Vector3> path = new List<Vector3>();\n        Node current = endNode;\n\n        while (current != null)\n        {\n            Vector3 pos = new Vector3(\n                current.x * cellSizeX + originOffset.x,\n                0.5f,\n                current.z * cellSizeZ + originOffset.z\n            );\n            path.Add(pos);\n            current = current.parent;\n        }\n\n        path.Reverse();\n        return path;\n    }\n}\n```\n\nRetrace walks backwards through the parent chain from the goal to the start, building a list of grid positions. Then it reverses the list so it runs start → goal.\n\nEach grid position is converted to a world-space Vector3 by multiplying the cell index by the cell size and adding the origin offset. The y is hardcoded to 0.5f — the vertical centre of a floor-level cell in my scene.\n\nThe enemy AI then moves along this list of Vector3 waypoints using Vector3.MoveTowards.\n\nHow the enemy uses the path\n\nOnce FindPath returns a list of world-space positions, the enemy AI follows them in sequence:\n\ncsharp// In the enemy MonoBehaviour\n\n```\nprivate List<Vector3> path;\nprivate int waypointIndex = 0;\n\nvoid Update()\n{\n    if (path == null || waypointIndex >= path.Count) return;\n\n    Vector3 target = path[waypointIndex];\n    transform.position = Vector3.MoveTowards(\n        transform.position,\n        target,\n        moveSpeed * Time.deltaTime\n    );\n\n    if (Vector3.Distance(transform.position, target) < 0.05f)\n        waypointIndex++;\n}\n```\n\nThe pathfinder is called once when the enemy spawns (or when the player reaches a certain distance away) and the result is cached. For a small maze, recalculating every few seconds is totally fine — but you could also recalculate only when the player changes cells to save performance.\n\nPutting it all together\n\nHere is the flow from game start to enemy movement:\n\nMazeGenerator runs DFS\n\n→ produces MazeCell[,] grid (walls carved)\n\n→ instantiates wall GameObjects in the scene\n\nAStarPathfinder is constructed\n\n→ receives the same MazeCell[,] grid\n\n→ knows cell sizes and world origin\n\nEnemy spawns\n\n→ calls FindPath(enemyGridPos, playerGridPos)\n\n→ receives List of world waypoints\n\nEnemy MonoBehaviour\n\n→ follows waypoints with MoveTowards\n\n→ recalculates path every N seconds (or on player cell change)\n\nThe two systems share the MazeCell[,] grid as their single source of truth. The generator writes to it; the pathfinder reads from it. No intermediate data structures, no duplicate wall tracking.\n\nPerformance notes\n\nFor a 15×15 maze (225 cells), FindPath runs in under 1ms on device. That is fast enough to call every frame if needed, but I recalculate once every 2 seconds in practice, which keeps CPU usage negligible.\n\nIf you scale up to larger grids:\n\nReplace the open list with a priority queue. The linear scan for the lowest-f node is O(n) per iteration. At 50×50+ cells this becomes noticeable. C# doesn't have a built-in min-heap, but you can use SortedSet with a custom IComparer, or pull in a priority queue from a NuGet package.\n\nUse a pooled node allocator. Calling new Node() inside a tight loop allocates heap memory and can cause GC spikes. For frequent pathfinding, consider pooling nodes or using a struct-based approach.\n\nCache the path and recalculate on demand. Don't recalculate every frame unless the maze or player position changes.\n\nWhat I learned\n\nBuilding a custom pathfinder instead of relying on Unity's NavMesh taught me a few things:\n\nNavMesh is not always the answer. For dynamic, procedurally generated environments, a grid-based pathfinder that understands your data model (in this case, MazeCell walls) can be simpler, faster, and more accurate than baking a mesh at runtime.\n\nThe heuristic matters more than the data structure — at small scales. At maze sizes I'm working with, swapping the list for a heap makes almost no measurable difference. Picking the right heuristic (Manhattan vs Euclidean) has a much bigger effect on path quality.\n\nWall-awareness is the whole point. Standard A* treats cells as either passable or impassable. Maze pathfinding is more nuanced — a cell is passable from some directions and not others. Encoding that in GetNeighbors rather than as a binary obstacle grid is cleaner and more accurate.\n\nWrapping up\n\nHere is a summary of everything covered:\n\nDFS generates a connected maze by carving walls between cells, tracked in a MazeCell[,] grid\n\nAStarPathfinder reads that same grid and uses wall data (not obstacle maps) to determine valid moves\n\nManhattan distance is the correct admissible heuristic for a four-directional grid\n\nRetrace converts grid indices back to world-space Vector3 waypoints\n\nThe enemy AI follows those waypoints with Vector3.MoveTowards\n\nThe full project — including the maze generator, enemy AI, and more — is in my portfolio:\n\n[https://fahadshahbaz.fun/works/AI-Maze-Game](https://fahadshahbaz.fun/works/AI-Maze-Game)\n\nIf you have questions about the implementation or want to see the DFS generator code in full, drop a comment below. And if this helped you build something, I'd love to see it.\n\nAbdul Fahad Shahbaz is a Unity game developer based in Lahore, Pakistan, specializing in mobile games, WebGL, and AR. Portfolio: fahadshahbaz.fun", "url": "https://wpnews.pro/news/how-i-implemented-a-pathfinding-in-unity-c-with-dfs-maze-generation", "canonical_source": "https://dev.to/abdul_fahad/how-i-implemented-a-pathfinding-in-unity-c-with-dfs-maze-generation-19jb", "published_at": "2026-06-28 11:40:54+00:00", "updated_at": "2026-06-28 12:34:07.412763+00:00", "lang": "en", "topics": ["artificial-intelligence"], "entities": ["Abdul Fahad Shahbaz", "Unity", "C#", "A* pathfinding", "Depth-First Search"], "alternates": {"html": "https://wpnews.pro/news/how-i-implemented-a-pathfinding-in-unity-c-with-dfs-maze-generation", "markdown": "https://wpnews.pro/news/how-i-implemented-a-pathfinding-in-unity-c-with-dfs-maze-generation.md", "text": "https://wpnews.pro/news/how-i-implemented-a-pathfinding-in-unity-c-with-dfs-maze-generation.txt", "jsonld": "https://wpnews.pro/news/how-i-implemented-a-pathfinding-in-unity-c-with-dfs-maze-generation.jsonld"}}