The Robot Kata - Part 2

In this exercise we are going to solve a variation of the robot kata. This version is called the Lost Robot kata.

The Robot Kata - Part 2

In this exercise we are going to solve a variation of the robot kata. This version is called the Lost Robot kata.

As in the original kata, we have a robot that can move forward and turn left and right. But this time, the area that the robot can travel goes from -3 to 3 in the X direction and -3 to 3 in the Y direction. As long as the robot is within this square things are fine. If the robot leaves this square, then it is considered lost and it does not respond to any more commands.

As before, given a series of commands, we need to display the final position of the robot, or if it is lost then display the message "Robot Lost"

Solving the kata

The first thing we need to decide is how we want to store the information that the robot is lost. We could make the robot state to None (the most common way) or have a flag which says that the robot is lost. Both these solutions are ugly because we will need to add null checks to every function before doing the calculation.

Instead we will take inspiration from the previous article and wrap the robot state with a Maybe monad. If the robot has a valid state then it will be Just(state) and if the robot is lost then it will be Nothing.

We are reuse the implementation of move, left and right from the previous kata. Here is the code for reference

def move(robot: RobotState) -> RobotState:
    (x, y), dir = robot
    match dir:
        case "North":
            return make_robot((x, y + 1), dir)
        case "South":
            return make_robot((x, y - 1), dir)
        case "East":
            return make_robot((x + 1, y), dir)
        case "West":
            return make_robot((x - 1, y), dir)

def left(robot: RobotState) -> RobotState:
    pos, dir = robot
    match dir:
        case "North":
            return make_robot(pos, "West")
        case "South":
            return make_robot(pos, "East")
        case "East":
            return make_robot(pos, "North")
        case "West":
            return make_robot(pos, "South")

def right(robot: RobotState) -> RobotState:
    pos, dir = robot
    match dir:
        case "North":
            return make_robot(pos, "East")
        case "South":
            return make_robot(pos, "West")
        case "East":
            return make_robot(pos, "South")
        case "West":
            return make_robot(pos, "North")

Now we write a function that will check if the robot state is within bounds. If it is, then we wrap the result in a Just otherwise return Nothing (see the previous article on monads for the implementation of these two classes)

def check_position(robot: RobotState):
    match robot:
        case ((x, y), _) if -3 <= x <= 3 and -3 <= y <= 3:
            return Just(robot)
        case _:
            return Nothing()

This check should be performed after doing a move, so we will compose this to the end of the move

move = compose(move, check_position)

Executing the move function will now run the original move calculation followed by the check. This composed move function takes a RobotState type as input and returns a Maybe monad as output.

left and right functions cannot cause the robot to get lost, so they remain as they were before.

Thats pretty much it. Yes, really, the kata is solved with this one function. We don't need to touch any of the existing code from the previous kata.

To compose a sequence of operations, we use map and flatmap.

start = make_robot((0, 0), "North")
end = (move(start)
        .map(left)
        .flatmap(move)
        .flatmap(move)
        .map(left)
        .flatmap(move)
        .flatmap(move)
        .flatmap(move)
        .map(right)
        .map(right)
        .map(right)
        .flatmap(move))

match end:
    case Just():
        print(f"Robot is at {end.val}") # Robot is at ((-1, -2), 'East')
    case Nothing():
        print("Robot lost")

left and right are ordinary functions so we use map to compose them. move returns a Maybe so it requires flatmap for composition.

And if the robot were to get lost

start = make_robot((0, 0), "North")
end = (move(start)
        .map(left)
        .flatmap(move)
        .flatmap(move)
        .flatmap(move)
        .flatmap(move) # robot gets lost here
        .map(left)
        .flatmap(move)
        .flatmap(move)
        .flatmap(move)
        .map(right)
        .map(right)
        .map(right)
        .flatmap(move))

match end:
    case Just():
        print(f"Robot is at {end.val}")
    case Nothing():
        print("Robot lost") # Robot Lost

Notice how the robot gets lost somewhere in the middle, but we don't need to add any check. We just compose the entire sequence that we want to execute and at the end we will get to know if the whole sequence failed or succeeded (and if success, what is the output).

The job of keeping track of the execution state is handled by the monad, leaving our code extremely clean and readable.