Python Arena
Step 4 of 4

Finishing the game

The puzzle can be played now, but it isn't a finished game yet. This step adds the finishing touches: a counter of moves, keys for starting over and quitting, and a message when the puzzle is solved.

A bar for text

The text goes in a bar under the map, so the window gets one row of tiles taller, and __init__ makes a font for the text:

self.window = pygame.display.set_mode((self.width * SIZE, self.height * SIZE + SIZE))
self.font = pygame.font.SysFont("Arial", 24)

Make the font once, in __init__, and not in draw: making a font takes time, and draw runs on every frame.

Counting moves

reset sets self.moves = 0, and try_move adds one at its very end, after all the checks. A bump into a wall returns before it gets there, so it isn't counted. A new method, draw_bar, writes the count and the keys in the bar:

def draw_bar(self):
    top = self.height * SIZE + 12
    text = self.font.render(f"Moves: {self.moves}", True, (255, 255, 255))
    self.window.blit(text, (10, top))

A puzzle player wants to know the count: the same puzzle can be solved in many ways, and the fewer moves, the better.

Starting over and quitting

Pushing a box into a corner ruins the game, so the player needs a way to start over. The R key calls reset, which puts the robot, the boxes and the counter back as they were at the start. The Esc key quits, just like closing the window:

if event.key == pygame.K_r:
    self.reset()
if event.key == pygame.K_ESCAPE:
    exit()

This works because reset makes a brand new list of boxes every time. If the starting places were in one list kept somewhere else, and reset said self.boxes = START_BOXES, both names would refer to the same list. Every push would change the starting places too, and R would bring back the boxes where they were last, not where they started. list(START_BOXES) would make a copy.

Solved!

The puzzle is solved when every box is on a target. A method answers the question by going through the boxes. The first box that isn't on a target settles it, so the method can return False right there. The answer is True only when the loop has checked them all:

def solved(self):
    for x, y in self.boxes:
        if self.map[y][x] != "X":
            return False
    return True

When the puzzle is solved, draw_bar shows a message instead of the count, and try_move starts with if self.solved(): return, so the robot stays still. The player can still look at the finished puzzle, and press R to play again.

Testing the ending

To test the ending, you'd have to solve the whole puzzle every time you change something. While you work on the ending, make it easy: a test level where one push solves it, or, for a moment, a solved that just says return True. Remember to put things back when the ending works.

Here's the finished game, with all six of its pictures:

The image floor.png
floor.png, 40 × 40 pixels
The image wall.png
wall.png, 40 × 40 pixels
The image target.png
target.png, 40 × 40 pixels
The image robot.png
robot.png, 40 × 40 pixels
The image box.png
box.png, 40 × 40 pixels
The image box-done.png
box-done.png, 40 × 40 pixels

The window below shows the puzzle solved in 16 moves. That's the fewest possible. Can you find another way to do it in 16?

import pygame

SIZE = 40
LEVEL = [
    "#########",
    "#...#...#",
    "#.....X.#",
    "#...#...#",
    "#..X#...#",
    "#.......#",
    "#########",
]


