Building Pop Drop

Play it first → Turn the launcher with the left and right arrows and fire with space. The piece bounces off the walls and sticks where it lands. Three or more of a kind touching pop, and anything left with nothing holding it up falls off. Miss too often and the ceiling comes down a row; let a piece past the red line and the game is over. It's a game in the style of the classic Snood.

This is how it was built, a step at a time. Every numbered step below is a whole program: copy it into the playground, press Run, and it works. Each step is the one before it with something added, so you can build the game the way it was built. Then break something on purpose and see what changes. That teaches you more than reading does.

There is nothing clever in it. No list comprehensions, no tricks, and every kaypy function it uses is on the API page.

The one big idea: a honeycomb is a list of lists

Like Block Slide, this game has no game objects. The board is a list of rows, and each row is a list of spots. A spot holds the name of a piece, or None if it's empty:

grid[0] = ["apple", "bean", "bean", "egg", "coin", "apple", "egg", "bean"]
grid[1] = ["coin", "egg", None, "apple", "bean", "coin", "egg", None]

grid[1][2] is row 1, spot 2. Row 0 is the top. Every frame the board is drawn fresh from the list, so popping a piece is just putting None in its spot.

What makes it a honeycomb is that every odd row is pushed half a piece to the right, so its pieces sit in the dips between the pieces above and below:

row 0:   A B C D E F G H
row 1:    a b c d e f g
row 2:   A B C D E F G H

That costs an odd row one spot (it would stick out past the wall), so odd rows have 7 spots and even rows 8. And because the rows nest into each other, they're closer together than a piece is tall: 42 pixels apart for pieces 48 pixels across.

Two questions decide the whole game, and both are answered by walking around this list. Which pieces of the same kind touch the one that just landed? Those pop. Which pieces are still joined to the ceiling? The rest fall. Both are a flood fill, which step 4 builds.

Step 1 — the honeycomb

from kaypy import *

COLS = 8           # spots across the even rows (the odd rows have one less)
CELL = 48          # a piece is 48 pixels across
ROW_H = 42         # rows overlap, like a honeycomb, so they are closer than 48
ROWS = 12          # rows in the board

BOARD_X = 28                 # the left wall
BOARD_Y = 100                # the top of the ceiling
BOARD_W = COLS * CELL

# Tall, to fit a phone held upright. The empty space at the bottom is where
# the launcher goes in step 2, and the controls for a phone in the last step.
kaypy(width=BOARD_W + 2 * BOARD_X, height=940, background=[24, 22, 40])

