Home page > Pygame Zero tutorials

Fifteen

A tutorial for Python and Pygame Zero 1.2

Download fifteen.py

Rules

There is a board with 15 pieces and an empty space. Move the pieces around until they are in sequential order by using the arrow keys to move pieces into the empty space.

Controls

Arrow keysMove piece

Overview

The pieces are stored as a grid of numbers.

The number 16 represents the empty space.

The other numbers are swapped with the empty space when an arrow key is pressed.

At the start of the game, the grid is initially in sorted order, and random moves are made to shuffle it. (If the piece positions were totally random instead, it could result in an unsolvable board.)

After a piece has been moved, the pieces are looped through, and if they all have their initial sorted values, then the game is over.

Coding

Drawing pieces

The pieces are drawn as squares.

For now, a piece is drawn where the empty space should be.

Full code at this point

def draw():
    screen.fill((0, 0, 0))

    for y in range(4):
        for x in range(4):
            piece_size = 100
            piece_draw_size = piece_size - 1

            screen.draw.filled_rect(
                Rect(
                    x * piece_size, y * piece_size,
                    piece_draw_size, piece_draw_size
                ),
                color=(100, 20, 150)
            )

Drawing numbers

The piece numbers are drawn on top of the pieces.

A piece number is calculated by adding the Y position (i.e. row number) multiplied by the number of pieces in a row to the X position plus 1.

For example, on the first row, the Y position is 0, so nothing is added to each X position, so the first number on the first row is 1. On the second row, 4 is added to each X position, so the first number on the second row is 5.

Full code at this point

def draw():
    screen.fill((0, 0, 0))

    for y in range(4):
        for x in range(4):
            # etc.

            screen.draw.text(
                str(y * 4 + x + 1),
                (x * piece_size, y * piece_size),
                fontsize=60
            )

Creating the grid

A grid is created with each piece's number stored at its position on the grid, and this number is drawn.

The number of pieces on the X and Y axes are reused from drawing the pieces, so they are made into variables.

Full code at this point

grid_x_count = 4
grid_y_count = 4

grid = []

for y in range(grid_y_count):
    grid.append([])
    for x in range(grid_x_count):
        grid[y].append(y * grid_x_count + x + 1)

def draw():
    screen.fill((0, 0, 0))

    for y in range(grid_y_count):
        for x in range(grid_x_count):
            # etc.

            screen.draw.text(
                str(grid[y][x]),
                (x * piece_size, y * piece_size),
                fontsize=60
            )

Not drawing the empty space

The number of pieces on each axis multiplied together gives the total number of pieces (i.e. 4 times 4 means 16 pieces), and a piece is drawn only if it isn't this number.

Full code at this point

def draw():
    screen.fill((0, 0, 0))

    for y in range(grid_y_count):
        for x in range(grid_x_count):
            if grid[y][x] == grid_x_count * grid_y_count:
                continue

            # etc.

Finding position of empty space

The first step in moving a piece is finding the position of the empty space.

