7
\$\begingroup\$

Why I made it

I watched this video by veritasium about Micro-Mouse maze-solving competitions, and found the logic to get to the center fascinating. Hence, despite not having a maze or a mouse, I spent a few hours coding my own interpretation of the logic used to get to the center.

Context

According to Wikipedia;

Micromouse is an event where small robotic mice compete to solve a 16×16 maze.

The rules are simple: A small robot mouse has to get to the center as fast as possible, while not knowing more than what it sees from an inside view along with where the maze's center is (relative to itself). My code respects these rules.

How it works

To decide in which direction it should go in, the mouse follows these steps:

  1. Get possibilities from where you are at (ex: UDLR)

  2. Ignore where you came from (ex: ULR)

  3. If no options are left, go where you came from (dead end)

  4. If one option left, go in that direction (continue)

  5. If you are at an intersection you already crossed (see step 9), ignore the directions associated to it.

  6. If one option left, go there and append that direction to the list (last branch to explore)

  7. If no options left, you already tried all directions from where you are, so go back in the direction you initially came from the first time you went to that intersection.

  8. Apply the priorities to decide where to go (ex: R)

  9. Associate where you came from and where you will go with the current intersection's position to do the right choice if ever you are to reach that same intersection again

  10. Change MouseX and MouseY accordingly

The priorities are as follow:

  1. If you can go towards the middle (X), do so
  2. Else if you can go towards the middle (Y), do so
  3. Or else chose any path (other than the one you took to come)

Code

Here is the python code:


"""
Maze solver based off some of the best Micro-Mouse competition techniques.

A maze is randomly generated, a mouse starts in the top left corner, and it finds its way
to reach the center basing itself only on where the center is relative to himself and in
which directions can it go in from where it currently is.

The techniques this script is inspired by are the ones that follow:
1. Djikstra best path (direction in this case) to center
2. Flood-fill (for going through each branch before returning to the trunk)
3. Ultimately, Theseus (for some trial and error)
"""

import time
import random

size = 17
# This is experimental, current logic struggles with open spaces, keep low
freewalls = 0


def clear_screen():
    """Clear screen and return to first line using an ASCII escape sequence"""
    print("\x1b[1;1H", end="")


