1
\$\begingroup\$

I want to make a simple maze with colour-coded cells:

  • white denotes a blank space,
  • black denotes a wall,
  • green denotes source and
  • red denotes destination.

So, my idea was to write an array of integers and replace each integer with its corresponding colour. Example:

var maze = [[2, 0, 0], [1, 1, 1], [1, 1, 3]]

0, 1, 2, 3 corresponds to wall, space, source, destination respectively

To achieve this, I think I could find all paths in the maze then choose one to make it the solution, and alter a value in each of the remaining paths to make them invalid, only if that value is not included the solution.

I decided to generate each maze with specified width and height, then choose both the source and the destination cells, and finally randomly assign each cell to be either a space or a wall.

The challenging part was to check if any paths exist in the maze, and to return them. If there are no solutions, generate another maze. However, I don't know how to do this yet.

I searched and I found solutions to this problems using breadth first search algorithm, but I don't know how successfully I can return all possible paths.

Simple paths' output for the above maze would be something like this:

[Vector2(0, 0), Vector2(1, 0), Vector2(1, 1), Vector2(1, 2), Vector2(2, 2)]
[Vector2(0, 0), Vector2(1, 0), Vector2(1, 1), Vector2(2, 1), Vector2(2, 2)]
[Vector2(0, 0), Vector2(1, 0), Vector2(2, 0), Vector2(2, 1), Vector2(2, 2)]
[Vector2(0, 0), Vector2(1, 0), Vector2(2, 0), Vector2(2, 1), Vector2(1, 1), Vector2(1, 2), Vector2(2, 2)]

What I have done so far:

func getNeighbors(cell, maze):
    for i in range(maze.size()):
        for j in range(maze[i].size()):
            if maze[cell.x][cell.y] == wall:
                return
            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors 

func bfs():
    var queue = []
    var source = Vector2(0, 0)
    var destination = Vector2(2, 2)
    queue.append(source)
    while queue.size() > 0:
        var current = queue.pop_back()
        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

I didn't understand the "visited cells" part of the algorithm. Also, I don't know how to save each path in an array of possible_paths.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Many common maze generators (see lists here, here, and here) produce spanning trees, which have the nice property that there's a unique path joining any two non-wall points, which would mean you could skip this search-and-invalidate step entirely: you'd know there's only one valid path by construction. Is that approach suitable for your needs? \$\endgroup\$
    – DMGregory
    Commented Dec 21, 2022 at 14:23

1 Answer 1

4
\$\begingroup\$

I'm writing this answer with the intent to help you learn.

Yet, I should say DMGregory is correct. Pick an algorithm based on what you want for your game. And might get rid of the invalidation process entirely too.


On your solution

In the spirit of teaching and learning, I'll look at your implementation. I'll also point out that Godot can has an A* solution built-in. I'll get to that.


Getting the Neighbors

As you have correctly identified, you need to be able to query which are the valid neighbors for a cell.

But this is not right:

func getNeighbors(cell, maze):
    for i in range(maze.size()):
        for j in range(maze[i].size()):
            if maze[cell.x][cell.y] == wall:
                return
            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors 

Here you are adding to - presumably - a field neighbors, which you also return. So the field neighbors would have all the elements added across all the calls to getNeighbors, including duplicates. And, remember, we only want the neighbors of the specific cell.

So, first of all, make neighbors local (I have also added types to the signature):

func getNeighbors(cell:Vector2, maze:Array) -> Array:
    var neighbors := []
    for i in range(maze.size()):
        for j in range(maze[i].size()):
            if maze[cell.x][cell.y] == wall:
                return
            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors 

Second, you don't need to iterate over the whole maze to find the neighbors. You only need to check adjacent cells (to which the comment you have alludes to). So do that:

func getNeighbors(cell:Vector2, maze:Array) -> Array:
    var neighbors := []
    for i in [cell.x - 1, cell.x, cell.x + 1]:
        for j in [cell.y - 1, cell.y, cell.y + 1]:
            if maze[i][j] == wall:
                return
            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors 