When a key is pressed, the grid is looped through, and if a piece is equal to the number of pieces on each axis multiplied together (i.e. it's the empty space), then, for now, its position is printed.

Full code at this point

def on_key_down(key):
    for y in range(grid_y_count):
        for x in range(grid_x_count):
            if grid[y][x] == grid_x_count * grid_y_count:
                empty_x = x
                empty_y = y

    # Temporary
    print('empty x: ' + str(empty_x) + ', empty y: ' + str(empty_y))
empty x: 3, empty y: 3

Moving pieces down

If the Y position of the empty space is greater than 0, it means that there is a piece above the empty space, so moving a piece down is possible.

The empty space is changed to the piece number above the space, and the piece above the space is changed to the space number.

For now, any key moves a piece down.

Full code at this point

def on_key_down(key):
    # etc.

    if empty_y > 0:
        changed = (grid[empty_y][empty_x], grid[empty_y - 1][empty_x])
        grid[empty_y - 1][empty_x], grid[empty_y][empty_x] = changed

Moving pieces up

If the Y position of the empty space is less than number of rows of the grid, it means that there is a piece below the empty space, so moving the piece up is possible.

The Y position of the piece that the empty space swaps with is made into a variable. When the up key is pressed, it is set to the position below the empty space (i.e. plus 1 on the Y axis).

Full code at this point

def on_key_down(key):
    # etc.

    new_empty_y = empty_y

    if key == keys.DOWN:
        new_empty_y -= 1
    elif key == keys.UP:
        new_empty_y += 1

    if 0 <= new_empty_y < grid_y_count:
        changed = (grid[empty_y][empty_x], grid[new_empty_y][empty_x])
        grid[new_empty_y][empty_x], grid[empty_y][empty_x] = changed

Moving pieces left and right

The X position of the piece that the empty space swaps with is made into a variable, and it is changed when the left or right arrow is pressed.

Full code at this point

def on_key_down(key):
    # etc.

    new_empty_y = empty_y
    new_empty_x = empty_x

    if key == keys.DOWN:
        new_empty_y -= 1
    elif key == keys.UP:
        new_empty_y += 1
    elif key == keys.RIGHT:
        new_empty_x -= 1
    elif key == keys.LEFT:
        new_empty_x += 1

    if (
        0 <= new_empty_y < grid_y_count and
        0 <= new_empty_x < grid_x_count
    ):
        changed = (grid[empty_y][empty_x], grid[new_empty_y][new_empty_x])
        grid[new_empty_y][new_empty_x], grid[empty_y][empty_x] = changed

Shuffling

At the beginning of the game, a number of random moves are made to shuffle the board.

A random number between 1 and 4 is generated and a move is made in one of the four movement directions based on this number.

The random module is imported so that random.randint can be used.

Full code at this point

import random

# etc.

for move_number in range(1000):
    for y in range(grid_y_count):
        for x in range(grid_x_count):
            if grid[y][x] == grid_x_count * grid_y_count:
                empty_x = x
                empty_y = y

    new_empty_y = empty_y
    new_empty_x = empty_x

    roll = random.randint(0, 3)
    if roll == 0:
        new_empty_y -= 1
    elif roll == 1:
        new_empty_y += 1
    elif roll == 2:
        new_empty_x -= 1
    elif roll == 3:
        new_empty_x += 1

    if (
        0 <= new_empty_y < grid_y_count and
        0 <= new_empty_x < grid_x_count
    ):
        changed = (grid[empty_y][empty_x], grid[new_empty_y][new_empty_x])
        grid[new_empty_y][new_empty_x], grid[empty_y][empty_x] = changed

Simplifying code

The only difference between the shuffling code and the keyboard controlled code is how the direction of the move is determined, so a function is made with the direction as a parameter.

Full code at this point

def move(direction):
    for y in range(grid_y_count):
        for x in range(grid_x_count):
            if grid[y][x] == grid_x_count * grid_y_count:
                empty_x = x
                empty_y = y

    new_empty_y = empty_y
    new_empty_x = empty_x

    if direction == 'down':
        new_empty_y -= 1
    elif direction == 'up':
        new_empty_y += 1
    elif direction == 'right':
        new_empty_x -= 1
    elif direction == 'left':
        new_empty_x += 1

    if (
        0 <= new_empty_y < grid_y_count and
        0 <= new_empty_x < grid_x_count
    ):
        changed = (grid[empty_y][empty_x], grid[new_empty_y][new_empty_x])
        grid[new_empty_y][new_empty_x], grid[empty_y][empty_x] = changed

for move_number in range(1000):
    move(random.choice(('down', 'up', 'right', 'left')))

def on_key_down(key):
    if key == keys.DOWN:
        move('down')
    elif key == keys.UP:
        move('up')
    elif key == keys.RIGHT:
        move('right')
    elif key == keys.LEFT:
        move('left')

Making the bottom-right position empty

So that the empty space always starts in the bottom-right corner, the pieces are moved left and up repeatedly. The number of pieces on an axis minus 1 is the maximum number of moves it would take to move the space from one side to the other.

Full code at this point

for move_number in range(1000):
    move(random.choice(('down', 'up', 'right', 'left')))

for move_number in range(grid_x_count - 1):
    move('left')

for move_number in range(grid_y_count - 1):
    move('up')

Resetting the game

A function is made which sets the initial state of the game.

This function is called before the game begins and when the r key is pressed.

Full code at this point

import random

grid_x_count = 4
grid_y_count = 4

def move(direction):
    # etc.

def reset():
    global grid

    grid = []

    for y in range(grid_y_count):
        grid.append([])
        for x in range(grid_x_count):
            grid[y].append(y * grid_x_count + x + 1)

    for move_number in range(1000):
        move(random.choice(('down', 'up', 'right', 'left')))

    for move_number in range(grid_x_count - 1):
        move('left')

    for move_number in range(grid_y_count - 1):
        move('up')

reset()

def on_key_down(key):
    # etc.

    elif key == keys.R:
        reset()

Check if complete

After a move is made, the pieces are looped through, and if none of the pieces are not equal to the number they were initially given (i.e. they are all in their sorted positions), then the game is reset.

Full code at this point

def on_key_down(key):
    # etc.

    complete = True

    for y in range(grid_y_count):
        for x in range(grid_x_count):
            if grid[y][x] != y * grid_x_count + x + 1:
                complete = False

    if complete:
        reset()

Simplifying code

The code for calculating the initial value of a piece is reused, so it is made into a function.

Full code at this point

def get_initial_value(x, y):
    return y * grid_x_count + x + 1

def reset():
    global grid

    grid = []

    for y in range(grid_y_count):
        grid.append([])
        for x in range(grid_x_count):
            grid[y].append(get_initial_value(x, y))

    # etc.

def on_key_down(key):
    # etc.

    for y in range(grid_y_count):
        for x in range(grid_x_count):
            if grid[y][x] != get_initial_value(x, y):
                complete = False

    # etc.

Reshuffle if complete after shuffling

If the pieces are still in their initial order after shuffling, the shuffling process happens again.

The code for checking if the pieces are in their initial order is reused, so it is made into a function.

Full code at this point

def is_complete():
    for y in range(grid_y_count):
        for x in range(grid_x_count):
            if grid[y][x] != get_initial_value(x, y):
                return False

    return True

def reset():
    global grid

    grid = []

    for y in range(grid_y_count):
        grid.append([])
        for x in range(grid_x_count):
            grid[y].append(get_initial_value(x, y))

    while True:
        for move_number in range(1000):
            move(random.choice(('down', 'up', 'right', 'left')))

        for move_number in range(grid_x_count - 1):
            move('left')

        for move_number in range(grid_y_count - 1):
            move('up')

        if not is_complete():
            break

reset()

def on_key_down(key):
    if key == keys.DOWN:
        move('down')
    elif key == keys.UP:
        move('up')
    elif key == keys.RIGHT:
        move('right')
    elif key == keys.LEFT:
        move('left')
    elif key == keys.R:
        reset()

    if is_complete():
        reset()