loadSprite("apple", "images/apple.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("egg", "images/egg.png")

# Each kind of piece: the colour of the circle behind it, and the size the
# picture is drawn at. The pictures are not all the same shape, so each one
# gets its own width and height.
PIECES = {
    "apple": {"colour": (150, 45, 60), "w": 31, "h": 36},
    "bean": {"colour": (35, 120, 85), "w": 38, "h": 32},
    "coin": {"colour": (190, 110, 30), "w": 26, "h": 32},
    "egg": {"colour": (130, 100, 70), "w": 29, "h": 36},
}
KINDS = ["apple", "bean", "coin", "egg"]


def row_length(row):
    if row % 2 == 0:
        return COLS
    return COLS - 1


def spot_x(row, col):
    """The middle of a spot, across."""
    x = BOARD_X + CELL / 2 + col * CELL
    if row % 2 == 1:
        x = x + CELL / 2
    return x


def spot_y(row):
    """The middle of a spot, down."""
    return BOARD_Y + CELL / 2 + row * ROW_H


# The board: a list of rows, and each row a list of spots. A spot holds the
# name of a piece, or None if it is empty. The top four rows start full.
grid = []
for row in range(ROWS):
    grid.append([None] * COLS)

for row in range(4):
    for col in range(row_length(row)):
        grid[row][col] = choose(KINDS)


def draw_piece(kind, x, y):
    piece = PIECES[kind]
    drawCircle(pos=vec2(x, y), radius=CELL / 2 - 2, color=piece["colour"])
    drawSprite(sprite=kind, pos=vec2(x, y), width=piece["w"],
               height=piece["h"], anchor="center")


@onDraw
def draw_everything():
    drawRect(pos=vec2(BOARD_X, BOARD_Y), width=BOARD_W, height=580,
             color=(34, 32, 56))
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                draw_piece(grid[row][col], spot_x(row, col), spot_y(row))

Run it, and four rows of pieces hang from the top: different every time.

  • [None] * COLS makes a list of 8 Nones: one empty row. The loop does that ROWS times, so grid starts as 12 empty rows.
  • The second loop fills the top 4 rows. row_length(row) says how many spots that row really has: 8 for an even row, 7 for an odd one. row % 2 is the remainder after dividing by 2, so it's 0 for even rows and 1 for odd ones.
  • spot_x(row, col) and spot_y(row) turn a spot into pixels: the middle of the piece. spot_x adds half a piece for the odd rows. That one if is the whole honeycomb. Take it out and every piece sits straight under the one above it, and because the rows are only 42 pixels apart, the 48-pixel pieces overlap each other.
  • draw_piece draws a coloured circle and then the picture on top of it. anchor="center" means pos is the middle of the picture rather than its top-left corner, so the picture sits in the middle of the circle. The circle is there so a coin and a lemon, both yellow, are easy to tell apart.

The pictures aren't all the same shape. The egg is tall and the bean is wide, so each kind in PIECES has its own width and height. Give them all "w": 36, "h": 36 and the egg and the bean come out the wrong shape.

Step 2 — aiming and bouncing

from kaypy import *

COLS = 8           # spots across the even rows (the odd rows have one less)
CELL = 48          # a piece is 48 pixels across
ROW_H = 42         # rows overlap, like a honeycomb, so they are closer than 48
ROWS = 12          # rows in the board

BOARD_X = 28                 # the left wall
BOARD_Y = 100                # the top of the ceiling
BOARD_W = COLS * CELL
LAUNCH = vec2(BOARD_X + BOARD_W / 2, 630)

SHOT_SPEED = 900   # pixels per second
TURN_SPEED = 100   # how fast the launcher turns, degrees per second

# Tall, to fit a phone held upright. The empty space at the bottom is where
# the controls for a phone go, in the last step.
kaypy(width=BOARD_W + 2 * BOARD_X, height=940, background=[24, 22, 40])

loadSprite("apple", "images/apple.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("egg", "images/egg.png")

# Each kind of piece: the colour of the circle behind it, and the size the
# picture is drawn at. The pictures are not all the same shape, so each one
# gets its own width and height.
PIECES = {
    "apple": {"colour": (150, 45, 60), "w": 31, "h": 36},
    "bean": {"colour": (35, 120, 85), "w": 38, "h": 32},
    "coin": {"colour": (190, 110, 30), "w": 26, "h": 32},
    "egg": {"colour": (130, 100, 70), "w": 29, "h": 36},
}
KINDS = ["apple", "bean", "coin", "egg"]


def row_length(row):
    if row % 2 == 0:
        return COLS
    return COLS - 1


def spot_x(row, col):
    """The middle of a spot, across."""
    x = BOARD_X + CELL / 2 + col * CELL
    if row % 2 == 1:
        x = x + CELL / 2
    return x


def spot_y(row):
    """The middle of a spot, down."""
    return BOARD_Y + CELL / 2 + row * ROW_H


# The board: a list of rows, and each row a list of spots. A spot holds the
# name of a piece, or None if it is empty. The top four rows start full.
grid = []
for row in range(ROWS):
    grid.append([None] * COLS)

for row in range(4):
    for col in range(row_length(row)):
        grid[row][col] = choose(KINDS)


aim = -90            # degrees: -90 is straight up, -180 would be left
loaded = choose(KINDS)   # the piece in the launcher
shot = None          # the piece in the air: a dictionary, or None


@onUpdate
def turn():
    global aim

    if isKeyDown("left"):
        aim = aim - TURN_SPEED * dt()
    if isKeyDown("right"):
        aim = aim + TURN_SPEED * dt()
    # Not too flat, or a shot would bounce from wall to wall for ages.
    aim = clamp(aim, -165, -15)


@onKeyPress("space")
def fire():
    global shot

    if shot is not None:
        return
    # Vec2.fromAngle turns an angle into a direction: an arrow one pixel
    # long, pointing that way.
    direction = Vec2.fromAngle(aim)
    shot = {"kind": loaded, "pos": LAUNCH, "vel": direction * SHOT_SPEED}


@onUpdate
def fly():
    global shot, loaded

    if shot is None:
        return

    shot["pos"] = shot["pos"] + shot["vel"] * dt()

    # Bounce off the side walls: put it back inside, and send it the other
    # way across. Its speed up the screen does not change.
    x = shot["pos"].x
    left = BOARD_X + CELL / 2
    right = BOARD_X + BOARD_W - CELL / 2
    if x < left:
        shot["pos"] = vec2(left, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)
    if x > right:
        shot["pos"] = vec2(right, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)

    # For now it flies straight through the pieces and is gone when it
    # reaches the ceiling. Sticking is the next step.
    if shot["pos"].y - CELL / 2 <= BOARD_Y:
        shot = None
        loaded = choose(KINDS)


def draw_piece(kind, x, y):
    piece = PIECES[kind]
    drawCircle(pos=vec2(x, y), radius=CELL / 2 - 2, color=piece["colour"])
    drawSprite(sprite=kind, pos=vec2(x, y), width=piece["w"],
               height=piece["h"], anchor="center")


@onDraw
def draw_everything():
    drawRect(pos=vec2(BOARD_X, BOARD_Y), width=BOARD_W, height=580,
             color=(34, 32, 56))
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                draw_piece(grid[row][col], spot_x(row, col), spot_y(row))

    # The launcher: a line pointing where the shot will go.
    tip = LAUNCH + Vec2.fromAngle(aim) * 60
    drawLine(p1=LAUNCH, p2=tip, width=6, color=(230, 230, 240))
    if shot is None:
        draw_piece(loaded, LAUNCH.x, LAUNCH.y)
    else:
        draw_piece(shot["kind"], shot["pos"].x, shot["pos"].y)

Hold the left and right arrows to turn the launcher, and press space to fire. Aim at a side wall and watch the piece bounce. For now it flies straight through the other pieces and disappears at the ceiling.

Aiming. aim is an angle in degrees. The screen's y goes down, so angles go round clockwise: 0 is right, -90 is straight up and -180 is left. Holding an arrow adds or takes away TURN_SPEED * dt() every frame. dt() is the time since the last frame, so that's 100 degrees a second, however fast the computer is. clamp stops it at -165 and -15. Flatter than that, a shot bounces from wall to wall for ages before it gets anywhere.

Firing. The flying piece is a dictionary with its kind, where it is ("pos"), and its velocity ("vel"): how far it moves in a second, across and down. Vec2.fromAngle(aim) turns the angle into a direction, an arrow one pixel long pointing that way, and multiplying it by SHOT_SPEED makes it 900 pixels long. The launcher line is drawn the same way, 60 pixels long.

Bouncing. Every frame the piece moves by vel * dt(). If it's gone past a wall, it's put back at the wall and its sideways speed is flipped: -shot["vel"].x. Its speed up the screen stays the same, which is what makes it a bounce.

if shot is not None: return at the top of fire means pressing space again while a piece is flying does nothing. Without it, every press would start a new shot from the launcher, and the one in the air would vanish.

Step 3 — sticking

from kaypy import *

COLS = 8           # spots across the even rows (the odd rows have one less)
CELL = 48          # a piece is 48 pixels across
ROW_H = 42         # rows overlap, like a honeycomb, so they are closer than 48
ROWS = 12          # rows in the board

BOARD_X = 28                 # the left wall
BOARD_Y = 100                # the top of the ceiling
BOARD_W = COLS * CELL
LAUNCH = vec2(BOARD_X + BOARD_W / 2, 630)

SHOT_SPEED = 900   # pixels per second
TURN_SPEED = 100   # how fast the launcher turns, degrees per second

# Tall, to fit a phone held upright. The empty space at the bottom is where
# the controls for a phone go, in the last step.
kaypy(width=BOARD_W + 2 * BOARD_X, height=940, background=[24, 22, 40])

loadSprite("apple", "images/apple.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("egg", "images/egg.png")

# Each kind of piece: the colour of the circle behind it, and the size the
# picture is drawn at. The pictures are not all the same shape, so each one
# gets its own width and height.
PIECES = {
    "apple": {"colour": (150, 45, 60), "w": 31, "h": 36},
    "bean": {"colour": (35, 120, 85), "w": 38, "h": 32},
    "coin": {"colour": (190, 110, 30), "w": 26, "h": 32},
    "egg": {"colour": (130, 100, 70), "w": 29, "h": 36},
}
KINDS = ["apple", "bean", "coin", "egg"]


def row_length(row):
    if row % 2 == 0:
        return COLS
    return COLS - 1


def in_grid(row, col):
    return 0 <= row < ROWS and 0 <= col < row_length(row)


def spot_x(row, col):
    """The middle of a spot, across."""
    x = BOARD_X + CELL / 2 + col * CELL
    if row % 2 == 1:
        x = x + CELL / 2
    return x


def spot_y(row):
    """The middle of a spot, down."""
    return BOARD_Y + CELL / 2 + row * ROW_H


# The board: a list of rows, and each row a list of spots. A spot holds the
# name of a piece, or None if it is empty. The top four rows start full.
grid = []
for row in range(ROWS):
    grid.append([None] * COLS)

for row in range(4):
    for col in range(row_length(row)):
        grid[row][col] = choose(KINDS)


# Every spot touches six others: one each side, two above and two below.
# WHICH two above depends on whether the row is pushed over.

def neighbours(row, col):
    if row % 2 == 0:
        shift = -1
    else:
        shift = 0
    around = [
        (row, col - 1), (row, col + 1),
        (row - 1, col + shift), (row - 1, col + shift + 1),
        (row + 1, col + shift), (row + 1, col + shift + 1),
    ]
    inside = []
    for spot in around:
        if in_grid(spot[0], spot[1]):
            inside.append(spot)
    return inside


aim = -90            # degrees: -90 is straight up, -180 would be left
loaded = choose(KINDS)   # the piece in the launcher
shot = None          # the piece in the air: a dictionary, or None


@onUpdate
def turn():
    global aim

    if isKeyDown("left"):
        aim = aim - TURN_SPEED * dt()
    if isKeyDown("right"):
        aim = aim + TURN_SPEED * dt()
    # Not too flat, or a shot would bounce from wall to wall for ages.
    aim = clamp(aim, -165, -15)


@onKeyPress("space")
def fire():
    global shot

    if shot is not None:
        return
    # Vec2.fromAngle turns an angle into a direction: an arrow one pixel
    # long, pointing that way.
    direction = Vec2.fromAngle(aim)
    shot = {"kind": loaded, "pos": LAUNCH, "vel": direction * SHOT_SPEED}


@onUpdate
def fly():

    if shot is None:
        return

    # A slow frame must not let the piece jump straight through another.
    step = min(dt(), 1 / 30)
    shot["pos"] = shot["pos"] + shot["vel"] * step

    # Bounce off the side walls: put it back inside, and send it the other
    # way across. Its speed up the screen does not change.
    x = shot["pos"].x
    left = BOARD_X + CELL / 2
    right = BOARD_X + BOARD_W - CELL / 2
    if x < left:
        shot["pos"] = vec2(left, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)
    if x > right:
        shot["pos"] = vec2(right, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)

    # Stick to the ceiling, or to any piece it comes close enough to touch.
    # "Close enough" is a little less than a whole piece, so a shot can slip
    # through a gap that looks big enough.
    if shot["pos"].y - CELL / 2 <= BOARD_Y:
        stick()
        return
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                middle = vec2(spot_x(row, col), spot_y(row))
                if shot["pos"].dist(middle) < CELL * 0.85:
                    stick()
                    return


def stick():
    """Put the flying piece in the nearest empty spot it could hang from."""
    global shot, loaded

    best_spot = None
    best_dist = 0
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is None and can_hang(row, col):
                middle = vec2(spot_x(row, col), spot_y(row))
                distance = shot["pos"].dist(middle)
                if best_spot is None or distance < best_dist:
                    best_spot = (row, col)
                    best_dist = distance

    grid[best_spot[0]][best_spot[1]] = shot["kind"]
    shot = None
    loaded = choose(KINDS)


def can_hang(row, col):
    """A spot can be filled if it touches the ceiling or another piece."""
    if row == 0:
        return True
    for near in neighbours(row, col):
        if grid[near[0]][near[1]] is not None:
            return True
    return False


def draw_piece(kind, x, y):
    piece = PIECES[kind]
    drawCircle(pos=vec2(x, y), radius=CELL / 2 - 2, color=piece["colour"])
    drawSprite(sprite=kind, pos=vec2(x, y), width=piece["w"],
               height=piece["h"], anchor="center")


@onDraw
def draw_everything():
    drawRect(pos=vec2(BOARD_X, BOARD_Y), width=BOARD_W, height=580,
             color=(34, 32, 56))
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                draw_piece(grid[row][col], spot_x(row, col), spot_y(row))

    # The launcher: a line pointing where the shot will go.
    tip = LAUNCH + Vec2.fromAngle(aim) * 60
    drawLine(p1=LAUNCH, p2=tip, width=6, color=(230, 230, 240))
    if shot is None:
        draw_piece(loaded, LAUNCH.x, LAUNCH.y)
    else:
        draw_piece(shot["kind"], shot["pos"].x, shot["pos"].y)

Now a shot sticks to the ceiling or to the first piece it touches, and snaps into a spot in the honeycomb.

Touching. Every frame, fly checks the distance from the shot to every piece on the board. .dist() measures it. Closer than CELL * 0.85 counts as touching: slightly less than a whole piece, so a shot can slip through a gap that looks just big enough.

Snapping. stick looks at every empty spot and keeps the nearest one the piece could hang from. can_hang says yes for a spot in the top row (it touches the ceiling) or one that touches a piece, so a piece can never end up floating on its own.

Neighbours. Every spot touches six others: one each side, two above and two below. Which two above depends on the row. Look at the picture of the honeycomb again: c in row 1 sits between C and D above it, the same column and one to the right. But C in row 2 sits between b and c above it, one to the left and the same column. That's what shift is. Then neighbours throws away anything off the edge of the board, using in_grid.

One more line in fly. min(dt(), 1 / 30) moves the shot as if no more than a thirtieth of a second had passed. On a slow frame, a whole dt() could carry it 45 pixels in one go, straight past a piece it should have hit.

This step can crash, and it's worth seeing. Fire straight up again and again. The pieces pile down towards the launcher, and when the bottom row is full and the next shot touches it, the game stops with:

TypeError: 'NoneType' object is not subscriptable

pointing at grid[best_spot[0]][best_spot[1]] = shot["kind"]. The piece needs a spot below the bottom row, and there isn't one, so no spot was ever found and best_spot is still None. None[0] makes no sense, and that's what the message means. Step 6 fixes it by ending the game before the board can get that full.

Step 4 — popping

from kaypy import *

COLS = 8           # spots across the even rows (the odd rows have one less)
CELL = 48          # a piece is 48 pixels across
ROW_H = 42         # rows overlap, like a honeycomb, so they are closer than 48
ROWS = 12          # rows in the board

BOARD_X = 28                 # the left wall
BOARD_Y = 100                # the top of the ceiling
BOARD_W = COLS * CELL
LAUNCH = vec2(BOARD_X + BOARD_W / 2, 630)

SHOT_SPEED = 900   # pixels per second
TURN_SPEED = 100   # how fast the launcher turns, degrees per second
POINTS_POP = 10    # for each piece that pops

# Tall, to fit a phone held upright. The empty space at the bottom is where
# the controls for a phone go, in the last step.
kaypy(width=BOARD_W + 2 * BOARD_X, height=940, background=[24, 22, 40])

loadSprite("apple", "images/apple.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("egg", "images/egg.png")

# Each kind of piece: the colour of the circle behind it, and the size the
# picture is drawn at. The pictures are not all the same shape, so each one
# gets its own width and height.
PIECES = {
    "apple": {"colour": (150, 45, 60), "w": 31, "h": 36},
    "bean": {"colour": (35, 120, 85), "w": 38, "h": 32},
    "coin": {"colour": (190, 110, 30), "w": 26, "h": 32},
    "egg": {"colour": (130, 100, 70), "w": 29, "h": 36},
}
KINDS = ["apple", "bean", "coin", "egg"]


def row_length(row):
    if row % 2 == 0:
        return COLS
    return COLS - 1


def in_grid(row, col):
    return 0 <= row < ROWS and 0 <= col < row_length(row)


def spot_x(row, col):
    """The middle of a spot, across."""
    x = BOARD_X + CELL / 2 + col * CELL
    if row % 2 == 1:
        x = x + CELL / 2
    return x


def spot_y(row):
    """The middle of a spot, down."""
    return BOARD_Y + CELL / 2 + row * ROW_H


# The board: a list of rows, and each row a list of spots. A spot holds the
# name of a piece, or None if it is empty. The top four rows start full.
grid = []
for row in range(ROWS):
    grid.append([None] * COLS)

for row in range(4):
    for col in range(row_length(row)):
        grid[row][col] = choose(KINDS)


# Every spot touches six others: one each side, two above and two below.
# WHICH two above depends on whether the row is pushed over.

def neighbours(row, col):
    if row % 2 == 0:
        shift = -1
    else:
        shift = 0
    around = [
        (row, col - 1), (row, col + 1),
        (row - 1, col + shift), (row - 1, col + shift + 1),
        (row + 1, col + shift), (row + 1, col + shift + 1),
    ]
    inside = []
    for spot in around:
        if in_grid(spot[0], spot[1]):
            inside.append(spot)
    return inside


def group_of(row, col):
    """Every spot joined to this one by pieces of the same kind.

    A flood fill: start with one spot, keep a list of spots still to look
    around, and add every matching neighbour not already found. When the
    list is empty, the whole group has been found.
    """
    kind = grid[row][col]
    found = [(row, col)]
    to_look = [(row, col)]
    while len(to_look) > 0:
        spot = to_look.pop()
        for near in neighbours(spot[0], spot[1]):
            if grid[near[0]][near[1]] == kind and near not in found:
                found.append(near)
                to_look.append(near)
    return found


score = 0
aim = -90            # degrees: -90 is straight up, -180 would be left
loaded = choose(KINDS)   # the piece in the launcher
shot = None          # the piece in the air: a dictionary, or None


@onUpdate
def turn():
    global aim

    if isKeyDown("left"):
        aim = aim - TURN_SPEED * dt()
    if isKeyDown("right"):
        aim = aim + TURN_SPEED * dt()
    # Not too flat, or a shot would bounce from wall to wall for ages.
    aim = clamp(aim, -165, -15)


@onKeyPress("space")
def fire():
    global shot

    if shot is not None:
        return
    # Vec2.fromAngle turns an angle into a direction: an arrow one pixel
    # long, pointing that way.
    direction = Vec2.fromAngle(aim)
    shot = {"kind": loaded, "pos": LAUNCH, "vel": direction * SHOT_SPEED}


@onUpdate
def fly():

    if shot is None:
        return

    # A slow frame must not let the piece jump straight through another.
    step = min(dt(), 1 / 30)
    shot["pos"] = shot["pos"] + shot["vel"] * step

    # Bounce off the side walls: put it back inside, and send it the other
    # way across. Its speed up the screen does not change.
    x = shot["pos"].x
    left = BOARD_X + CELL / 2
    right = BOARD_X + BOARD_W - CELL / 2
    if x < left:
        shot["pos"] = vec2(left, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)
    if x > right:
        shot["pos"] = vec2(right, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)

    # Stick to the ceiling, or to any piece it comes close enough to touch.
    # "Close enough" is a little less than a whole piece, so a shot can slip
    # through a gap that looks big enough.
    if shot["pos"].y - CELL / 2 <= BOARD_Y:
        stick()
        return
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                middle = vec2(spot_x(row, col), spot_y(row))
                if shot["pos"].dist(middle) < CELL * 0.85:
                    stick()
                    return


def stick():
    """Put the flying piece in the nearest empty spot it could hang from."""
    global shot, loaded

    best_spot = None
    best_dist = 0
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is None and can_hang(row, col):
                middle = vec2(spot_x(row, col), spot_y(row))
                distance = shot["pos"].dist(middle)
                if best_spot is None or distance < best_dist:
                    best_spot = (row, col)
                    best_dist = distance

    grid[best_spot[0]][best_spot[1]] = shot["kind"]
    shot = None
    loaded = choose(KINDS)
    settle(best_spot[0], best_spot[1])


def settle(row, col):
    """After a piece sticks: three or more of a kind pop."""
    global score

    group = group_of(row, col)
    if len(group) >= 3:
        for spot in group:
            grid[spot[0]][spot[1]] = None
        score = score + POINTS_POP * len(group)


def can_hang(row, col):
    """A spot can be filled if it touches the ceiling or another piece."""
    if row == 0:
        return True
    for near in neighbours(row, col):
        if grid[near[0]][near[1]] is not None:
            return True
    return False


def draw_piece(kind, x, y):
    piece = PIECES[kind]
    drawCircle(pos=vec2(x, y), radius=CELL / 2 - 2, color=piece["colour"])
    drawSprite(sprite=kind, pos=vec2(x, y), width=piece["w"],
               height=piece["h"], anchor="center")


@onDraw
def draw_everything():
    drawRect(pos=vec2(BOARD_X, BOARD_Y), width=BOARD_W, height=580,
             color=(34, 32, 56))
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                draw_piece(grid[row][col], spot_x(row, col), spot_y(row))

    # The launcher: a line pointing where the shot will go.
    tip = LAUNCH + Vec2.fromAngle(aim) * 60
    drawLine(p1=LAUNCH, p2=tip, width=6, color=(230, 230, 240))
    if shot is None:
        draw_piece(loaded, LAUNCH.x, LAUNCH.y)
    else:
        draw_piece(shot["kind"], shot["pos"].x, shot["pos"].y)

    drawText(text="Score " + str(score), pos=vec2(BOARD_X, 58), size=20)

Land a piece next to two of the same kind, and all three pop. Land it on a bigger group and the whole group goes.

group_of(row, col) is the flood fill, and it's the heart of the game. It finds every piece joined to this one by pieces of the same kind, using two lists:

  • found: every spot in the group so far. It starts with just the piece that landed.
  • to_look: spots whose neighbours haven't been checked yet. It starts with the same one.

The while loop takes a spot off to_look and checks its six neighbours. A neighbour of the same kind that isn't in found yet goes into both lists: it's in the group, and its own neighbours need checking. When to_look is empty, there's nothing left to check, and found is the whole group, however oddly it winds around the board.

Each spot is a pair, (row, col). Brackets like that make a tuple, which is like a list that can't be changed. spot[0] is the row and spot[1] the column, and near not in found works because Python can compare two pairs to see if they're the same.

Try taking out and near not in found. As soon as a piece lands next to one of its own kind, the two find each other, then each other again, for ever. The while loop never ends and the game freezes. That check is what makes the flood fill stop.

settle runs after every piece sticks. If the group is 3 or more, every spot in it is set back to None and the score goes up 10 for each piece.

Step 5 — falling off

from kaypy import *

COLS = 8           # spots across the even rows (the odd rows have one less)
CELL = 48          # a piece is 48 pixels across
ROW_H = 42         # rows overlap, like a honeycomb, so they are closer than 48
ROWS = 12          # rows in the board

BOARD_X = 28                 # the left wall
BOARD_Y = 100                # the top of the ceiling
BOARD_W = COLS * CELL
LAUNCH = vec2(BOARD_X + BOARD_W / 2, 630)

SHOT_SPEED = 900   # pixels per second
TURN_SPEED = 100   # how fast the launcher turns, degrees per second
POINTS_POP = 10    # for each piece that pops
POINTS_DROP = 20   # for each piece that falls off

# Tall, to fit a phone held upright. The empty space at the bottom is where
# the controls for a phone go, in the last step.
kaypy(width=BOARD_W + 2 * BOARD_X, height=940, background=[24, 22, 40])

loadSprite("apple", "images/apple.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("egg", "images/egg.png")

# Each kind of piece: the colour of the circle behind it, and the size the
# picture is drawn at. The pictures are not all the same shape, so each one
# gets its own width and height.
PIECES = {
    "apple": {"colour": (150, 45, 60), "w": 31, "h": 36},
    "bean": {"colour": (35, 120, 85), "w": 38, "h": 32},
    "coin": {"colour": (190, 110, 30), "w": 26, "h": 32},
    "egg": {"colour": (130, 100, 70), "w": 29, "h": 36},
}
KINDS = ["apple", "bean", "coin", "egg"]


def row_length(row):
    if row % 2 == 0:
        return COLS
    return COLS - 1


def in_grid(row, col):
    return 0 <= row < ROWS and 0 <= col < row_length(row)


def spot_x(row, col):
    """The middle of a spot, across."""
    x = BOARD_X + CELL / 2 + col * CELL
    if row % 2 == 1:
        x = x + CELL / 2
    return x


def spot_y(row):
    """The middle of a spot, down."""
    return BOARD_Y + CELL / 2 + row * ROW_H


# The board: a list of rows, and each row a list of spots. A spot holds the
# name of a piece, or None if it is empty. The top four rows start full.
grid = []
for row in range(ROWS):
    grid.append([None] * COLS)

for row in range(4):
    for col in range(row_length(row)):
        grid[row][col] = choose(KINDS)


# Every spot touches six others: one each side, two above and two below.
# WHICH two above depends on whether the row is pushed over.

def neighbours(row, col):
    if row % 2 == 0:
        shift = -1
    else:
        shift = 0
    around = [
        (row, col - 1), (row, col + 1),
        (row - 1, col + shift), (row - 1, col + shift + 1),
        (row + 1, col + shift), (row + 1, col + shift + 1),
    ]
    inside = []
    for spot in around:
        if in_grid(spot[0], spot[1]):
            inside.append(spot)
    return inside


def group_of(row, col):
    """Every spot joined to this one by pieces of the same kind.

    A flood fill: start with one spot, keep a list of spots still to look
    around, and add every matching neighbour not already found. When the
    list is empty, the whole group has been found.
    """
    kind = grid[row][col]
    found = [(row, col)]
    to_look = [(row, col)]
    while len(to_look) > 0:
        spot = to_look.pop()
        for near in neighbours(spot[0], spot[1]):
            if grid[near[0]][near[1]] == kind and near not in found:
                found.append(near)
                to_look.append(near)
    return found


def hanging_on():
    """Every spot joined to the ceiling, through pieces of any kind.

    The same flood fill, but it starts from every piece in the top row at
    once, and any piece will do.
    """
    found = []
    for col in range(row_length(0)):
        if grid[0][col] is not None:
            found.append((0, col))
    to_look = list(found)
    while len(to_look) > 0:
        spot = to_look.pop()
        for near in neighbours(spot[0], spot[1]):
            if grid[near[0]][near[1]] is not None and near not in found:
                found.append(near)
                to_look.append(near)
    return found


score = 0
aim = -90            # degrees: -90 is straight up, -180 would be left
loaded = choose(KINDS)   # the piece in the launcher
shot = None          # the piece in the air: a dictionary, or None

# Pieces that have just popped or fallen. They are not on the board any
# more; they are only drawn, until they are gone.
popping = []
falling = []


@onUpdate
def turn():
    global aim

    if isKeyDown("left"):
        aim = aim - TURN_SPEED * dt()
    if isKeyDown("right"):
        aim = aim + TURN_SPEED * dt()
    # Not too flat, or a shot would bounce from wall to wall for ages.
    aim = clamp(aim, -165, -15)


@onKeyPress("space")
def fire():
    global shot

    if shot is not None:
        return
    # Vec2.fromAngle turns an angle into a direction: an arrow one pixel
    # long, pointing that way.
    direction = Vec2.fromAngle(aim)
    shot = {"kind": loaded, "pos": LAUNCH, "vel": direction * SHOT_SPEED}


@onUpdate
def fly():

    if shot is None:
        return

    # A slow frame must not let the piece jump straight through another.
    step = min(dt(), 1 / 30)
    shot["pos"] = shot["pos"] + shot["vel"] * step

    # Bounce off the side walls: put it back inside, and send it the other
    # way across. Its speed up the screen does not change.
    x = shot["pos"].x
    left = BOARD_X + CELL / 2
    right = BOARD_X + BOARD_W - CELL / 2
    if x < left:
        shot["pos"] = vec2(left, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)
    if x > right:
        shot["pos"] = vec2(right, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)

    # Stick to the ceiling, or to any piece it comes close enough to touch.
    # "Close enough" is a little less than a whole piece, so a shot can slip
    # through a gap that looks big enough.
    if shot["pos"].y - CELL / 2 <= BOARD_Y:
        stick()
        return
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                middle = vec2(spot_x(row, col), spot_y(row))
                if shot["pos"].dist(middle) < CELL * 0.85:
                    stick()
                    return


def stick():
    """Put the flying piece in the nearest empty spot it could hang from."""
    global shot, loaded

    best_spot = None
    best_dist = 0
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is None and can_hang(row, col):
                middle = vec2(spot_x(row, col), spot_y(row))
                distance = shot["pos"].dist(middle)
                if best_spot is None or distance < best_dist:
                    best_spot = (row, col)
                    best_dist = distance

    grid[best_spot[0]][best_spot[1]] = shot["kind"]
    shot = None
    loaded = choose(KINDS)
    settle(best_spot[0], best_spot[1])


def settle(row, col):
    """After a piece sticks: three or more of a kind pop, then anything
    left with nothing holding it up falls."""
    global score

    group = group_of(row, col)
    if len(group) >= 3:
        for spot in group:
            popping.append({"kind": grid[spot[0]][spot[1]],
                            "x": spot_x(spot[0], spot[1]),
                            "y": spot_y(spot[0]), "time": 0})
            grid[spot[0]][spot[1]] = None
        score = score + POINTS_POP * len(group)

        # Now anything not joined to the ceiling falls.
        held = hanging_on()
        dropped = 0
        for r in range(ROWS):
            for c in range(row_length(r)):
                if grid[r][c] is not None and (r, c) not in held:
                    knock_off(r, c)
                    dropped = dropped + 1
        score = score + POINTS_DROP * dropped


def knock_off(row, col):
    """Take a piece off the board and let it fall."""
    falling.append({"kind": grid[row][col], "x": spot_x(row, col),
                    "y": spot_y(row), "speed": -100})
    grid[row][col] = None


@onUpdate
def move_leftovers():
    global popping, falling

    keep = []
    for piece in popping:
        piece["time"] = piece["time"] + dt()
        if piece["time"] < 0.25:
            keep.append(piece)
    popping = keep

    keep = []
    for piece in falling:
        piece["speed"] = piece["speed"] + 1800 * dt()
        piece["y"] = piece["y"] + piece["speed"] * dt()
        if piece["y"] < height() + CELL:
            keep.append(piece)
    falling = keep


def can_hang(row, col):
    """A spot can be filled if it touches the ceiling or another piece."""
    if row == 0:
        return True
    for near in neighbours(row, col):
        if grid[near[0]][near[1]] is not None:
            return True
    return False


def draw_piece(kind, x, y, size=1.0, opacity=1.0):
    piece = PIECES[kind]
    drawCircle(pos=vec2(x, y), radius=(CELL / 2 - 2) * size,
               color=piece["colour"], opacity=opacity)
    drawSprite(sprite=kind, pos=vec2(x, y), width=piece["w"] * size,
               height=piece["h"] * size, anchor="center", opacity=opacity)


@onDraw
def draw_everything():
    drawRect(pos=vec2(BOARD_X, BOARD_Y), width=BOARD_W, height=580,
             color=(34, 32, 56))
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                draw_piece(grid[row][col], spot_x(row, col), spot_y(row))

    for piece in popping:
        grow = 1 + piece["time"] * 2
        draw_piece(piece["kind"], piece["x"], piece["y"], grow,
                   1 - piece["time"] * 4)
    for piece in falling:
        draw_piece(piece["kind"], piece["x"], piece["y"])

    # The launcher: a line pointing where the shot will go.
    tip = LAUNCH + Vec2.fromAngle(aim) * 60
    drawLine(p1=LAUNCH, p2=tip, width=6, color=(230, 230, 240))
    if shot is None:
        draw_piece(loaded, LAUNCH.x, LAUNCH.y)
    else:
        draw_piece(shot["kind"], shot["pos"].x, shot["pos"].y)

    drawText(text="Score " + str(score), pos=vec2(BOARD_X, 58), size=20)

Pop the pieces holding up a bunch of others, and the whole bunch falls off the board. That's where the big scores are.

hanging_on() is the same flood fill, with two differences. It starts from every piece in the top row at once, because they all touch the ceiling. And any piece will do: it doesn't care what kind it is. What it finds is every piece still joined to the ceiling. Anything on the board that isn't in that list has nothing holding it up, and knock_off takes it away. Each one is worth 20.

The animations. A piece that pops or falls is no longer on the board, but it would look odd if it just vanished. So it goes into one of two lists: popping or falling. Each frame, move_leftovers moves them along:

  • A popping piece counts up its "time", and draw_everything draws it bigger and fainter as the time goes up. At a quarter of a second it's left out of the list.
  • A falling piece starts with a "speed" of -100, a small hop upwards, and gains 1800 every second, which is gravity. Once it's below the bottom of the window, it's left out of the list.

Both use the same trick for taking things out of a list: make a new list, keep, of only the ones to keep, and use that from then on. Removing items from a list while a for loop is going through it skips some of them.

Step 6 — the danger meter

from kaypy import *

COLS = 8           # spots across the even rows (the odd rows have one less)
CELL = 48          # a piece is 48 pixels across
ROW_H = 42         # rows overlap, like a honeycomb, so they are closer than 48
DANGER_ROWS = 11   # a piece in the 12th row down is past the red line

BOARD_X = 28                 # the left wall
BOARD_Y = 100                # the top of the ceiling
BOARD_W = COLS * CELL
LAUNCH = vec2(BOARD_X + BOARD_W / 2, 630)

SHOT_SPEED = 900   # pixels per second
TURN_SPEED = 100   # how fast the launcher turns, degrees per second
POINTS_POP = 10    # for each piece that pops
POINTS_DROP = 20   # for each piece that falls off
MISSES_ALLOWED = 7  # misses before the ceiling comes down

# Tall, to fit a phone held upright. The empty space at the bottom is where
# the controls for a phone go, in the last step.
kaypy(width=BOARD_W + 2 * BOARD_X, height=940, background=[24, 22, 40])

loadSprite("apple", "images/apple.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("egg", "images/egg.png")

# Each kind of piece: the colour of the circle behind it, and the size the
# picture is drawn at. The pictures are not all the same shape, so each one
# gets its own width and height.
PIECES = {
    "apple": {"colour": (150, 45, 60), "w": 31, "h": 36},
    "bean": {"colour": (35, 120, 85), "w": 38, "h": 32},
    "coin": {"colour": (190, 110, 30), "w": 26, "h": 32},
    "egg": {"colour": (130, 100, 70), "w": 29, "h": 36},
}
KINDS = ["apple", "bean", "coin", "egg"]


# One more row than can be played in, so a piece that sticks just past the
# red line has somewhere to go -- and then ends the game.
ROWS = DANGER_ROWS + 1


def row_length(row):
    if row % 2 == 0:
        return COLS
    return COLS - 1


def in_grid(row, col):
    return 0 <= row < ROWS and 0 <= col < row_length(row)


def empty_grid():
    rows = []
    for row in range(ROWS):
        rows.append([None] * COLS)
    return rows


# The ceiling comes down a row at a time. Everything hangs from it, so the
# whole board is drawn lower by the same amount.

drops = 0            # how many rows the ceiling has come down


def ceiling_y():
    return BOARD_Y + drops * ROW_H


def spot_x(row, col):
    """The middle of a spot, across."""
    x = BOARD_X + CELL / 2 + col * CELL
    if row % 2 == 1:
        x = x + CELL / 2
    return x


def spot_y(row):
    """The middle of a spot, down."""
    return ceiling_y() + CELL / 2 + row * ROW_H


# The board: a list of rows, and each row a list of spots. A spot holds the
# name of a piece, or None if it is empty. new_game() fills it.
grid = []


# Every spot touches six others: one each side, two above and two below.
# WHICH two above depends on whether the row is pushed over.

def neighbours(row, col):
    if row % 2 == 0:
        shift = -1
    else:
        shift = 0
    around = [
        (row, col - 1), (row, col + 1),
        (row - 1, col + shift), (row - 1, col + shift + 1),
        (row + 1, col + shift), (row + 1, col + shift + 1),
    ]
    inside = []
    for spot in around:
        if in_grid(spot[0], spot[1]):
            inside.append(spot)
    return inside


def group_of(row, col):
    """Every spot joined to this one by pieces of the same kind.

    A flood fill: start with one spot, keep a list of spots still to look
    around, and add every matching neighbour not already found. When the
    list is empty, the whole group has been found.
    """
    kind = grid[row][col]
    found = [(row, col)]
    to_look = [(row, col)]
    while len(to_look) > 0:
        spot = to_look.pop()
        for near in neighbours(spot[0], spot[1]):
            if grid[near[0]][near[1]] == kind and near not in found:
                found.append(near)
                to_look.append(near)
    return found


def hanging_on():
    """Every spot joined to the ceiling, through pieces of any kind.

    The same flood fill, but it starts from every piece in the top row at
    once, and any piece will do.
    """
    found = []
    for col in range(row_length(0)):
        if grid[0][col] is not None:
            found.append((0, col))
    to_look = list(found)
    while len(to_look) > 0:
        spot = to_look.pop()
        for near in neighbours(spot[0], spot[1]):
            if grid[near[0]][near[1]] is not None and near not in found:
                found.append(near)
                to_look.append(near)
    return found


score = 0
misses = 0           # shots in a row that popped nothing
over = False
aim = -90            # degrees: -90 is straight up, -180 would be left
loaded = choose(KINDS)   # the piece in the launcher
shot = None          # the piece in the air: a dictionary, or None

# Pieces that have just popped or fallen. They are not on the board any
# more; they are only drawn, until they are gone.
popping = []
falling = []


@onUpdate
def turn():
    global aim

    if isKeyDown("left"):
        aim = aim - TURN_SPEED * dt()
    if isKeyDown("right"):
        aim = aim + TURN_SPEED * dt()
    # Not too flat, or a shot would bounce from wall to wall for ages.
    aim = clamp(aim, -165, -15)


@onKeyPress("space")
def fire():
    global shot

    if shot is not None or over:
        return
    # Vec2.fromAngle turns an angle into a direction: an arrow one pixel
    # long, pointing that way.
    direction = Vec2.fromAngle(aim)
    shot = {"kind": loaded, "pos": LAUNCH, "vel": direction * SHOT_SPEED}


@onUpdate
def fly():

    if shot is None:
        return

    # A slow frame must not let the piece jump straight through another.
    step = min(dt(), 1 / 30)
    shot["pos"] = shot["pos"] + shot["vel"] * step

    # Bounce off the side walls: put it back inside, and send it the other
    # way across. Its speed up the screen does not change.
    x = shot["pos"].x
    left = BOARD_X + CELL / 2
    right = BOARD_X + BOARD_W - CELL / 2
    if x < left:
        shot["pos"] = vec2(left, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)
    if x > right:
        shot["pos"] = vec2(right, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)

    # Stick to the ceiling, or to any piece it comes close enough to touch.
    # "Close enough" is a little less than a whole piece, so a shot can slip
    # through a gap that looks big enough.
    if shot["pos"].y - CELL / 2 <= ceiling_y():
        stick()
        return
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                middle = vec2(spot_x(row, col), spot_y(row))
                if shot["pos"].dist(middle) < CELL * 0.85:
                    stick()
                    return


def stick():
    """Put the flying piece in the nearest empty spot it could hang from."""
    global shot, loaded

    best_spot = None
    best_dist = 0
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is None and can_hang(row, col):
                middle = vec2(spot_x(row, col), spot_y(row))
                distance = shot["pos"].dist(middle)
                if best_spot is None or distance < best_dist:
                    best_spot = (row, col)
                    best_dist = distance

    grid[best_spot[0]][best_spot[1]] = shot["kind"]
    shot = None
    loaded = choose(KINDS)
    settle(best_spot[0], best_spot[1])


def settle(row, col):
    """After a piece sticks: pop, drop, fill the danger meter, check."""
    global score, misses, drops

    group = group_of(row, col)
    if len(group) >= 3:
        for spot in group:
            popping.append({"kind": grid[spot[0]][spot[1]],
                            "x": spot_x(spot[0], spot[1]),
                            "y": spot_y(spot[0]), "time": 0})
            grid[spot[0]][spot[1]] = None
        score = score + POINTS_POP * len(group)

        # Now anything not joined to the ceiling falls.
        held = hanging_on()
        dropped = 0
        for r in range(ROWS):
            for c in range(row_length(r)):
                if grid[r][c] is not None and (r, c) not in held:
                    knock_off(r, c)
                    dropped = dropped + 1
        score = score + POINTS_DROP * dropped
        misses = max(0, misses - 1)
    else:
        misses = misses + 1
        if misses >= MISSES_ALLOWED:
            misses = 0
            drops = drops + 1
            shake(4)

    # Past the red line? Look at the rows below it.
    for r in range(DANGER_ROWS - drops, ROWS):
        for c in range(row_length(r)):
            if grid[r][c] is not None:
                game_over()
                return


def game_over():
    global over

    over = True
    shake(10)

    @say("The pieces reached the line!\n\nScore: " + str(score),
         button="Play again")
    def again():
        new_game()


def new_game():
    global grid, drops, score, misses, over, shot, popping, falling

    grid = empty_grid()
    drops = 0
    score = 0
    misses = 0
    over = False
    shot = None
    popping = []
    falling = []
    for row in range(4):
        for col in range(row_length(row)):
            grid[row][col] = choose(KINDS)


def knock_off(row, col):
    """Take a piece off the board and let it fall."""
    falling.append({"kind": grid[row][col], "x": spot_x(row, col),
                    "y": spot_y(row), "speed": -100})
    grid[row][col] = None


@onUpdate
def move_leftovers():
    global popping, falling

    keep = []
    for piece in popping:
        piece["time"] = piece["time"] + dt()
        if piece["time"] < 0.25:
            keep.append(piece)
    popping = keep

    keep = []
    for piece in falling:
        piece["speed"] = piece["speed"] + 1800 * dt()
        piece["y"] = piece["y"] + piece["speed"] * dt()
        if piece["y"] < height() + CELL:
            keep.append(piece)
    falling = keep


def can_hang(row, col):
    """A spot can be filled if it touches the ceiling or another piece."""
    if row == 0:
        return True
    for near in neighbours(row, col):
        if grid[near[0]][near[1]] is not None:
            return True
    return False


def draw_piece(kind, x, y, size=1.0, opacity=1.0):
    piece = PIECES[kind]
    drawCircle(pos=vec2(x, y), radius=(CELL / 2 - 2) * size,
               color=piece["colour"], opacity=opacity)
    drawSprite(sprite=kind, pos=vec2(x, y), width=piece["w"] * size,
               height=piece["h"] * size, anchor="center", opacity=opacity)


@onDraw
def draw_everything():
    # The well the pieces hang in, the ceiling that comes down, and the red
    # line across the bottom of the last row a piece may be in.
    line_y = BOARD_Y + DANGER_ROWS * ROW_H + (CELL - ROW_H)
    drawRect(pos=vec2(BOARD_X, BOARD_Y), width=BOARD_W,
             height=LAUNCH.y + CELL - BOARD_Y, color=(34, 32, 56))
    drawRect(pos=vec2(BOARD_X, BOARD_Y - 8), width=BOARD_W,
             height=ceiling_y() - BOARD_Y + 8, color=(110, 110, 130))
    drawRect(pos=vec2(BOARD_X, line_y), width=BOARD_W, height=3,
             color=(240, 90, 90))
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                draw_piece(grid[row][col], spot_x(row, col), spot_y(row))

    for piece in popping:
        grow = 1 + piece["time"] * 2
        draw_piece(piece["kind"], piece["x"], piece["y"], grow,
                   1 - piece["time"] * 4)
    for piece in falling:
        draw_piece(piece["kind"], piece["x"], piece["y"])

    # The launcher: a line pointing where the shot will go.
    tip = LAUNCH + Vec2.fromAngle(aim) * 60
    drawLine(p1=LAUNCH, p2=tip, width=6, color=(230, 230, 240))
    if shot is None:
        draw_piece(loaded, LAUNCH.x, LAUNCH.y)
    else:
        draw_piece(shot["kind"], shot["pos"].x, shot["pos"].y)

    drawText(text="Score " + str(score), pos=vec2(BOARD_X, 58), size=20)

    # The danger meter: one box per miss.
    for i in range(MISSES_ALLOWED):
        if i < misses:
            colour = (240, 90, 90)
        else:
            colour = (70, 66, 100)
        drawRect(pos=vec2(BOARD_X + BOARD_W - MISSES_ALLOWED * 16 + i * 16,
                          62), width=12, height=16, color=colour, radius=3)


new_game()

Now there's a way to lose. Each shot that pops nothing lights a box in the danger meter at the top right. When all seven are lit, the ceiling comes down a row and takes every piece with it. A shot that does pop something turns one box off again. If any piece ends up past the red line, the game is over.

The ceiling. drops counts how many rows it has come down, and ceiling_y() is where it is now. spot_y measures from ceiling_y() instead of from BOARD_Y, and because everything on the board is drawn and hit-tested through spot_y, changing one number moves the whole board down. Nothing in grid changes at all: row 0 is still row 0, it's just drawn lower.

The red line is under the 11th row (DANGER_ROWS), and it doesn't move. Each time the ceiling drops, one row fewer fits above the line, so the loop that looks for pieces past it starts at row DANGER_ROWS - drops.

The crash from step 3 is gone. ROWS is now DANGER_ROWS + 1: one more row than can be played in. The moment any piece lands past the line, the game is over, so the board never gets full enough to need a row that doesn't exist.

new_game() puts everything back as it started and fills a fresh board. The same function starts the first game, at the very bottom of the file, and every game after it, from the Play again button. say() pauses the game while its message is up, and over stops fire working until the next game starts.

Step 7 — the whole game

This is the game exactly as it is on the games page. It adds:

  • Levels. Clear every piece and the next level starts. Level 1 has 4 kinds and 4 rows, and each level adds one more of each, up to all 7 kinds and 7 rows. KINDS[:5] is the first 5 kinds in the list. The danger meter gets shorter too: misses_allowed() is 7 on level 1, then 6, 5 and finally 4.
  • Steel. From level 2, some pieces are steel. settle never pops a group of steel, so the only way to get rid of it is to pop what it hangs from. choose(range(1, rows)) picks a row at random, not counting the top one, so steel never touches the ceiling.
  • Never a useless piece. kinds_left() lists the kinds still on the board, and load_new() only hands you those. Without it, clearing the last coin could still leave you holding a coin, with nothing it could ever match.
  • The next piece, shown at the bottom left, so you can plan a shot ahead.
  • Between levels. level_cleared knocks off any steel still hanging, as a bonus, and sets busy so you can't fire. Then wait(1.0, show_message) gives the last pieces a second to fall before the message comes up. Showing it straight away would pause the game with them frozen in mid-air.
  • Your best score, kept with getData and setData, so it's still there next time.
  • Sounds, and a message at the start explaining what to do.
  • Controls for a phone. joystick=["space"] puts a d-pad and one button at the bottom of the window. They pretend to be the arrow keys and space, so nothing else in the game needed changing. That's what the empty space at the bottom has been for since step 1.

Only space fires, not the up arrow as well, and that's because of the d-pad. A thumb between left and up holds both arrows, so a player turning left whose thumb slid upwards a little would fire by accident.

"""Pop Drop -- aim, fire, and pop the pieces before they reach the bottom.

    left / right arrows   turn the launcher
    space                 fire

The pieces hang from the ceiling in a honeycomb. Fire one up at them: it
bounces off the side walls and sticks where it lands. Three or more of the
same kind touching each other pop -- and anything left hanging with nothing
holding it to the ceiling falls off too.

Every shot that pops nothing fills the danger meter. When it is full, the
ceiling comes down a row. If a piece goes past the red line, the game is
over. Clear every piece to move up a level. Steel never pops: the only way
to get rid of it is to cut away whatever it hangs from.

On a phone, the d-pad turns the launcher and the button fires.

HOW THIS FILE IS PUT TOGETHER

    1. the window and the pictures
    2. the settings
    3. the board: a list of rows, and where each spot is on the screen
    4. neighbours, groups, and what is still hanging on
    5. what the game has to remember
    6. levels
    7. aiming and firing
    8. the flying piece, and where it sticks
    9. drawing
   10. start the game

Like Block Slide, the board is not made of game objects. It is a list of
lists of names -- "apple", "steel", or None for an empty spot -- and it is
drawn from that list every frame.
"""

from kaypy import *

# --------------------------------------------------------------- 1. setup

COLS = 8           # spots across the even rows (the odd rows have one less)
CELL = 48          # a piece is 48 pixels across
ROW_H = 42         # rows overlap, like a honeycomb, so they are closer than 48
DANGER_ROWS = 11   # a piece in the 12th row down is past the red line

BOARD_X = 28                 # the left wall
BOARD_Y = 100                # the top of the ceiling
BOARD_W = COLS * CELL
LAUNCH = vec2(BOARD_X + BOARD_W / 2, 630)

# The window is taller than the game. The bottom part is for the d-pad and
# the fire button: joystick=["space"] puts them there, and they press the
# arrow keys and space for you.
kaypy(width=BOARD_W + 2 * BOARD_X, height=940, background=[24, 22, 40],
      joystick=["space"])

loadSprite("apple", "images/apple.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("egg", "images/egg.png")
loadSprite("ghosty", "images/ghosty.png")
loadSprite("lemon", "images/lemon.png")
loadSprite("mushroom", "images/mushroom.png")
loadSprite("steel", "images/steel.png")

loadSound("fire", "sounds/swoosh.wav")
loadSound("stick", "sounds/thump.wav")
loadSound("pop", "sounds/small_beep.wav")
loadSound("drop", "sounds/small_boom.wav")
loadSound("level", "sounds/rising_beep.wav")
loadSound("over", "sounds/lazer_punch.wav")


# ---------------------------------------------------------- 2. settings

SHOT_SPEED = 900      # pixels per second
TURN_SPEED = 100      # how fast the launcher turns, degrees per second
POINTS_POP = 10       # for each piece that pops
POINTS_DROP = 20      # for each piece that falls off
POINTS_LEVEL = 100    # for clearing a level, times the level number

# Each kind of piece: the colour of the circle behind it, and the size the
# picture is drawn at. The pictures are not all the same shape, so each one
# gets its own width and height -- otherwise the ghost would be squashed.
PIECES = {
    "apple": {"colour": (150, 45, 60), "w": 31, "h": 36},
    "bean": {"colour": (35, 120, 85), "w": 38, "h": 32},
    "coin": {"colour": (190, 110, 30), "w": 26, "h": 32},
    "egg": {"colour": (130, 100, 70), "w": 29, "h": 36},
    "ghosty": {"colour": (50, 80, 170), "w": 29, "h": 36},
    "lemon": {"colour": (130, 140, 30), "w": 26, "h": 36},
    "mushroom": {"colour": (120, 60, 140), "w": 38, "h": 32},
}

# The order new kinds are added in, one more each level.
KINDS = ["apple", "bean", "coin", "egg", "ghosty", "lemon", "mushroom"]

BEST_KEY = "pop_drop_best"


# ----------------------------------------------------------- 3. the board
#
# grid[row][col] is the name of the piece in that spot, or None.
#
# Row 0 is the top. The odd rows are pushed half a piece to the right, so
# they fit into the gaps of the rows above and below -- that is what makes
# it a honeycomb. It also means an odd row has room for one piece fewer.
#
#     row 0:   A B C D E F G H
#     row 1:    a b c d e f g
#     row 2:   A B C D E F G H
#
# There is one more row than can be played in, so a piece that sticks just
# past the red line has somewhere to go -- and then ends the game.

ROWS = DANGER_ROWS + 1
grid = []


def row_length(row):
    if row % 2 == 0:
        return COLS
    return COLS - 1


def in_grid(row, col):
    return 0 <= row < ROWS and 0 <= col < row_length(row)


def empty_grid():
    rows = []
    for row in range(ROWS):
        rows.append([None] * COLS)
    return rows


# The ceiling comes down a row at a time. Everything hangs from it, so the
# whole board is drawn lower by the same amount.

drops = 0            # how many rows the ceiling has come down


def ceiling_y():
    return BOARD_Y + drops * ROW_H


def spot_x(row, col):
    """The middle of a spot, across."""
    x = BOARD_X + CELL / 2 + col * CELL
    if row % 2 == 1:
        x = x + CELL / 2
    return x


def spot_y(row):
    """The middle of a spot, down."""
    return ceiling_y() + CELL / 2 + row * ROW_H


# ------------------------------------- 4. neighbours, groups, hanging on
#
# Every spot touches six others: one each side, two above and two below.
# WHICH two above depends on whether the row is pushed over. Look at "c" in
# the picture above: it sits between "C" and "D". But "C" sits between "b"
# and "c". Hence the shift.

def neighbours(row, col):
    if row % 2 == 0:
        shift = -1
    else:
        shift = 0
    around = [
        (row, col - 1), (row, col + 1),
        (row - 1, col + shift), (row - 1, col + shift + 1),
        (row + 1, col + shift), (row + 1, col + shift + 1),
    ]
    inside = []
    for spot in around:
        if in_grid(spot[0], spot[1]):
            inside.append(spot)
    return inside


def group_of(row, col):
    """Every spot joined to this one by pieces of the same kind.

    A flood fill: start with one spot, keep a list of spots still to look
    around, and add every matching neighbour not already found. When the
    list is empty, the whole group has been found.
    """
    kind = grid[row][col]
    found = [(row, col)]
    to_look = [(row, col)]
    while len(to_look) > 0:
        spot = to_look.pop()
        for near in neighbours(spot[0], spot[1]):
            if grid[near[0]][near[1]] == kind and near not in found:
                found.append(near)
                to_look.append(near)
    return found


def hanging_on():
    """Every spot joined to the ceiling, through pieces of any kind.

    The same flood fill, but it starts from every piece in the top row at
    once, and any piece will do.
    """
    found = []
    for col in range(row_length(0)):
        if grid[0][col] is not None:
            found.append((0, col))
    to_look = list(found)
    while len(to_look) > 0:
        spot = to_look.pop()
        for near in neighbours(spot[0], spot[1]):
            if grid[near[0]][near[1]] is not None and near not in found:
                found.append(near)
                to_look.append(near)
    return found


def kinds_left():
    """The kinds still on the board, not counting steel."""
    left = []
    for row in range(ROWS):
        for col in range(row_length(row)):
            kind = grid[row][col]
            if kind is not None and kind != "steel" and kind not in left:
                left.append(kind)
    return left


# ------------------------------------------- 5. what the game remembers

level = 1
score = 0
best = getData(BEST_KEY, 0)
misses = 0           # shots in a row that popped nothing
aim = -90            # degrees: -90 is straight up, -180 would be left
loaded = "apple"     # the piece in the launcher
next_up = "bean"     # the one after it
shot = None          # the piece in the air: a dictionary, or None
busy = False         # True between levels: no firing then
over = False

# Pieces that have just popped or fallen. They are not on the board any
# more; they are only drawn, until they are gone.
popping = []
falling = []


def misses_allowed():
    """How many misses fill the danger meter. Fewer as the levels go up."""
    return max(4, 8 - level)


# ---------------------------------------------------------- 6. levels
#
# Level 1 has four kinds of piece and four rows. Each level adds one of
# each, up to all seven kinds and seven rows, and from level 2 on, some of
# the pieces are steel.

def start_level():
    global grid, drops, misses, shot, busy, popping, falling

    grid = empty_grid()
    drops = 0
    misses = 0
    shot = None
    busy = False
    popping = []
    falling = []

    kinds = KINDS[:min(3 + level, len(KINDS))]
    rows = min(3 + level, 7)
    for row in range(rows):
        for col in range(row_length(row)):
            grid[row][col] = choose(kinds)

    # Steel goes anywhere below the top row. It may land on steel already
    # put there, so there can be fewer than this -- that is fine.
    for i in range(min(level - 1, 6)):
        row = choose(range(1, rows))
        col = choose(range(row_length(row)))
        grid[row][col] = "steel"

    load_new()
    load_new()


def load_new():
    """Move the next piece into the launcher, and choose a new next one.

    Only kinds still on the board are chosen, so you are never handed a
    piece that cannot match anything.
    """
    global loaded, next_up

    left = kinds_left()
    loaded = next_up
    if loaded not in left and len(left) > 0:
        loaded = choose(left)
    if len(left) > 0:
        next_up = choose(left)


def level_cleared():
    global level, score, busy

    busy = True
    score = score + POINTS_LEVEL * level
    play("level")

    # Any steel still hanging falls away as a bonus.
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                knock_off(row, col)

    level = level + 1

    def show_message():
        @say("Level " + str(level - 1) + " cleared!\n\n"
             "Next: level " + str(level) + ".", button="Go")
        def go_on():
            start_level()

    wait(1.0, show_message)


def game_over():
    global over, best

    over = True
    play("over")
    shake(10)
    if score > best:
        best = score
        setData(BEST_KEY, best)
        message = "New best: " + str(score) + "!"
    else:
        message = "Score: " + str(score) + "\nBest: " + str(best)

    @say("The pieces reached the line!\n\n" + message, button="Play again")
    def again():
        new_game()


def new_game():
    global level, score, over, aim

    level = 1
    score = 0
    over = False
    aim = -90
    start_level()


# ------------------------------------------------- 7. aiming and firing

@onUpdate
def turn():
    global aim

    if isKeyDown("left"):
        aim = aim - TURN_SPEED * dt()
    if isKeyDown("right"):
        aim = aim + TURN_SPEED * dt()
    # Not too flat, or a shot would bounce from wall to wall for ages.
    aim = clamp(aim, -165, -15)


# Only space fires, not up as well. On a phone the d-pad holds two arrows
# at once when a thumb is between them, so a thumb turning left that slid a
# little upwards would fire by accident.
@onKeyPress("space")
def fire():
    global shot

    if shot is not None or busy or over:
        return
    # Vec2.fromAngle turns an angle into a direction: an arrow one pixel
    # long, pointing that way.
    direction = Vec2.fromAngle(aim)
    shot = {"kind": loaded, "pos": LAUNCH, "vel": direction * SHOT_SPEED}
    play("fire")


# ------------------------------- 8. the flying piece, and where it sticks

@onUpdate
def fly():
    if shot is None:
        return

    # A slow frame must not let the piece jump straight through another.
    step = min(dt(), 1 / 30)
    shot["pos"] = shot["pos"] + shot["vel"] * step

    # Bounce off the side walls: put it back inside, and send it the other
    # way across. Its speed up the screen does not change.
    x = shot["pos"].x
    left = BOARD_X + CELL / 2
    right = BOARD_X + BOARD_W - CELL / 2
    if x < left:
        shot["pos"] = vec2(left, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)
    if x > right:
        shot["pos"] = vec2(right, shot["pos"].y)
        shot["vel"] = vec2(-shot["vel"].x, shot["vel"].y)

    # Stick to the ceiling, or to any piece it comes close enough to touch.
    # "Close enough" is a little less than a whole piece, so a shot can slip
    # through a gap that looks big enough.
    if shot["pos"].y - CELL / 2 <= ceiling_y():
        stick()
        return
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                middle = vec2(spot_x(row, col), spot_y(row))
                if shot["pos"].dist(middle) < CELL * 0.85:
                    stick()
                    return


def stick():
    """Put the flying piece in the nearest empty spot it could hang from."""
    global shot

    best_spot = None
    best_dist = 0
    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is None and can_hang(row, col):
                middle = vec2(spot_x(row, col), spot_y(row))
                distance = shot["pos"].dist(middle)
                if best_spot is None or distance < best_dist:
                    best_spot = (row, col)
                    best_dist = distance

    grid[best_spot[0]][best_spot[1]] = shot["kind"]
    shot = None
    settle(best_spot[0], best_spot[1])


def can_hang(row, col):
    """A spot can be filled if it touches the ceiling or another piece."""
    if row == 0:
        return True
    for near in neighbours(row, col):
        if grid[near[0]][near[1]] is not None:
            return True
    return False


def settle(row, col):
    """After a piece sticks: pop, drop, fill the danger meter, check."""
    global score, misses, drops

    group = group_of(row, col)
    if len(group) >= 3 and grid[row][col] != "steel":
        for spot in group:
            popping.append({"kind": grid[spot[0]][spot[1]],
                            "x": spot_x(spot[0], spot[1]),
                            "y": spot_y(spot[0]), "time": 0})
            grid[spot[0]][spot[1]] = None
        score = score + POINTS_POP * len(group)
        play("pop")

        # Now anything not joined to the ceiling falls.
        held = hanging_on()
        dropped = 0
        for r in range(ROWS):
            for c in range(row_length(r)):
                if grid[r][c] is not None and (r, c) not in held:
                    knock_off(r, c)
                    dropped = dropped + 1
        if dropped > 0:
            score = score + POINTS_DROP * dropped
            play("drop")
        misses = max(0, misses - 1)
    else:
        play("stick")
        misses = misses + 1
        if misses >= misses_allowed():
            misses = 0
            drops = drops + 1
            shake(4)

    if len(kinds_left()) == 0:
        level_cleared()
        return

    # Past the red line? Look at the rows below it.
    for r in range(DANGER_ROWS - drops, ROWS):
        for c in range(row_length(r)):
            if grid[r][c] is not None:
                game_over()
                return

    load_new()


def knock_off(row, col):
    """Take a piece off the board and let it fall."""
    falling.append({"kind": grid[row][col], "x": spot_x(row, col),
                    "y": spot_y(row), "speed": -100})
    grid[row][col] = None


@onUpdate
def move_leftovers():
    global popping, falling

    keep = []
    for piece in popping:
        piece["time"] = piece["time"] + dt()
        if piece["time"] < 0.25:
            keep.append(piece)
    popping = keep

    keep = []
    for piece in falling:
        piece["speed"] = piece["speed"] + 1800 * dt()
        piece["y"] = piece["y"] + piece["speed"] * dt()
        if piece["y"] < height() + CELL:
            keep.append(piece)
    falling = keep


# ------------------------------------------------------------ 9. drawing

def draw_piece(kind, x, y, size=1.0, opacity=1.0):
    if kind == "steel":
        drawSprite(sprite="steel", pos=vec2(x, y), width=(CELL - 4) * size,
                   height=(CELL - 4) * size, anchor="center",
                   opacity=opacity)
        return
    piece = PIECES[kind]
    drawCircle(pos=vec2(x, y), radius=(CELL / 2 - 2) * size,
               color=piece["colour"], opacity=opacity)
    drawSprite(sprite=kind, pos=vec2(x, y), width=piece["w"] * size,
               height=piece["h"] * size, anchor="center", opacity=opacity)


@onDraw
def draw_everything():
    # The well the pieces hang in, and the ceiling that comes down.
    line_y = BOARD_Y + DANGER_ROWS * ROW_H + (CELL - ROW_H)
    drawRect(pos=vec2(BOARD_X, BOARD_Y), width=BOARD_W,
             height=LAUNCH.y + CELL - BOARD_Y, color=(34, 32, 56))
    drawRect(pos=vec2(BOARD_X, BOARD_Y - 8), width=BOARD_W,
             height=ceiling_y() - BOARD_Y + 8, color=(110, 110, 130))
    drawRect(pos=vec2(BOARD_X, line_y), width=BOARD_W, height=3,
             color=(240, 90, 90))

    for row in range(ROWS):
        for col in range(row_length(row)):
            if grid[row][col] is not None:
                draw_piece(grid[row][col], spot_x(row, col), spot_y(row))

    for piece in popping:
        grow = 1 + piece["time"] * 2
        draw_piece(piece["kind"], piece["x"], piece["y"], grow,
                   1 - piece["time"] * 4)
    for piece in falling:
        draw_piece(piece["kind"], piece["x"], piece["y"])

    # The launcher: a line pointing where the shot will go.
    if not busy:
        tip = LAUNCH + Vec2.fromAngle(aim) * 60
        drawLine(p1=LAUNCH, p2=tip, width=6, color=(230, 230, 240))
        if shot is None:
            draw_piece(loaded, LAUNCH.x, LAUNCH.y)
    if shot is not None:
        draw_piece(shot["kind"], shot["pos"].x, shot["pos"].y)

    drawText(text="Next", pos=vec2(BOARD_X + 10, LAUNCH.y - 50), size=18)
    if not busy:
        draw_piece(next_up, BOARD_X + 30, LAUNCH.y, 0.8)

    # The words along the top, and the danger meter: one box per miss.
    drawText(text="Pop Drop", pos=vec2(BOARD_X, 14), size=30)
    drawText(text="Level " + str(level), pos=vec2(BOARD_X + 260, 20),
             size=22)
    drawText(text="Score " + str(score), pos=vec2(BOARD_X, 58), size=20)
    drawText(text="Best " + str(best), pos=vec2(BOARD_X + 150, 58), size=20)

    allowed = misses_allowed()
    for i in range(allowed):
        if i < misses:
            colour = (240, 90, 90)
        else:
            colour = (70, 66, 100)
        drawRect(pos=vec2(BOARD_X + BOARD_W - allowed * 16 + i * 16, 62),
                 width=12, height=16, color=colour, radius=3)


# ------------------------------------------------------- 10. start the game

new_game()
say("Turn the launcher with the left and right arrows. "
    "Fire with space.\n\n"
    "Three of a kind touching pop. Don't let the pieces reach the red line!",
    button="Play")

Make it yours

Open the whole game in the playground → It goes straight into the editor, ready to change and run.

  • Change how it feels. SHOT_SPEED and TURN_SPEED are in the settings. A slower shot is easier to follow off the walls.
  • Change the difficulty. misses_allowed() decides how often the ceiling comes down. max(4, 8 - level) never goes below 4; make it max(6, 10 - level) for an easier game.
  • A new kind of piece. Load another picture, say loadSprite("grape", "images/grape.png"), add "grape" to KINDS, and give it an entry in PIECES. Forget the PIECES entry and the game stops with KeyError: 'grape' the first time one is drawn, because there's no colour or size to look up.
  • Need four to pop. Change len(group) >= 3 in settle to 4. It's much harder, and the next piece matters a lot more.