def generate_maze():
    """Randomly generates a solvable maze"""
    maze = [["\u25fc" for _ in range(size)] for _ in range(size)]

    def carve(x, y):
        maze[x][y] = " "
        dirs = [(2, 0), (-2, 0), (0, 2), (0, -2)]
        random.shuffle(dirs)
        for dx, dy in dirs:
            nx, ny = x + dx, y + dy
            if 0 <= nx < size and 0 <= ny < size and maze[nx][ny] == "\u25fc":
                maze[x + dx // 2][y + dy // 2] = " "
                carve(nx, ny)

    carve(0, 0)  # ensure where the mouse starts at is open

    # Creating some freestanding walls
    for _ in range(freewalls):
        freewallX = 0
        freewallY = 0
        while maze[freewallX][freewallY] == " ":
            freewallX = random.randrange(size)
            freewallY = random.randrange(size)
        maze[freewallX][freewallY] = " "

    maze[int((size - 1) / 2)][int((size - 1) / 2)] = " "  # ensure center is open
    return maze


displaygrid = generate_maze()

logicgrid = [row[:] for row in displaygrid]

MouseX = 0
MouseY = 0

width = len(displaygrid[0])

intersections = {}
camefrom = ""


def possible_movements(x, y):
    """Get possibilities for in which direction the mouse can go in"""
    return logicgrid[y][x]


def displayed_character(x, y):
    """Get displayed character on a given position"""
    if x < width and x >= 0 and y < width and y >= 0:
        return displaygrid[y][x]
    else:
        return None


def set_logic(x, y, setto):
    """Set the possible directions the mouse can move in from a given position"""
    logicgrid[y][x] = setto


def remove_logic(x, y, removethis):
    """Remove a direction from the possible directions the mouse can go in from a given position"""
    logicgrid[y][x] = logicgrid[y][x].replace(removethis, "")


def display_check(x, y):
    """Check if the given position has the mouse or the goal before final printed result"""
    if [x, y] == [MouseX, MouseY]:
        return "M"
    elif x == y == (width - 1) / 2:
        return "X"
    else:
        return displayed_character(x, y)


def add_intersection(x, y, camefrom):
    """Add a direction to the list of directions to which the mouse has gone in from a certain position"""
    intersections.setdefault((x, y), []).append(camefrom)


def recall_intersection(x, y):
    """Return all directions the mouse already went in from the intersection it is at"""
    if intersections.get((x, y)):
        return [_ for _ in intersections.get((x, y)) if _ is not None]
    else:
        return None


def next_move(directions, camefrom, middle):
    """Choose in which direction to go in next"""
    possibilities = directions.replace(camefrom, "")

    if len(possibilities) == 0:  # It reached a dead end
        return camefrom

    if len(possibilities) > 1 and recall_intersection(
        MouseX, MouseY
    ):  # It has been there before, ignore all branches it has already explored
        for branch in recall_intersection(MouseX, MouseY):
            possibilities = possibilities.replace(branch, "")

    if (
        len(possibilities) == 0
    ):  # If all branches from where it is have been explored, go back to where you initially came from
        return recall_intersection(MouseX, MouseY)[0]

    if len(possibilities) == 1:  # If there is only one branch to explore left
        return possibilities

    add_intersection(MouseX, MouseY, camefrom)
    movechoice = None
    if MouseX < middle and "R" in possibilities:
        movechoice = "R"
    elif MouseX > middle and "L" in possibilities:
        movechoice = "L"
    elif MouseY < middle and "D" in possibilities:
        movechoice = "D"
    elif MouseY > middle and "U" in possibilities:
        movechoice = "U"
    else:  # Take any path if they all dont go closer to the center. Occurs more often as width increases.
        movechoice = possibilities[0]

    add_intersection(MouseX, MouseY, movechoice)
    return movechoice


def update_logic():
    """Set all the directions in which the mouse can go in per the displayed maze"""
    for y, row in enumerate(logicgrid):
        for x, cell in enumerate(logicgrid):
            if displayed_character(x, y) == " ":
                set_logic(x, y, "UDLR")

                # Top and bottom
                if y == 0:
                    remove_logic(x, y, "U")
                if y == width - 1:
                    remove_logic(x, y, "D")

                # Sides
                if x == 0:
                    remove_logic(x, y, "L")
                if x == width - 1:
                    remove_logic(x, y, "R")

                # Corners
                if x == 0 and y == 0:
                    set_logic(0, 0, "RD")
                if x == 0 and y == width - 1:
                    set_logic(x, y, "UR")
                if x == width - 1 and y == 0:
                    set_logic(x, y, "LD")
                if x == width - 1 and y == width - 1:
                    set_logic(x, y, "UL")

                # Non-walls
                if displayed_character(x + 1, y) != " ":
                    remove_logic(x, y, "R")
                if displayed_character(x - 1, y) != " ":
                    remove_logic(x, y, "L")
                if displayed_character(x, y + 1) != " ":
                    remove_logic(x, y, "D")
                if displayed_character(x, y - 1) != " ":
                    remove_logic(x, y, "U")
            else:
                set_logic(x, y, "WALL")


def update_display():
    """Print the maze"""

    # Frame top
    print("\u2588" * (2 * width + 1))

    # Frame sides and content
    for y, row in enumerate(displaygrid):
        line = ["\u2588"]
        for x, cell in enumerate(row):
            line.append(f"{display_check(x, y)} ")
        line[-1] = line[-1][:-1]  # remove the last space
        line.append("\u2588")
        print("".join(line))

    # Frame bottom
    print("\u2588" * (2 * width + 1))


if __name__ == "__main__":
    update_logic()
    update_display()
    print("\033[2J\033[H", end="")
    middle = (width - 1) / 2
    movenumber = 0
    while MouseX != middle or MouseY != middle:
        choices = possible_movements(MouseX, MouseY)
        goto = next_move(choices, camefrom, middle)
        if goto == "U":
            MouseY -= 1
            camefrom = "D"
        if goto == "D":
            MouseY += 1
            camefrom = "U"
        if goto == "L":
            MouseX -= 1
            camefrom = "R"
        if goto == "R":
            MouseX += 1
            camefrom = "L"
        time.sleep(0.1)
        clear_screen()
        update_display()
        movenumber += 1
    print(f"Done in {movenumber} steps")

Preview

Sample run

Please tell me everything that comes to mind.

\$\endgroup\$

3 Answers 3

3
\$\begingroup\$

You're doing many things right. Kudos.

Seeing enumerate rather than looping over indices alone is worthy of praise for what seems like a fairly early effort.

Global state

Global variables is one of the things you shouldn't be doing. Rather, your game should probably be a class which encapsulates that state. Then you can create multiple instances of it.

As you've already worked toward reusability with the __main__ guard, this would further that goal.

Match

On top of toolic's suggestions about enums, I'd suggest you leverage pattern-matching.

An example with otherwise minimal changes to your code:

    while MouseX != middle or MouseY != middle:
        choices = possible_movements(MouseX, MouseY)
        match next_move(choices, camefrom, middle):
            case "U":
                MouseY -= 1
                camefrom = "D"
            case "D":
                MouseY += 1
                camefrom = "U"
            case "L":
                MouseX -= 1
                camefrom = "R"
            case "R":
                MouseX += 1
                camefrom = "L"

Testing for empty

This:

    if len(possibilities) == 0:  # It reached a dead end
        return camefrom

Is simpler as:

    if possibilities == []:  # It reached a dead end
        return camefrom

Or knowing that an empty list in Python is false:

    if not possibilities:  # It reached a dead end
        return camefrom

The same can be applied to dictionaries and sets.

Removing the last element of a list

This:

        line[-1] = line[-1][:-1]  # remove the last space

I simpler as:

        line[-1].pop()  # remove the last space

Though if the list is empty already, popping from it will raise an IndexError exception that your code will not.

Default return value

Be aware that if no explicit return is encountered, a Python function returns None.

This:

def displayed_character(x, y):
    """Get displayed character on a given position"""
    if x < width and x >= 0 and y < width and y >= 0:
        return displaygrid[y][x]
    else:
        return None

Can be reduced to:

def displayed_character(x, y):
    """Get displayed character on a given position"""
    if x < width and x >= 0 and y < width and y >= 0:
        return displaygrid[y][x]

Your docstring

"""
Maze solver based off some of the best Micro-Mouse competition techniques.

A maze is randomly generated, a mouse starts in the top left corner, and it finds its way
to reach the center basing itself only on where the center is relative to himself and in
which directions can it go in from where it currently is.

The techniques this script is inspired by are the ones that follow:
1. Djikstra best path (direction in this case) to center
2. Flood-fill (for going through each branch before returning to the trunk)
3. Ultimately, Theseus (for some trial and error)
"""

Neat!

How does the following code specifically relate to these techniques? No further documentation/comments give me any indication of where these techniques are employed.

Right now it feels like the program's docstring makes promises the rest of the code doesn't deliver.

Formatting

This is some really odd formatting.

    if (
        len(possibilities) == 0
    ):  # If all branches from where it is have been explored, go back to where you initially came from
        return recall_intersection(MouseX, MouseY)[0]

It appears to be based on adding comments to the end of lines. I'd suggest you put comments on the line before as a convention and then this becomes:

# If all branches from where it is have been explored, 
# go back to where you initially came from
if not possibilities:
    return recall_intersection(MouseX, MouseY)[0]

I know which one I'd rather read.

\$\endgroup\$
4
  • \$\begingroup\$ Sorry for the formatting, I used ruff and must have not spotted that part. \$\endgroup\$ Commented 15 hours ago
  • \$\begingroup\$ A dummed-down version of some of Djikstra's pathmaking logic is used for making decisions at intersections based on the fact that probability-wise if a direction brings it closer to the center it should go in that direction, and flood-fill along with Theseus for how it explores each branch taking notes of things like which branches were already explored. \$\endgroup\$ Commented 12 hours ago
  • \$\begingroup\$ By dummed-down, I mean it lacks the logic for it to know a bad direction to go in based off all the walls it has seen. In other words, sometimes, the walls it knows about are blocking off any possibility for getting to the center from that direction. Once again, my code does not have this logic. \$\endgroup\$ Commented 12 hours ago
  • 1
    \$\begingroup\$ I don't doubt you, but the code should probably explain that. \$\endgroup\$ Commented 12 hours ago
3
\$\begingroup\$

Good job, using only built-in libs.

I am on Linux but I believe the escape sequences should work on Windows too. Otherwise there should be a guard if the host OS may not support those escape sequences.

I suggest adding a few constants for those escape sequences, to make the code more expressive. For example, \u25fc draws some sort of square, whereas \u2588 is more like a rectangle.

it would make sense to use a dedicated object for coordinates, as seen in a recent question. A namedtuple is convenient and can reduce bugs stemming from incorrect parameter order eg:

from collections import namedtuple
Point = namedtuple('Point', 'x y', defaults=[0, 0])

start_position = Point(x=0, y=0)

(Thinking aloud) To generate the 2-dimensional grid, we could perhaps use itertools.product or related function.

Misc: movenumber can be incremented automatically using the enumerate function.

I have not checked the algo in depth.

\$\endgroup\$
2
\$\begingroup\$

The maze looks great!

Naming

The PEP 8 style guide recommends snake_case for function and variable names. For example:

displaygrid would be display_grid

logicgrid would be logic_grid

PEP 8 also recommends all caps for constants. For example, size would be SIZE. Consider giving that constant a more specific name as well.

Magic numbers

Well, the control codes are not regular numbers, but they are constants which are used multiple times. For example, it would be helpful to assign "\u2588" to a named constant.

Simpler

In the main code, the 4 if statements should be combined into a single if/elif:

if goto == "U":

if goto == "D":

# etc.

The checks are mutually exclusive. This makes the code more efficient since you don't have to perform the 2nd check if the first is true. Also, this more clearly shows the intent of the code.

Documentation

It is great that you have docstrings for your functions. It would also be good to describe the input and return types.

Also consider using type hints to describe input and return types to make the code more self-documenting.

Enum

Since the goto variable is limited to a certain set of values, it would be worth considering using an enumerated type (Enum). This also clearly documents what states are available. The same may be true for other variables as well.

\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.