Wait a minute. Something tells me your maze is rows not columns. Look you have it defined like this:

var maze = [[2, 0, 0], [1, 1, 1], [1, 1, 3]]

Rearrange and it is like this:

var maze = [
    [2, 0, 0],
    [1, 1, 1],
    [1, 1, 3]
]

So the outer array is the vertical, and the inner is the horizontal. Thus, it is like this:

func getNeighbors(cell:Vector2, maze:Array) -> Array:
    var neighbors := []
    for i in [cell.x - 1, cell.x, cell.x + 1]:
        for j in [cell.y - 1, cell.y, cell.y + 1]:
            if maze[j][i] == wall:
                return
            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors 

Better yet:

func getNeighbors(cell:Vector2, maze:Array) -> Array:
    var neighbors := []
    for j in [cell.y - 1, cell.y, cell.y + 1]:
        for i in [cell.x - 1, cell.x, cell.x + 1]:
            if maze[j][i] == wall:
                return
            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors 

Ah, skip the cell itself:

func getNeighbors(cell:Vector2, maze:Array) -> Array:
    var neighbors := []
    for j in [cell.y - 1, cell.y, cell.y + 1]:
        for i in [cell.x - 1, cell.x, cell.x + 1]:
            if i == cell.x and j == cell.y:
                continue

            if maze[j][i] == wall:
                return
            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors 

Skip walls. Don't exit on walls:

func getNeighbors(cell:Vector2, maze:Array) -> Array:
    var neighbors := []
    for j in [cell.y - 1, cell.y, cell.y + 1]:
        for i in [cell.x - 1, cell.x, cell.x + 1]:
            if i == cell.x and j == cell.y:
                continue

            if maze[j][i] == wall:
                continue

            # add neighbors (x+1, y) (x-1, y) (x, y-1), (x, y+1) if they are within range of width and height
    return neighbors

And, yes, add to the neighbors:

func getNeighbors(cell:Vector2, maze:Array) -> Array:
    var neighbors := []
    for j in [cell.y - 1, cell.y, cell.y + 1]:
        for i in [cell.x - 1, cell.x, cell.x + 1]:
            if i == cell.x and j == cell.y:
                continue

            if maze[j][i] == wall:
                continue
            
            neighbors.append(Vector2(i, j))

    return neighbors

Interlude: Dictionary

Let me introduce you to Dictionary.

You can declare a Dictionary like this:

var dict := {}

Or like this:

var dict := Dictionary.new()

You can set (adds if the element is not there) elements like this:

dict[key] = value

And you can get elements like this:

value = dict[key]

Crucially, the dictionary won't store two elements on the same key. There will not be duplicated keys. If I do this:

dict[key] = value
dict[key] = another_value

The second replaces the first one.

