Python Arena
Step 3 of 4

Pushing boxes

Now the boxes. A box on the floor and a box on its target look different, so the player can see which ones are done:

The image box.png
box.png, 40 × 40 pixels
The image box-done.png
box-done.png, 40 × 40 pixels

A list of boxes

Like the robot, the boxes move, so they don't go in the map. The game keeps a list of their places, which reset sets up: self.boxes = [(2, 2), (6, 4)]. Whether a square has a box on it is then a question for in:

In the Python interpreter
>>> boxes = [(2, 2), (6, 4)]
>>> (6, 4) in boxes
True
>>> (4, 6) in boxes
False

draw puts each box on top of its square's tile, right after drawing the tile. On a target square, it uses the picture of a finished box:

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))

The rules of pushing

When the robot steps onto a square with a box, the box moves one square further in the same direction. That works only if the square behind the box is free: no wall and no other box. If it isn't free, nothing moves at all, not the box and not the robot. So a robot can't push two boxes in a row.

"Free" is a question the game asks often, so it gets a small method of its own:

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

try_move gets a new part between the wall check and the robot's move:

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))

The box's place is a tuple, and a tuple can't be changed. So the old place is removed from the list and the new place added. Trying to change the tuple itself stops the program:

boxes = [(2, 2), (6, 4)]
box = boxes[0]
box[0] = 3
Python stops with
Traceback (most recent call last):
  File "main.py", line 3, in <module>
    box[0] = 3
    ~~~^^^
TypeError: 'tuple' object does not support item assignment

Code that says what it means

There are other ways to store this game. One is to give every kind of square a number and keep everything in a single grid of numbers: say 7 for a wall, 2 for a box and 6 for a box that's on its target. It can be shorter, but a line like if grid[y][x] == 7: means nothing unless you remember that 7 stands for a wall. Numbers like these, whose meaning lives only in the programmer's head, are called magic numbers, and they make code hard to read and easy to break.

This game uses characters that look like what they mean, # for a wall, and attributes with names, self.robot and self.boxes, for what moves. When working code is hard to read, it's worth rewriting it to be clearer without changing what it does. That's called refactoring.

Here's the game with boxes. Besides the two box pictures, it loads the pictures from before:

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

Push the box on the right onto the target above it:

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))
        pygame.display.set_caption("Box Robot")
        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)]

    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)

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

    def try_move(self, dx, dy):
        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)

    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))
        pygame.display.flip()


if __name__ == "__main__":
    game = BoxGame()
    game.run()
Box Robot360 × 280
The program's window after 95 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 box.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 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)
The window after 95 frames (After 10 frames: the right arrow is pressed and let go. After 18 frames: the up arrow is pressed and let go. After 26 frames: the right arrow is pressed and let go. After 34 frames: the right arrow is pressed and let go. After 42 frames: the down arrow is pressed and let go. After 50 frames: the down arrow is pressed and let go. After 58 frames: the down arrow is pressed and let go. After 66 frames: the right arrow is pressed and let go. After 74 frames: the up arrow is pressed and let go. After 82 frames: the up arrow is pressed and let go.).

Your task

Challenge 1 of 3

The boxes have gone missing from the window, although the robot can still push them. Fill in the blanks in draw, so that a box is drawn on every square in the list of boxes, with the finished box's picture on a target.

Challenge 2

Write the method is_free(x, y). It returns True when square (x, y) has no wall and no box on it, and False otherwise. try_move uses it to check the square behind a box, so as it is now, the robot can push boxes into walls and onto other boxes.

Challenge 3

The robot walks over the boxes as if they weren't there. Add the pushing to try_move: when the robot steps onto a box, the box moves one square further in the same direction, if that square is free. If it isn't free, neither the box nor the robot moves. The method is_free is ready to use.