class BoxGame:
    def __init__(self):
        pygame.init()
        self.load_pictures()
        self.map = LEVEL
        self.height = len(self.map)
        self.width = len(self.map[0])
        self.window = pygame.display.set_mode((self.width * SIZE, self.height * SIZE + SIZE))
        pygame.display.set_caption("Box Robot")
        self.font = pygame.font.SysFont("Arial", 24)
        self.clock = pygame.time.Clock()
        self.reset()

    def load_pictures(self):
        self.tiles = {
            "#": pygame.image.load("wall.png"),
            ".": pygame.image.load("floor.png"),
            "X": pygame.image.load("target.png"),
        }
        self.robot_picture = pygame.image.load("robot.png")
        self.box_picture = pygame.image.load("box.png")
        self.done_picture = pygame.image.load("box-done.png")

    def reset(self):
        self.robot = (2, 3)
        self.boxes = [(2, 2), (6, 4)]
        self.moves = 0

    def run(self):
        while True:
            self.handle_events()
            self.draw()
            self.clock.tick(60)

    def handle_events(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                exit()
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_LEFT:
                    self.try_move(-1, 0)
                if event.key == pygame.K_RIGHT:
                    self.try_move(1, 0)
                if event.key == pygame.K_UP:
                    self.try_move(0, -1)
                if event.key == pygame.K_DOWN:
                    self.try_move(0, 1)
                if event.key == pygame.K_r:
                    self.reset()
                if event.key == pygame.K_ESCAPE:
                    exit()

    def is_free(self, x, y):
        return self.map[y][x] != "#" and (x, y) not in self.boxes

    def try_move(self, dx, dy):
        if self.solved():
            return
        x, y = self.robot
        new_x = x + dx
        new_y = y + dy
        if self.map[new_y][new_x] == "#":
            return
        if (new_x, new_y) in self.boxes:
            box_x = new_x + dx
            box_y = new_y + dy
            if not self.is_free(box_x, box_y):
                return
            self.boxes.remove((new_x, new_y))
            self.boxes.append((box_x, box_y))
        self.robot = (new_x, new_y)
        self.moves += 1

    def solved(self):
        for x, y in self.boxes:
            if self.map[y][x] != "X":
                return False
        return True

    def draw(self):
        self.window.fill((0, 0, 0))
        for y in range(self.height):
            for x in range(self.width):
                square = self.map[y][x]
                self.window.blit(self.tiles[square], (x * SIZE, y * SIZE))
                if (x, y) in self.boxes:
                    if square == "X":
                        self.window.blit(self.done_picture, (x * SIZE, y * SIZE))
                    else:
                        self.window.blit(self.box_picture, (x * SIZE, y * SIZE))
        robot_x, robot_y = self.robot
        self.window.blit(self.robot_picture, (robot_x * SIZE, robot_y * SIZE))
        self.draw_bar()
        pygame.display.flip()

    def draw_bar(self):
        top = self.height * SIZE + 12
        if self.solved():
            text = self.font.render(f"Solved in {self.moves} moves!", True, (120, 230, 120))
        else:
            text = self.font.render(f"Moves: {self.moves}", True, (255, 255, 255))
        self.window.blit(text, (10, top))
        keys = self.font.render("R: restart  Esc: quit", True, (160, 160, 160))
        self.window.blit(keys, (196, top))


if __name__ == "__main__":
    game = BoxGame()
    game.run()
Box Robot360 × 320
The program's window after 110 frames
The window as text
fill (0, 0, 0)
blit wall.png at (0, 0)
blit wall.png at (40, 0)
blit wall.png at (80, 0)
blit wall.png at (120, 0)
blit wall.png at (160, 0)
blit wall.png at (200, 0)
blit wall.png at (240, 0)
blit wall.png at (280, 0)
blit wall.png at (320, 0)
blit wall.png at (0, 40)
blit floor.png at (40, 40)
blit floor.png at (80, 40)
blit floor.png at (120, 40)
blit wall.png at (160, 40)
blit floor.png at (200, 40)
blit floor.png at (240, 40)
blit floor.png at (280, 40)
blit wall.png at (320, 40)
blit wall.png at (0, 80)
blit floor.png at (40, 80)
blit floor.png at (80, 80)
blit floor.png at (120, 80)
blit floor.png at (160, 80)
blit floor.png at (200, 80)
blit target.png at (240, 80)
blit box-done.png at (240, 80)
blit floor.png at (280, 80)
blit wall.png at (320, 80)
blit wall.png at (0, 120)
blit floor.png at (40, 120)
blit floor.png at (80, 120)
blit floor.png at (120, 120)
blit wall.png at (160, 120)
blit floor.png at (200, 120)
blit floor.png at (240, 120)
blit floor.png at (280, 120)
blit wall.png at (320, 120)
blit wall.png at (0, 160)
blit floor.png at (40, 160)
blit floor.png at (80, 160)
blit target.png at (120, 160)
blit box-done.png at (120, 160)
blit wall.png at (160, 160)
blit floor.png at (200, 160)
blit floor.png at (240, 160)
blit floor.png at (280, 160)
blit wall.png at (320, 160)
blit wall.png at (0, 200)
blit floor.png at (40, 200)
blit floor.png at (80, 200)
blit floor.png at (120, 200)
blit floor.png at (160, 200)
blit floor.png at (200, 200)
blit floor.png at (240, 200)
blit floor.png at (280, 200)
blit wall.png at (320, 200)
blit wall.png at (0, 240)
blit wall.png at (40, 240)
blit wall.png at (80, 240)
blit wall.png at (120, 240)
blit wall.png at (160, 240)
blit wall.png at (200, 240)
blit wall.png at (240, 240)
blit wall.png at (280, 240)
blit wall.png at (320, 240)
blit robot.png at (240, 120)
blit text "Solved in 16 moves!" at (10, 292)
blit text "R: restart  Esc: quit" at (196, 292)
The window after 110 frames (After 10 frames: the left arrow is pressed and let go. After 16 frames: the up arrow is pressed and let go. After 22 frames: the right arrow is pressed and let go. After 28 frames: the up arrow is pressed and let go. After 34 frames: the right arrow is pressed and let go. After 40 frames: the down arrow is pressed and let go. After 46 frames: the down arrow is pressed and let go. After 52 frames: the up arrow is pressed and let go. After 58 frames: the right arrow is pressed and let go. After 64 frames: the right arrow is pressed and let go. After 70 frames: the down arrow is pressed and let go. After 76 frames: the down arrow is pressed and let go. After 82 frames: the down arrow is pressed and let go. After 88 frames: the right arrow is pressed and let go. After 94 frames: the up arrow is pressed and let go. After 100 frames: the up arrow is pressed and let go.).

Your task

Challenge 1 of 3

The moves aren't counted yet. Fill in the blanks: the counter starts at zero, grows by one with every move the robot makes, and its value is shown in the bar.

Challenge 2

The bar promises that R restarts the game and Esc quits, but neither key does anything yet. Make them work in handle_events: R puts everything back as it was at the start, and Esc ends the program, as closing the window does.

Challenge 3

Write the method solved(). It returns True when every box is on a target square, and False otherwise. The rest of the game uses it already: once it works, the bar shows a message when the puzzle is solved, and the robot stops.