You might want to check if the element is there (since getting a elements that isn't there is an error):

if dict.has(key):
    value = dict[key]

Or you can use get which lets you define a fallback which is returned if the element isn't there:

value = dict.get(key, fallback)

Oh, and you remove from the dictionary like this:

dict.erase(key)

Therefore, we add whatever (e.g. null or true) we can use the keys as a set. Thus, this is a set:

var set := {}

You add an element to the set like this:

set[value] = true

Note that the set cannot have a value twice.

You remove an element from the set like this:

set.erase(value)

And you check if the set has a value like this:

set.has(value)

This is faster than adding the elements to an Array and then checking if the Array has the elements (with has) because looking in the array requires Godot to iterate over it, but for the set... I mean Dictionary Godot creates an index for quick lookup.


Breadth-first search

Ok, let us look to your code:

func bfs():
    var queue = []
    var source = Vector2(0, 0)
    var destination = Vector2(2, 2)
    queue.append(source)
    while queue.size() > 0:
        var current = queue.pop_back()
        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

Hmm… You are hard-coding source and destination. Why do you have them in the maze then? Those are two sources of truth. That is not good, because it means that you need to update in two different places if it changes (and if you forget, or you do it wrong, you got a bug).

I'll promote them to parameters. The method call this can pass them. In fact the method that calls this can both pass them and also write them to the maze.

func bfs(source:Vector2, destination:Vector2):
    var queue = []
    queue.append(source)
    while queue.size() > 0:
        var current = queue.pop_back()
        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

In fact, traditionally the graph maze is a parameter too. You made it a parameter in the prior method, so why not here too?

func bfs(maze:Array, source:Vector2, destination:Vector2):
    var queue = []
    queue.append(source)
    while queue.size() > 0:
        var current = queue.pop_back()
        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

On your main issue: visited cells. You need to know which cells you have explored already. How to do that? The simplest solution is to add them to an Array… But we will use a set, I mean a Dictionary.

func bfs(maze:Array, source:Vector2, destination:Vector2):
    var queue := []
    var visited_cells := {}
    queue.append(source)
    while queue.size() > 0:
        var current = queue.pop_back()
        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

And the source should be marked visited right away:

func bfs(maze:Array, source:Vector2, destination:Vector2):
    var queue := []
    var visited_cells := {}
    visited_cells[source] = true
    queue.append(source)
    while queue.size() > 0:
        var current = queue.pop_back()
        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

And yes, you add the source to the queue. I'll leave it as you have it, but know you could have done var queue := [source].

We iterate until the queue is empty. Again, I'll leave it as you have it, but know there is an empty method you can use.

And you dequeue… pop_back removes and returns the last element. And append adds at the end. Thus, the combination of pop_back and append means you are using your Array as a stack not as a queue.

There are two built-in ways to use an Array as a queue:

  • With pop_back and push_front.
  • With pop_front and push_back (append).

We could take our time and implement something else (pre-allocate and use indexing), but let's do with what is available already since it is less error prone (look into it if you want).

Operations on the back of the Array are faster. Although we will do both operations equally as much (we will remove everything we add), I'll add at the back since it is in the inner loop (I expect elements to be added in quick succession, at least at the start). Thus:

func bfs(maze:Array, source:Vector2, destination:Vector2):
    var queue := []
    var visited_cells := {}
    visited_cells[source] = true
    queue.append(source)
    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

Next we check if it is the goal. If it is, we return the path… Wait, we also need to declare the path we are going to return! That is an issue, because we will actually be building multiple paths. Well, we can use a Dictionary for this… But for the moment I'll return an empty path.

func bfs(maze:Array, source:Vector2, destination:Vector2) -> Array:
    var paths := {}
    var queue := []
    var visited_cells := {}
    visited_cells[source] = true
    queue.append(source)
    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        if current == destination:
            return [] # TODO

        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            queue.append(n)

    return [] # TODO

Yes, we iterate over the neighbors. And we skip the ones that we visited:

func bfs(maze:Array, source:Vector2, destination:Vector2) -> Array:
    var paths := {}
    var queue := []
    var visited_cells := {}
    visited_cells[source] = true
    queue.append(source)
    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        if current == destination:
            return [] # TODO

        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            if visited_cells.has(n):
                continue

            queue.append(n)

    return [] # TODO

Now we mark the cell as visited:

func bfs(maze:Array, source:Vector2, destination:Vector2) -> Array:
    var paths := {}
    var queue := []
    var visited_cells := {}
    visited_cells[source] = true
    queue.append(source)
    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        if current == destination:
            return [] # TODO

        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            if visited_cells.has(n):
                continue

            visited_cells[n] = true
            queue.append(n)

    return [] # TODO

We make the… current the parent of the neighbor … What we are doing is the path up to the neighbor. That is, we need to say that to get to the neighbor, we first need to get to the current cell.

And I said we will use a Dictionary for the paths. The plan is to use the cells as keys, and store the paths as values. So, first of all, the path to get to the source, is just the source:

func bfs(maze:Array, source:Vector2, destination:Vector2) -> Array:
    var paths := {}
    var queue := []
    var visited_cells := {}
    visited_cells[source] = true
    queue.append(source)
    paths[source] = [source]
    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        if current == destination:
            return [] # TODO

        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            if visited_cells.has(n):
                continue

            visited_cells[n] = true
            queue.append(n)

    return [] # TODO

Let me re-arrange that:

func bfs(maze:Array, source:Vector2, destination:Vector2) -> Array:
    var queue := []
    queue.append(source)

    var visited_cells := {}
    visited_cells[source] = true

    var paths := {}
    paths[source] = [source]

    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        if current == destination:
            return [] # TODO

        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            if visited_cells.has(n):
                continue

            visited_cells[n] = true
            queue.append(n)

    return [] # TODO

Now, the path to get to the neighbor is the path to the current cell, plus the neighbor (and yes, we can just add Arrays here):

func bfs(maze:Array, source:Vector2, destination:Vector2) -> Array:
    var queue := []
    queue.append(source)

    var visited_cells := {}
    visited_cells[source] = true

    var paths := {}
    paths[source] = [source]

    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        if current == destination:
            return [] # TODO

        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            if visited_cells.has(n):
                continue

            visited_cells[n] = true
            paths[n] = paths[current] + [n]
            queue.append(n)

    return [] # TODO

And yes, we add to the queue…

Finally we return the path to the destination:

func bfs(maze:Array, source:Vector2, destination:Vector2) -> Array:
    var queue := []
    queue.append(source)

    var visited_cells := {}
    visited_cells[source] = true

    var paths := {}
    paths[source] = [source]

    while queue.size() > 0:
        var current:Vector2 = queue.pop_front()
        if current == destination:
            return paths[destination] # There is a path

        var neighbors = getNeighbors (current, maze)
        for n in neighbors:
            if visited_cells.has(n):
                continue

            visited_cells[n] = true
            paths[n] = paths[current] + [n]
            queue.append(n)

    return [] # There isn't a path

Please notice that Breadth-first search will find a path if there is a path, but it might not be the shortest one.

What I mention in the next few paragraphs is beyond this question, but I want to bring it up if you want to continue learning how these work:

If you want to make sure you get the shortest one, you want to implement Dijkstra's algorithm. The reason you don't see game developers talking about Dijkstra's algorithm as much is because it is exhaustive, in fact it compute the shortest path between all the points.

However we don't need to be exhaustive to get the shortest path between two points, we need a way to guess what parts to check… We get that from adding an heuristic. Which is what the famous A* does.

It is also possible to further modify A* to limit its memory use or some other constraints. Usually in exchange of losing the guarantee of getting the shortest path (instead we get a short enough path).


Godot A*

Alright, if you are using Godot, you can save yourself the trouble of implementing those algorithms and use the AStar2D class (there is also a 3D counterpart). They implements - as the name suggests - the A* algorithm.

However, you needed the lessons about using arrays and dictionaries we went over. Thus, I encourage you to - at least - go over the code I presented before.

You can use the class AStar2D by adding cells with add_point. It requires ids. It is a good idea to be clever with the ids so you can compute the id for a given position. Something like this would work assuming the maze is rectangular:

var astar := AStar2D.new()
var height := maze.size()
var width := maze[0].size()
for j in height:
    for i in width:
        var id := j * width + i
        astar.add_point(id, Vector2(i, j))

Here the id is the number of the row multiplied by the length of the row (which gives you how many elements were before the start of the row) plus the position inside the row.

We, of course, don't need to add impasable points, so skip walls:

var astar := AStar2D.new()
var height := maze.size()
var width := maze[0].size()
for j in height:
    for i in width:
        if maze[j][i] == wall:
            continue

        var id := j * width + i
        astar.add_point(id, Vector2(i, j))

That also highlights that ids don't need to be continous. It is more important to be able to compute the id from the coordinates (which is why I didn't just have an id variable start at zero and increment it with each point).

You also want to connect them with connect_points. Which means we need to check the neighbors!

We are not going to connect to neighbors that has not been added. That includes:

  • Positions outside the maze (we don't add those).
  • Positions that are not passable (we don't add those).
  • Positions that we haven't checked (when we check them we might add them).

In fact, we are not in a hurry to check positions ahead. When the loop gets to them, we can connect them (the connection is bidirectional by default). Thus, we can limit ourselves to position that we have already passed.

var astar := AStar2D.new()
var height := maze.size()
var width := maze[0].size()
for j in height:
    for i in width:
        if maze[j][i] == wall:
            continue

        var id := j * width + i
        astar.add_point(id, Vector2(i, j))
        for jn in [j - 1, j]:
            for i in [i - 1, i]:
                var idn := jn * width + in
                if astar.has_point(idn):
                    astar.connect_points(idn, id)

I'll admit that the code above is a little too nested for my taste. If you see fit to refactor it by extracting functions, go ahead. In fact, you probably want to pass astar as a parameter because you will likely populate it once and query it multiple times.

Ah, of course, you will query the AStar! Use get_point_path:

var path := astar.get_point_path(
    source.y * width + source.x,
    destination.y * width + destination.x
)

And there is your path. Guaranteed to be the shortest. And with good performance.

Be aware that it is computing the path you ask for (it does not compute all the paths in advance). Thus, it is a good idea to hold on to the result while you are using it instead querying it every frame or something like that.


Wait… maze generation?

The challenging part was to check if any paths exist in the maze, and to return them. If there are no solutions, generate another maze. However, I don't know how to do this yet.

You are checking if the maze is valid. There are maze generation algorithms that can guarantee to generate a valid maze.


You are generating by placing walls at random:

I decided to generate each maze with specified width and height, then choose both the source and the destination cells, and finally randomly assign each cell to be either a space or a wall.

But you don't only know there isn't a path. You know, for each cell if there is a path.

Thus, instead of throwing away this maze, you could random-walk from the destination until it connects to one of the cells that has a path. Start by storing from where we are walking:

var walking := destination

The plan is to pick a neighbor at random. For this I'll declare a RandomNumberGenerator, and seed it with randomize:

var random := RandomNumberGenerator.new()
random.randomize()

Now compute the list of your possible neighbors:

var possible_neighbors := [
    Vector2(walking.x - 1, walking.y - 1),
    Vector2(walking.x, walking.y - 1),
    Vector2(walking.x + 1, walking.y - 1),

    Vector2(walking.x - 1, walking.y),
    Vector2(walking.x + 1, walking.y),

    Vector2(walking.x - 1, walking.y + 1),
    Vector2(walking.x, walking.y + 1),
    Vector2(walking.x + 1, walking.y + 1),
]

Make sure you aren't going outside the maze:

for index in range(possible_neighbors.size(), -1, -1, -1):
    var n:Vector2 = possible_neighbors[index]
    if n.x < 0.0 or n.x >= width or n.y < 0.0 or n.y >= height:
        possible_neighbors.remove_at(index)

Note that I'm iterating backwards to avoid re-indexing.

Now pick at random:

var neighbor := possible_neighbors[
    random.randi_range(0, possible_neighbors.size() - 1)
]

If it is a wall, carve it (I mean make it passable).

if maze[neighbor.y][neighbor.x] != wall:
    maze[neighbor.y][neighbor.x] = space # something like this?

If it was already passable, then check if there is a path from the source to it (if you are inside the bfs method you can check if paths has it as a key, if you are using astar ask it for a path to it). If there is a path, you are done.

Otherwise set walking to the neighbor and repeat. Until you find that there is a path.

Addendum: The random-walk is random™. Nothing prevents it from going over cells it already went over. So keeping a list of visited cells (similar to breath-first search) can allow you prevent that. Similarly, if you use a weighted random (look it up), you can make it more likely for it to pick a position that is closer to the source (by vector distance). And the closer is a cell to the source the more likely there is a path from to source to that cell (that, by the way, is an heuristic). This improvements will make the carving process go faster.


Be aware that I came up with this approach because:

  • I'm keeping the same vein of what you were doing.
  • I think there is plenty for you to learn from it.

However, you will likely get better results by picking an algorithm based on what you want for your game.

\$\endgroup\$
0

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .