Building Garden Guard

Play it first → Zombies walk across a lawn towards your house, and you stop them with plants. Plants cost sun, so you collect the suns that fall from the sky and plant pineapples to make more. Beans shoot peas down their row. There are eight levels: each of the first four brings a new plant, and each of the last four a new kind of zombie. It's a game in the style of Plants vs. Zombies, and it's played entirely by clicking, or by tapping on a phone or tablet held sideways.

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: objects that carry their own values

Everything that moves is a game object, made with add(): the plants, the zombies, the peas and the suns. And each one carries a few values of its own, stored on it with a dot, as if it were a variable:

zombie.row = 2
zombie.walk_speed = 16
plant.timer = 1.4

So a zombie knows which row it's in, and a plant knows how long until it shoots again. Then one @onUpdate("zombie") function runs for every zombie each frame and reads that zombie's own values. A hundred zombies, one function.

Pick names that kaypy isn't already using for something. Components put values on an object too: move() gives it a speed, health() gives it hp. Set pea.speed and you've changed how fast move() moves it. That's why the zombies use walk_speed.

The lawn itself is a list of lists, grid[row][col], with the plant in each square or None. It answers the question the zombies keep asking: is there a plant in front of me?

Step 1 — the lawn

from kaypy import *

COLS = 9           # squares across the lawn
ROWS = 5           # rows of lawn
CELL = 80          # a square is 80 pixels wide
ROW_H = 90         # and 90 tall

LAWN_X = 90        # the lawn starts here; left of it is the house
LAWN_Y = 120       # and here, under the top bar

kaypy(width=LAWN_X + COLS * CELL + 50, height=LAWN_Y + ROWS * ROW_H + 20,
      background=[60, 45, 35])

loadSprite("sun", "images/sun.png")

sun = 50

# The lawn is made of rectangles that are game objects, not drawn in
# onDraw. onDraw draws on top of every object, so a lawn drawn there would
# cover the plants and the zombies. z(-10) puts these behind everything.
for row in range(ROWS):
    for col in range(COLS):
        if (row + col) % 2 == 0:
            green = (90, 170, 70)
        else:
            green = (80, 155, 62)
        add([rect(CELL, ROW_H), pos(LAWN_X + col * CELL, LAWN_Y + row * ROW_H),
             color(green[0], green[1], green[2]), z(-10)])


@onDraw
def draw_everything():
    # The top bar, and how much sun you have.
    drawRect(pos=vec2(0, 0), width=width(), height=LAWN_Y - 8,
             color=(110, 75, 45))
    drawRect(pos=vec2(8, 8), width=78, height=96, color=(80, 55, 35),
             radius=8)
    drawSprite(sprite="sun", pos=vec2(47, 42), width=50, height=50,
               anchor="center")
    drawText(text=str(sun), pos=vec2(47, 88), size=22, anchor="center")

Run it, and there's a striped lawn and a top bar showing 50 sun.

  • The lawn is 45 rectangles, one per square, made with add() in two loops. (row + col) % 2 is 0 or 1 by turns along a row and down a column, which is what makes the squares alternate like a chessboard.
  • The top bar is drawn in @onDraw, which runs every frame, after everything else.

Why the lawn isn't drawn in onDraw too. onDraw draws on top of every game object. A lawn drawn there would cover the plants and the zombies, and the screen would show nothing but grass. So the lawn is made of objects, and z(-10) puts them behind everything else. The top bar is fine in onDraw, because nothing should ever be drawn over it.

Step 2 — sun

from kaypy import *

COLS = 9           # squares across the lawn
ROWS = 5           # rows of lawn
CELL = 80          # a square is 80 pixels wide
ROW_H = 90         # and 90 tall

LAWN_X = 90        # the lawn starts here; left of it is the house
LAWN_Y = 120       # and here, under the top bar

kaypy(width=LAWN_X + COLS * CELL + 50, height=LAWN_Y + ROWS * ROW_H + 20,
      background=[60, 45, 35])

loadSprite("sun", "images/sun.png")

SUN_VALUE = 25          # what one sun is worth
SKY_SUN_EVERY = 9       # seconds between suns falling from the sky
SUN_LIFE = 12           # seconds a sun stays before it fades away

sun = 50

# The lawn is made of rectangles that are game objects, not drawn in
# onDraw. onDraw draws on top of every object, so a lawn drawn there would
# cover the plants and the zombies. z(-10) puts these behind everything.
for row in range(ROWS):
    for col in range(COLS):
        if (row + col) % 2 == 0:
            green = (90, 170, 70)
        else:
            green = (80, 155, 62)
        add([rect(CELL, ROW_H), pos(LAWN_X + col * CELL, LAWN_Y + row * ROW_H),
             color(green[0], green[1], green[2]), z(-10)])


def make_sun(x, start_y, land_y):
    """A sun that drifts down to land_y, waits, and fades away."""
    s = add([sprite("sun"), pos(x, start_y), anchor("center"), scale(0.8),
             opacity(1), lifespan(SUN_LIFE, fade=2), z(20), "sun"])
    s.land_y = land_y


@onUpdate("sun")
def drift(s):
    if s.pos.y < s.land_y:
        s.pos.y += 60 * dt()


@loop(SKY_SUN_EVERY)
def sun_from_the_sky():
    # It starts under the top bar, so it seems to come out from behind it.
    x = rand(LAWN_X + 30, LAWN_X + COLS * CELL - 30)
    make_sun(x, LAWN_Y - 40, rand(LAWN_Y + 40, LAWN_Y + ROWS * ROW_H - 40))


@onMousePress
def click():
    global sun

    m = mousePos()
    for s in get("sun"):
        if m.dist(s.pos) < 35:
            sun = sun + SUN_VALUE
            s.destroy()
            return


@onDraw
def draw_everything():
    # The top bar, and how much sun you have.
    drawRect(pos=vec2(0, 0), width=width(), height=LAWN_Y - 8,
             color=(110, 75, 45))
    drawRect(pos=vec2(8, 8), width=78, height=96, color=(80, 55, 35),
             radius=8)
    drawSprite(sprite="sun", pos=vec2(47, 42), width=50, height=50,
               anchor="center")
    drawText(text=str(sun), pos=vec2(47, 88), size=22, anchor="center")

Every 9 seconds a sun drifts down onto the lawn. Click it to collect it, and the count goes up by 25. Leave it, and it fades away.

  • @loop(SKY_SUN_EVERY) runs sun_from_the_sky every 9 seconds. It picks a random spot across the lawn and a random height to land at. The sun starts at LAWN_Y - 40, under the top bar, so it seems to come out from behind it.
  • s.land_y = land_y is the first value an object carries for itself. drift runs for every sun, every frame, and moves it down until it reaches its own land_y.
  • lifespan(SUN_LIFE, fade=2) takes the sun away after 12 seconds, fading it out over the last 2. The fade works by changing the sun's opacity, so the sun needs opacity(1) to have one. Without it, the sun just vanishes.
  • click runs when the mouse button goes down, or a finger touches the screen. m.dist(s.pos) is how far the click was from a sun's middle, and closer than 35 pixels counts.

click changes sun, which lives outside it, so it has to say global sun first. Leave that out and the first click on a sun stops the game with:

UnboundLocalError: cannot access local variable 'sun' where it is not associated with a value

Without global, sun = sun + SUN_VALUE makes a new sun that belongs to click alone, and then asks what's in it before anything has been put there.

Step 3 — planting

from kaypy import *

COLS = 9           # squares across the lawn
ROWS = 5           # rows of lawn
CELL = 80          # a square is 80 pixels wide
ROW_H = 90         # and 90 tall

LAWN_X = 90        # the lawn starts here; left of it is the house
LAWN_Y = 120       # and here, under the top bar

kaypy(width=LAWN_X + COLS * CELL + 50, height=LAWN_Y + ROWS * ROW_H + 20,
      background=[60, 45, 35])

loadSprite("sun", "images/sun.png")
loadSprite("pineapple", "images/pineapple.png")
loadSprite("bean", "images/bean.png")

SUN_VALUE = 25          # what one sun is worth
SKY_SUN_EVERY = 9       # seconds between suns falling from the sky
SUN_LIFE = 12           # seconds a sun stays before it fades away

# Each plant: what it costs, how long before you can plant another, and how
# often it does its job, in seconds. "scale" shrinks the picture to fit a
# square; "w" and "h" are the size of the picture file itself, which the
# cards need to draw it.
PLANTS = {
    "pineapple": {"cost": 50, "wait": 5, "every": 12,
                  "scale": 0.75, "w": 64, "h": 80},
    "bean": {"cost": 100, "wait": 5, "every": 1.4,
             "scale": 0.9, "w": 64, "h": 54},
}
CARDS = ["pineapple", "bean"]

sun = 50
selected = None      # the name of the plant card picked, or None
ready_at = {}        # plant name -> the time() its card is ready again

# grid[row][col] is the plant in that square, or None.
grid = []
for row in range(ROWS):
    grid.append([None] * COLS)

# The lawn is made of rectangles that are game objects, not drawn in
# onDraw. onDraw draws on top of every object, so a lawn drawn there would
# cover the plants and the zombies. z(-10) puts these behind everything.
for row in range(ROWS):
    for col in range(COLS):
        if (row + col) % 2 == 0:
            green = (90, 170, 70)
        else:
            green = (80, 155, 62)
        add([rect(CELL, ROW_H), pos(LAWN_X + col * CELL, LAWN_Y + row * ROW_H),
             color(green[0], green[1], green[2]), z(-10)])


def square_x(col):
    """The middle of a square, across."""
    return LAWN_X + col * CELL + CELL / 2


def row_y(row):
    """The middle of a row, down."""
    return LAWN_Y + row * ROW_H + ROW_H / 2


def make_sun(x, start_y, land_y):
    """A sun that drifts down to land_y, waits, and fades away."""
    s = add([sprite("sun"), pos(x, start_y), anchor("center"), scale(0.8),
             opacity(1), lifespan(SUN_LIFE, fade=2), z(20), "sun"])
    s.land_y = land_y


@onUpdate("sun")
def drift(s):
    if s.pos.y < s.land_y:
        s.pos.y += 60 * dt()


@loop(SKY_SUN_EVERY)
def sun_from_the_sky():
    # It starts under the top bar, so it seems to come out from behind it.
    x = rand(LAWN_X + 30, LAWN_X + COLS * CELL - 30)
    make_sun(x, LAWN_Y - 40, rand(LAWN_Y + 40, LAWN_Y + ROWS * ROW_H - 40))


CARD_X = 95
CARD_W = 78
CARD_GAP = 6


def card_x(i):
    return CARD_X + i * (CARD_W + CARD_GAP)


def card_ready(name):
    return time() >= ready_at.get(name, 0)


# One handler for every click or tap. It asks, in order: was that a sun?
# a card? the lawn? A sun comes first because suns float over the lawn,
# and a tap on one should collect it, not plant something under it.
@onMousePress
def click():
    global sun, selected

    m = mousePos()
    for s in get("sun"):
        if m.dist(s.pos) < 35:
            sun = sun + SUN_VALUE
            s.destroy()
            return

    if m.y < LAWN_Y:
        for i in range(len(CARDS)):
            if card_x(i) <= m.x < card_x(i) + CARD_W:
                name = CARDS[i]
                if selected == name:
                    selected = None            # tap again to put it back
                elif card_ready(name) and sun >= PLANTS[name]["cost"]:
                    selected = name
        return

    col = int((m.x - LAWN_X) // CELL)
    row = int((m.y - LAWN_Y) // ROW_H)
    if selected is None or col < 0 or col >= COLS or row < 0 or row >= ROWS:
        return
    if grid[row][col] is not None:
        return

    put_plant(selected, row, col)
    sun = sun - PLANTS[selected]["cost"]
    ready_at[selected] = time() + PLANTS[selected]["wait"]
    selected = None


def put_plant(name, row, col):
    info = PLANTS[name]
    plant = add([sprite(name), pos(square_x(col), row_y(row)),
                 anchor("center"), scale(info["scale"]), z(1), "plant"])
    plant.kind = name
    plant.row = row
    plant.col = col
    plant.timer = info["every"]
    if name == "pineapple":
        plant.timer = 5          # the first sun comes sooner
    grid[row][col] = plant


@onUpdate("plant")
def plant_work(plant):
    plant.timer -= dt()
    if plant.timer > 0:
        return

    if plant.kind == "pineapple":
        make_sun(plant.pos.x + 15, plant.pos.y - 10, plant.pos.y + 20)
        plant.timer = PLANTS["pineapple"]["every"]


@onDraw
def draw_everything():
    # The top bar, and how much sun you have.
    drawRect(pos=vec2(0, 0), width=width(), height=LAWN_Y - 8,
             color=(110, 75, 45))
    drawRect(pos=vec2(8, 8), width=78, height=96, color=(80, 55, 35),
             radius=8)
    drawSprite(sprite="sun", pos=vec2(47, 42), width=50, height=50,
               anchor="center")
    drawText(text=str(sun), pos=vec2(47, 88), size=22, anchor="center")

    # A card for each plant.
    for i in range(len(CARDS)):
        name = CARDS[i]
        info = PLANTS[name]
        x = card_x(i)
        drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                 color=(235, 220, 170), radius=8)
        if selected == name:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(255, 240, 60), radius=8, outline=4)
        drawSprite(sprite=name, pos=vec2(x + CARD_W / 2, 46),
                   width=info["w"] * info["scale"] * 0.75,
                   height=info["h"] * info["scale"] * 0.75, anchor="center")
        drawText(text=str(info["cost"]), pos=vec2(x + CARD_W / 2, 90),
                 size=18, color=(60, 40, 20), anchor="center")

        # Grey while it is waiting to be ready, shrinking as it gets
        # nearer; dim if you cannot afford it.
        if not card_ready(name):
            left = (ready_at[name] - time()) / info["wait"]
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96 * left,
                     color=(40, 40, 40), opacity=0.6, radius=8)
        elif sun < info["cost"]:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(40, 40, 40), opacity=0.4, radius=8)

Now there are two cards in the top bar. Click the pineapple card, then a square of lawn, and a pineapple grows there. Every 12 seconds it makes a sun of its own. Beans can be planted too, but they don't do anything yet.

The cards. PLANTS is a dictionary of dictionaries: everything about each plant in one place. click now asks three questions in order. Was it a sun? Was it a card? Was it the lawn? Suns come first because they float over the lawn, and a click on one should collect it, not plant something underneath.

Clicking a card selects it, but only if you can afford it and it's ready. Clicking the lawn plants whatever is selected, if that square is empty. Click a selected card again to put it back.

Waiting. After you plant something, ready_at[name] is set to time() plus the wait, the moment the card is ready again. card_ready compares the time now with that. ready_at.get(name, 0) means "or 0 if it isn't in the dictionary yet", so every card is ready at the start. The grey part of a card shrinks as the time runs out: left goes from 1 down to 0.

Plants are objects that carry their kind, their row and column, and a timer. plant_work runs for every plant, every frame, counting its timer down. When a pineapple's reaches zero, it makes a sun beside itself and starts counting again.

Turning a click into a square. (m.x - LAWN_X) // CELL is how many whole squares the click is from the lawn's left edge: // divides and throws away the remainder. The same for the row. A click left of the lawn gives a negative column, and one past the right edge a column of 9 or more; the if after it ignores both.

Step 4 — zombies

from kaypy import *

COLS = 9           # squares across the lawn
ROWS = 5           # rows of lawn
CELL = 80          # a square is 80 pixels wide
ROW_H = 90         # and 90 tall

LAWN_X = 90        # the lawn starts here; left of it is the house
LAWN_Y = 120       # and here, under the top bar

kaypy(width=LAWN_X + COLS * CELL + 50, height=LAWN_Y + ROWS * ROW_H + 20,
      background=[60, 45, 35])

loadSprite("sun", "images/sun.png")
loadSprite("pineapple", "images/pineapple.png")
loadSprite("bean", "images/bean.png")

# The zombie is a strip of tiny pictures, one frame after another.
loadSprite("zombie", "dungeon/zombie.png", sliceX=4, anims={
    "walk": {"from": 0, "to": 3, "speed": 6, "loop": True},
})

SUN_VALUE = 25          # what one sun is worth
SKY_SUN_EVERY = 9       # seconds between suns falling from the sky
SUN_LIFE = 12           # seconds a sun stays before it fades away
BITE_TIME = 0.6         # seconds between a zombie's bites
ZOMBIE_EVERY = 10       # seconds between zombies, for now

# Each plant: what it costs, how long before you can plant another, how
# many bites it takes to eat, and how often it does its job, in seconds. "scale" shrinks the picture to fit a
# square; "w" and "h" are the size of the picture file itself, which the
# cards need to draw it.
PLANTS = {
    "pineapple": {"cost": 50, "wait": 5, "hp": 6, "every": 12,
                  "scale": 0.75, "w": 64, "h": 80},
    "bean": {"cost": 100, "wait": 5, "hp": 6, "every": 1.4,
             "scale": 0.9, "w": 64, "h": 54},
}
CARDS = ["pineapple", "bean"]

# Each zombie: how many hits it takes, and how fast it walks.
ZOMBIES = {
    "zombie": {"hp": 10, "speed": 16, "scale": 4},
}

sun = 50
selected = None      # the name of the plant card picked, or None
ready_at = {}        # plant name -> the time() its card is ready again
over = False         # True once the zombies are in

# grid[row][col] is the plant in that square, or None.
grid = []

# The lawn is made of rectangles that are game objects, not drawn in
# onDraw. onDraw draws on top of every object, so a lawn drawn there would
# cover the plants and the zombies. z(-10) puts these behind everything.
for row in range(ROWS):
    for col in range(COLS):
        if (row + col) % 2 == 0:
            green = (90, 170, 70)
        else:
            green = (80, 155, 62)
        add([rect(CELL, ROW_H), pos(LAWN_X + col * CELL, LAWN_Y + row * ROW_H),
             color(green[0], green[1], green[2]), z(-10)])


def start_level():
    global sun, selected, ready_at, over, grid

    destroyAll("plant")
    destroyAll("zombie")
    destroyAll("sun")

    sun = 50
    selected = None
    ready_at = {}
    over = False
    grid = []
    for row in range(ROWS):
        grid.append([None] * COLS)


def level_lost():
    global over

    over = True
    shake(10)

    @say("The zombies got into the house!", button="Try again")
    def try_again():
        start_level()


def square_x(col):
    """The middle of a square, across."""
    return LAWN_X + col * CELL + CELL / 2


def row_y(row):
    """The middle of a row, down."""
    return LAWN_Y + row * ROW_H + ROW_H / 2


def make_sun(x, start_y, land_y):
    """A sun that drifts down to land_y, waits, and fades away."""
    s = add([sprite("sun"), pos(x, start_y), anchor("center"), scale(0.8),
             opacity(1), lifespan(SUN_LIFE, fade=2), z(20), "sun"])
    s.land_y = land_y


@onUpdate("sun")
def drift(s):
    if s.pos.y < s.land_y:
        s.pos.y += 60 * dt()


@loop(SKY_SUN_EVERY)
def sun_from_the_sky():
    # It starts under the top bar, so it seems to come out from behind it.
    x = rand(LAWN_X + 30, LAWN_X + COLS * CELL - 30)
    make_sun(x, LAWN_Y - 40, rand(LAWN_Y + 40, LAWN_Y + ROWS * ROW_H - 40))


CARD_X = 95
CARD_W = 78
CARD_GAP = 6


def card_x(i):
    return CARD_X + i * (CARD_W + CARD_GAP)


def card_ready(name):
    return time() >= ready_at.get(name, 0)


# One handler for every click or tap. It asks, in order: was that a sun?
# a card? the lawn? A sun comes first because suns float over the lawn,
# and a tap on one should collect it, not plant something under it.
@onMousePress
def click():
    global sun, selected

    if over:
        return
    m = mousePos()
    for s in get("sun"):
        if m.dist(s.pos) < 35:
            sun = sun + SUN_VALUE
            s.destroy()
            return

    if m.y < LAWN_Y:
        for i in range(len(CARDS)):
            if card_x(i) <= m.x < card_x(i) + CARD_W:
                name = CARDS[i]
                if selected == name:
                    selected = None            # tap again to put it back
                elif card_ready(name) and sun >= PLANTS[name]["cost"]:
                    selected = name
        return

    col = int((m.x - LAWN_X) // CELL)
    row = int((m.y - LAWN_Y) // ROW_H)
    if selected is None or col < 0 or col >= COLS or row < 0 or row >= ROWS:
        return
    if grid[row][col] is not None:
        return

    put_plant(selected, row, col)
    sun = sun - PLANTS[selected]["cost"]
    ready_at[selected] = time() + PLANTS[selected]["wait"]
    selected = None


def put_plant(name, row, col):
    info = PLANTS[name]
    plant = add([sprite(name), pos(square_x(col), row_y(row)),
                 anchor("center"), scale(info["scale"]), health(info["hp"]),
                 z(1), "plant"])
    plant.kind = name
    plant.row = row
    plant.col = col
    plant.timer = info["every"]
    if name == "pineapple":
        plant.timer = 5          # the first sun comes sooner
    grid[row][col] = plant


def remove_plant(plant):
    grid[plant.row][plant.col] = None
    plant.destroy()


@onUpdate("plant")
def plant_work(plant):
    if over:
        return
    plant.timer -= dt()
    if plant.timer > 0:
        return

    if plant.kind == "pineapple":
        make_sun(plant.pos.x + 15, plant.pos.y - 10, plant.pos.y + 20)
        plant.timer = PLANTS["pineapple"]["every"]


@loop(ZOMBIE_EVERY)
def zombie_comes():
    if not over:
        spawn("zombie")


def spawn(kind):
    info = ZOMBIES[kind]
    row = choose(range(ROWS))
    # anchor("bot") means pos is where its feet are.
    zombie = add([sprite(kind, anim="walk"),
                  pos(width() + 30, row_y(row) + ROW_H / 2 - 8),
                  anchor("bot"), scale(info["scale"]), area(),
                  health(info["hp"]), z(3 + row), "zombie"])
    zombie.flipX = True      # the pictures face right; zombies walk left
    zombie.kind = kind
    zombie.row = row
    zombie.walk_speed = info["speed"] * rand(0.9, 1.1)
    zombie.bite_wait = 0


def plant_in_front(zombie):
    """The plant in the square the zombie's face is in, or None."""
    col = int((zombie.pos.x - 20 - LAWN_X) // CELL)
    if col < 0 or col >= COLS:
        return None
    return grid[zombie.row][col]


@onUpdate("zombie")
def walk(zombie):
    if over:
        return

    plant = plant_in_front(zombie)
    if plant is not None:
        # Stop and eat.
        zombie.bite_wait -= dt()
        if zombie.bite_wait <= 0:
            plant.hurt(1)
            zombie.bite_wait = BITE_TIME
            if not plant.isAlive():
                remove_plant(plant)
    else:
        zombie.pos.x -= zombie.walk_speed * dt()

    if zombie.pos.x < 20:
        level_lost()


@onDraw
def draw_everything():
    # The top bar, and how much sun you have.
    drawRect(pos=vec2(0, 0), width=width(), height=LAWN_Y - 8,
             color=(110, 75, 45))
    drawRect(pos=vec2(8, 8), width=78, height=96, color=(80, 55, 35),
             radius=8)
    drawSprite(sprite="sun", pos=vec2(47, 42), width=50, height=50,
               anchor="center")
    drawText(text=str(sun), pos=vec2(47, 88), size=22, anchor="center")

    # A card for each plant.
    for i in range(len(CARDS)):
        name = CARDS[i]
        info = PLANTS[name]
        x = card_x(i)
        drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                 color=(235, 220, 170), radius=8)
        if selected == name:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(255, 240, 60), radius=8, outline=4)
        drawSprite(sprite=name, pos=vec2(x + CARD_W / 2, 46),
                   width=info["w"] * info["scale"] * 0.75,
                   height=info["h"] * info["scale"] * 0.75, anchor="center")
        drawText(text=str(info["cost"]), pos=vec2(x + CARD_W / 2, 90),
                 size=18, color=(60, 40, 20), anchor="center")

        # Grey while it is waiting to be ready, shrinking as it gets
        # nearer; dim if you cannot afford it.
        if not card_ready(name):
            left = (ready_at[name] - time()) / info["wait"]
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96 * left,
                     color=(40, 40, 40), opacity=0.6, radius=8)
        elif sun < info["cost"]:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(40, 40, 40), opacity=0.4, radius=8)


start_level()

Every 10 seconds a zombie walks in from the right, in a random row. It stops to eat any plant in its way, and if it reaches the house, the game is over. Nothing can fight back yet, so you will lose this one. Try pineapples in a zombie's path to slow it down.

  • The picture is a strip of 4 tiny frames, and sliceX=4 cuts it up. The "walk" animation plays them over and over. scale(4) makes the 16-pixel zombie 64 pixels tall.
  • zombie.flipX = True: the pictures face right, and these zombies walk left. Take that line out and they walk backwards.
  • anchor("bot") means pos is where its feet are, so every kind of zombie stands on the same line, however tall it is.
  • health() gives plants and zombies hit points. A zombie .hurt(1)s the plant in front of it every BITE_TIME seconds, and when .isAlive() says no, remove_plant takes it off the lawn and out of grid, so the square is free again.
  • plant_in_front turns the zombie's position into a column, the same way click does, 20 pixels in front of its middle, where its face is.

start_level() puts everything back as it started. It runs once at the very bottom of the file, and again from the Try again button. say() pauses the game while its message is up, and over stops the zombies, the plants and the clicks until the next game starts.

Step 5 — peas

from kaypy import *

COLS = 9           # squares across the lawn
ROWS = 5           # rows of lawn
CELL = 80          # a square is 80 pixels wide
ROW_H = 90         # and 90 tall

LAWN_X = 90        # the lawn starts here; left of it is the house
LAWN_Y = 120       # and here, under the top bar

kaypy(width=LAWN_X + COLS * CELL + 50, height=LAWN_Y + ROWS * ROW_H + 20,
      background=[60, 45, 35])

loadSprite("sun", "images/sun.png")
loadSprite("pineapple", "images/pineapple.png")
loadSprite("bean", "images/bean.png")

# The zombie is a strip of tiny pictures, one frame after another.
loadSprite("zombie", "dungeon/zombie.png", sliceX=4, anims={
    "walk": {"from": 0, "to": 3, "speed": 6, "loop": True},
})

SUN_VALUE = 25          # what one sun is worth
SKY_SUN_EVERY = 9       # seconds between suns falling from the sky
SUN_LIFE = 12           # seconds a sun stays before it fades away
PEA_SPEED = 320         # pixels per second
BITE_TIME = 0.6         # seconds between a zombie's bites
ZOMBIE_EVERY = 10       # seconds between zombies, for now

# Each plant: what it costs, how long before you can plant another, how
# many bites it takes to eat, and how often it does its job, in seconds. "scale" shrinks the picture to fit a
# square; "w" and "h" are the size of the picture file itself, which the
# cards need to draw it.
PLANTS = {
    "pineapple": {"cost": 50, "wait": 5, "hp": 6, "every": 12,
                  "scale": 0.75, "w": 64, "h": 80},
    "bean": {"cost": 100, "wait": 5, "hp": 6, "every": 1.4,
             "scale": 0.9, "w": 64, "h": 54},
}
CARDS = ["pineapple", "bean"]

# Each zombie: how many hits it takes, and how fast it walks.
ZOMBIES = {
    "zombie": {"hp": 10, "speed": 16, "scale": 4},
}

sun = 50
selected = None      # the name of the plant card picked, or None
ready_at = {}        # plant name -> the time() its card is ready again
over = False         # True once the zombies are in

# grid[row][col] is the plant in that square, or None.
grid = []

# The lawn is made of rectangles that are game objects, not drawn in
# onDraw. onDraw draws on top of every object, so a lawn drawn there would
# cover the plants and the zombies. z(-10) puts these behind everything.
for row in range(ROWS):
    for col in range(COLS):
        if (row + col) % 2 == 0:
            green = (90, 170, 70)
        else:
            green = (80, 155, 62)
        add([rect(CELL, ROW_H), pos(LAWN_X + col * CELL, LAWN_Y + row * ROW_H),
             color(green[0], green[1], green[2]), z(-10)])


def start_level():
    global sun, selected, ready_at, over, grid

    destroyAll("plant")
    destroyAll("zombie")
    destroyAll("pea")
    destroyAll("sun")

    sun = 50
    selected = None
    ready_at = {}
    over = False
    grid = []
    for row in range(ROWS):
        grid.append([None] * COLS)


def level_lost():
    global over

    over = True
    shake(10)

    @say("The zombies got into the house!", button="Try again")
    def try_again():
        start_level()


def square_x(col):
    """The middle of a square, across."""
    return LAWN_X + col * CELL + CELL / 2


def row_y(row):
    """The middle of a row, down."""
    return LAWN_Y + row * ROW_H + ROW_H / 2


def make_sun(x, start_y, land_y):
    """A sun that drifts down to land_y, waits, and fades away."""
    s = add([sprite("sun"), pos(x, start_y), anchor("center"), scale(0.8),
             opacity(1), lifespan(SUN_LIFE, fade=2), z(20), "sun"])
    s.land_y = land_y


@onUpdate("sun")
def drift(s):
    if s.pos.y < s.land_y:
        s.pos.y += 60 * dt()


@loop(SKY_SUN_EVERY)
def sun_from_the_sky():
    # It starts under the top bar, so it seems to come out from behind it.
    x = rand(LAWN_X + 30, LAWN_X + COLS * CELL - 30)
    make_sun(x, LAWN_Y - 40, rand(LAWN_Y + 40, LAWN_Y + ROWS * ROW_H - 40))


CARD_X = 95
CARD_W = 78
CARD_GAP = 6


def card_x(i):
    return CARD_X + i * (CARD_W + CARD_GAP)


def card_ready(name):
    return time() >= ready_at.get(name, 0)


# One handler for every click or tap. It asks, in order: was that a sun?
# a card? the lawn? A sun comes first because suns float over the lawn,
# and a tap on one should collect it, not plant something under it.
@onMousePress
def click():
    global sun, selected

    if over:
        return
    m = mousePos()
    for s in get("sun"):
        if m.dist(s.pos) < 35:
            sun = sun + SUN_VALUE
            s.destroy()
            return

    if m.y < LAWN_Y:
        for i in range(len(CARDS)):
            if card_x(i) <= m.x < card_x(i) + CARD_W:
                name = CARDS[i]
                if selected == name:
                    selected = None            # tap again to put it back
                elif card_ready(name) and sun >= PLANTS[name]["cost"]:
                    selected = name
        return

    col = int((m.x - LAWN_X) // CELL)
    row = int((m.y - LAWN_Y) // ROW_H)
    if selected is None or col < 0 or col >= COLS or row < 0 or row >= ROWS:
        return
    if grid[row][col] is not None:
        return

    put_plant(selected, row, col)
    sun = sun - PLANTS[selected]["cost"]
    ready_at[selected] = time() + PLANTS[selected]["wait"]
    selected = None


def put_plant(name, row, col):
    info = PLANTS[name]
    plant = add([sprite(name), pos(square_x(col), row_y(row)),
                 anchor("center"), scale(info["scale"]), health(info["hp"]),
                 z(1), "plant"])
    plant.kind = name
    plant.row = row
    plant.col = col
    plant.timer = info["every"]
    if name == "pineapple":
        plant.timer = 5          # the first sun comes sooner
    grid[row][col] = plant


def remove_plant(plant):
    grid[plant.row][plant.col] = None
    plant.destroy()


@onUpdate("plant")
def plant_work(plant):
    if over:
        return
    plant.timer -= dt()
    if plant.timer > 0:
        return

    if plant.kind == "pineapple":
        make_sun(plant.pos.x + 15, plant.pos.y - 10, plant.pos.y + 20)
        plant.timer = PLANTS["pineapple"]["every"]

    if plant.kind == "bean":
        if zombie_ahead(plant):
            shoot(plant)
            plant.timer = PLANTS["bean"]["every"]


def zombie_ahead(plant):
    """Is there a zombie on the lawn in this plant's row, in front of it?"""
    for zombie in get("zombie"):
        if zombie.row == plant.row and zombie.pos.x > plant.pos.x - 10:
            if zombie.pos.x < LAWN_X + COLS * CELL + 10:
                return True
    return False


def shoot(plant):
    pea = add([circle(9), pos(plant.pos.x + 25, row_y(plant.row) - 6),
               anchor("center"), color(120, 220, 80), area(),
               move(vec2(1, 0), PEA_SPEED), z(15), "pea"])
    pea.row = plant.row


@onUpdate("pea")
def pea_gone(pea):
    # Past the end of the lawn, a pea can never hit anything. Take it away,
    # or the game slowly fills up with peas nobody can see.
    if pea.pos.x > width() + 20:
        pea.destroy()


@onCollide("pea", "zombie")
def hit(pea, zombie):
    # A pea only hits zombies in its own row, and only once.
    if pea.row != zombie.row or not pea.exists():
        return
    pea.destroy()
    zombie.hurt(1)
    if not zombie.isAlive():
        zombie.destroy()


@loop(ZOMBIE_EVERY)
def zombie_comes():
    if not over:
        spawn("zombie")


def spawn(kind):
    info = ZOMBIES[kind]
    row = choose(range(ROWS))
    # anchor("bot") means pos is where its feet are.
    zombie = add([sprite(kind, anim="walk"),
                  pos(width() + 30, row_y(row) + ROW_H / 2 - 8),
                  anchor("bot"), scale(info["scale"]), area(),
                  health(info["hp"]), z(3 + row), "zombie"])
    zombie.flipX = True      # the pictures face right; zombies walk left
    zombie.kind = kind
    zombie.row = row
    zombie.walk_speed = info["speed"] * rand(0.9, 1.1)
    zombie.bite_wait = 0


def plant_in_front(zombie):
    """The plant in the square the zombie's face is in, or None."""
    col = int((zombie.pos.x - 20 - LAWN_X) // CELL)
    if col < 0 or col >= COLS:
        return None
    return grid[zombie.row][col]


@onUpdate("zombie")
def walk(zombie):
    if over:
        return

    plant = plant_in_front(zombie)
    if plant is not None:
        # Stop and eat.
        zombie.bite_wait -= dt()
        if zombie.bite_wait <= 0:
            plant.hurt(1)
            zombie.bite_wait = BITE_TIME
            if not plant.isAlive():
                remove_plant(plant)
    else:
        zombie.pos.x -= zombie.walk_speed * dt()

    if zombie.pos.x < 20:
        level_lost()


@onDraw
def draw_everything():
    # The top bar, and how much sun you have.
    drawRect(pos=vec2(0, 0), width=width(), height=LAWN_Y - 8,
             color=(110, 75, 45))
    drawRect(pos=vec2(8, 8), width=78, height=96, color=(80, 55, 35),
             radius=8)
    drawSprite(sprite="sun", pos=vec2(47, 42), width=50, height=50,
               anchor="center")
    drawText(text=str(sun), pos=vec2(47, 88), size=22, anchor="center")

    # A card for each plant.
    for i in range(len(CARDS)):
        name = CARDS[i]
        info = PLANTS[name]
        x = card_x(i)
        drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                 color=(235, 220, 170), radius=8)
        if selected == name:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(255, 240, 60), radius=8, outline=4)
        drawSprite(sprite=name, pos=vec2(x + CARD_W / 2, 46),
                   width=info["w"] * info["scale"] * 0.75,
                   height=info["h"] * info["scale"] * 0.75, anchor="center")
        drawText(text=str(info["cost"]), pos=vec2(x + CARD_W / 2, 90),
                 size=18, color=(60, 40, 20), anchor="center")

        # Grey while it is waiting to be ready, shrinking as it gets
        # nearer; dim if you cannot afford it.
        if not card_ready(name):
            left = (ready_at[name] - time()) / info["wait"]
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96 * left,
                     color=(40, 40, 40), opacity=0.6, radius=8)
        elif sun < info["cost"]:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(40, 40, 40), opacity=0.4, radius=8)


start_level()

Now beans fight back. A bean shoots a pea every 1.4 seconds, but only when there's a zombie in its row, ahead of it. Ten peas finish a zombie.

  • zombie_ahead looks through every zombie for one in the same row, to the right of the bean, and already on the lawn. Without it, beans would shoot all game long at nothing.
  • A pea is a circle with move(vec2(1, 0), PEA_SPEED), which carries it right for ever without an onUpdate of its own. It remembers its row.
  • pea_gone takes away any pea that's flown past the edge of the window. A pea out there can never hit anything, and without this, the game would slowly fill up with peas nobody can see.
  • @onCollide("pea", "zombie") runs when a pea touches a zombie. It only counts if they're in the same row, and only once: pea.exists() is false once the pea has been destroyed, so a pea touching two zombies at once can't hit both.

Both the pea and the zombie need area() for onCollide to notice them touching. Take area() off the zombie and there's no error at all: the peas fly straight through, and the zombies walk into the house.

Step 6 — waves, lawnmowers, and winning

from kaypy import *

COLS = 9           # squares across the lawn
ROWS = 5           # rows of lawn
CELL = 80          # a square is 80 pixels wide
ROW_H = 90         # and 90 tall

LAWN_X = 90        # the lawn starts here; left of it is the house
LAWN_Y = 120       # and here, under the top bar

kaypy(width=LAWN_X + COLS * CELL + 50, height=LAWN_Y + ROWS * ROW_H + 20,
      background=[60, 45, 35])

loadSprite("sun", "images/sun.png")
loadSprite("pineapple", "images/pineapple.png")
loadSprite("bean", "images/bean.png")

# The zombie is a strip of tiny pictures, one frame after another.
loadSprite("zombie", "dungeon/zombie.png", sliceX=4, anims={
    "walk": {"from": 0, "to": 3, "speed": 6, "loop": True},
})

SUN_VALUE = 25          # what one sun is worth
SKY_SUN_EVERY = 9       # seconds between suns falling from the sky
SUN_LIFE = 12           # seconds a sun stays before it fades away
PEA_SPEED = 320         # pixels per second
BITE_TIME = 0.6         # seconds between a zombie's bites

FIRST_WAVE = 20         # seconds before the first zombies come
WAVE_GAP = 16           # seconds between waves
SPAWN_GAP = 2.5         # seconds between zombies in the same wave

# Each plant: what it costs, how long before you can plant another, how
# many bites it takes to eat, and how often it does its job, in seconds. "scale" shrinks the picture to fit a
# square; "w" and "h" are the size of the picture file itself, which the
# cards need to draw it.
PLANTS = {
    "pineapple": {"cost": 50, "wait": 5, "hp": 6, "every": 12,
                  "scale": 0.75, "w": 64, "h": 80},
    "bean": {"cost": 100, "wait": 5, "hp": 6, "every": 1.4,
             "scale": 0.9, "w": 64, "h": 54},
}
CARDS = ["pineapple", "bean"]

# Each zombie: how many hits it takes, and how fast it walks.
ZOMBIES = {
    "zombie": {"hp": 10, "speed": 16, "scale": 4},
}

# The waves of zombies. A wave is a list of the zombies in it; they come
# one after another, in random rows. The last wave is the big one.
WAVES = [["zombie"], ["zombie"], ["zombie", "zombie"], ["zombie"],
         ["zombie", "zombie"],
         ["zombie", "zombie", "zombie", "zombie"]]

sun = 50
selected = None      # the name of the plant card picked, or None
ready_at = {}        # plant name -> the time() its card is ready again
over = False         # True once the zombies are in, or the level is won

# grid[row][col] is the plant in that square, or None.
grid = []

# One lawnmower per row: where it is, and whether it is waiting, going, or
# gone for good.
mowers = []

wave_number = 0      # how many waves have come so far
wave_wait = 0        # seconds until the next wave
queue = []           # zombies of this wave still to come
spawn_wait = 0       # seconds until the next one of them
banner = ""          # big words across the lawn, for a moment
banner_time = 0

# The lawn is made of rectangles that are game objects, not drawn in
# onDraw. onDraw draws on top of every object, so a lawn drawn there would
# cover the plants and the zombies. z(-10) puts these behind everything.
for row in range(ROWS):
    for col in range(COLS):
        if (row + col) % 2 == 0:
            green = (90, 170, 70)
        else:
            green = (80, 155, 62)
        add([rect(CELL, ROW_H), pos(LAWN_X + col * CELL, LAWN_Y + row * ROW_H),
             color(green[0], green[1], green[2]), z(-10)])


def start_level():
    global sun, selected, ready_at, over, grid, mowers
    global wave_number, wave_wait, queue, spawn_wait, banner, banner_time

    destroyAll("plant")
    destroyAll("zombie")
    destroyAll("pea")
    destroyAll("sun")

    sun = 50
    selected = None
    ready_at = {}
    over = False
    grid = []
    mowers = []
    for row in range(ROWS):
        grid.append([None] * COLS)
        mowers.append({"x": LAWN_X - 40, "state": "waiting"})

    wave_number = 0
    wave_wait = FIRST_WAVE
    queue = []
    spawn_wait = 0
    banner = "Here they come!"
    banner_time = 2


def level_won():
    global over

    over = True

    @say("You saved the garden!", button="Play again")
    def again():
        start_level()


def level_lost():
    global over

    over = True
    shake(10)

    @say("The zombies got into the house!", button="Try again")
    def try_again():
        start_level()


def square_x(col):
    """The middle of a square, across."""
    return LAWN_X + col * CELL + CELL / 2


def row_y(row):
    """The middle of a row, down."""
    return LAWN_Y + row * ROW_H + ROW_H / 2


def make_sun(x, start_y, land_y):
    """A sun that drifts down to land_y, waits, and fades away."""
    s = add([sprite("sun"), pos(x, start_y), anchor("center"), scale(0.8),
             opacity(1), lifespan(SUN_LIFE, fade=2), z(20), "sun"])
    s.land_y = land_y


@onUpdate("sun")
def drift(s):
    if s.pos.y < s.land_y:
        s.pos.y += 60 * dt()


@loop(SKY_SUN_EVERY)
def sun_from_the_sky():
    # It starts under the top bar, so it seems to come out from behind it.
    x = rand(LAWN_X + 30, LAWN_X + COLS * CELL - 30)
    make_sun(x, LAWN_Y - 40, rand(LAWN_Y + 40, LAWN_Y + ROWS * ROW_H - 40))


CARD_X = 95
CARD_W = 78
CARD_GAP = 6


def card_x(i):
    return CARD_X + i * (CARD_W + CARD_GAP)


def card_ready(name):
    return time() >= ready_at.get(name, 0)


# One handler for every click or tap. It asks, in order: was that a sun?
# a card? the lawn? A sun comes first because suns float over the lawn,
# and a tap on one should collect it, not plant something under it.
@onMousePress
def click():
    global sun, selected

    if over:
        return
    m = mousePos()
    for s in get("sun"):
        if m.dist(s.pos) < 35:
            sun = sun + SUN_VALUE
            s.destroy()
            return

    if m.y < LAWN_Y:
        for i in range(len(CARDS)):
            if card_x(i) <= m.x < card_x(i) + CARD_W:
                name = CARDS[i]
                if selected == name:
                    selected = None            # tap again to put it back
                elif card_ready(name) and sun >= PLANTS[name]["cost"]:
                    selected = name
        return

    col = int((m.x - LAWN_X) // CELL)
    row = int((m.y - LAWN_Y) // ROW_H)
    if selected is None or col < 0 or col >= COLS or row < 0 or row >= ROWS:
        return
    if grid[row][col] is not None:
        return

    put_plant(selected, row, col)
    sun = sun - PLANTS[selected]["cost"]
    ready_at[selected] = time() + PLANTS[selected]["wait"]
    selected = None


def put_plant(name, row, col):
    info = PLANTS[name]
    plant = add([sprite(name), pos(square_x(col), row_y(row)),
                 anchor("center"), scale(info["scale"]), health(info["hp"]),
                 z(1), "plant"])
    plant.kind = name
    plant.row = row
    plant.col = col
    plant.timer = info["every"]
    if name == "pineapple":
        plant.timer = 5          # the first sun comes sooner
    grid[row][col] = plant


def remove_plant(plant):
    grid[plant.row][plant.col] = None
    plant.destroy()


@onUpdate("plant")
def plant_work(plant):
    if over:
        return
    plant.timer -= dt()
    if plant.timer > 0:
        return

    if plant.kind == "pineapple":
        make_sun(plant.pos.x + 15, plant.pos.y - 10, plant.pos.y + 20)
        plant.timer = PLANTS["pineapple"]["every"]

    if plant.kind == "bean":
        if zombie_ahead(plant):
            shoot(plant)
            plant.timer = PLANTS["bean"]["every"]


def zombie_ahead(plant):
    """Is there a zombie on the lawn in this plant's row, in front of it?"""
    for zombie in get("zombie"):
        if zombie.row == plant.row and zombie.pos.x > plant.pos.x - 10:
            if zombie.pos.x < LAWN_X + COLS * CELL + 10:
                return True
    return False


def shoot(plant):
    pea = add([circle(9), pos(plant.pos.x + 25, row_y(plant.row) - 6),
               anchor("center"), color(120, 220, 80), area(),
               move(vec2(1, 0), PEA_SPEED), z(15), "pea"])
    pea.row = plant.row


@onUpdate("pea")
def pea_gone(pea):
    # Past the end of the lawn, a pea can never hit anything. Take it away,
    # or the game slowly fills up with peas nobody can see.
    if pea.pos.x > width() + 20:
        pea.destroy()


@onCollide("pea", "zombie")
def hit(pea, zombie):
    # A pea only hits zombies in its own row, and only once.
    if pea.row != zombie.row or not pea.exists():
        return
    pea.destroy()
    zombie.hurt(1)
    if not zombie.isAlive():
        zombie.destroy()


def spawn(kind):
    info = ZOMBIES[kind]
    row = choose(range(ROWS))
    # anchor("bot") means pos is where its feet are.
    zombie = add([sprite(kind, anim="walk"),
                  pos(width() + 30, row_y(row) + ROW_H / 2 - 8),
                  anchor("bot"), scale(info["scale"]), area(),
                  health(info["hp"]), z(3 + row), "zombie"])
    zombie.flipX = True      # the pictures face right; zombies walk left
    zombie.kind = kind
    zombie.row = row
    zombie.walk_speed = info["speed"] * rand(0.9, 1.1)
    zombie.bite_wait = 0


def plant_in_front(zombie):
    """The plant in the square the zombie's face is in, or None."""
    col = int((zombie.pos.x - 20 - LAWN_X) // CELL)
    if col < 0 or col >= COLS:
        return None
    return grid[zombie.row][col]


@onUpdate("zombie")
def walk(zombie):
    if over:
        return

    plant = plant_in_front(zombie)
    if plant is not None:
        # Stop and eat.
        zombie.bite_wait -= dt()
        if zombie.bite_wait <= 0:
            plant.hurt(1)
            zombie.bite_wait = BITE_TIME
            if not plant.isAlive():
                remove_plant(plant)
    else:
        zombie.pos.x -= zombie.walk_speed * dt()

    # At the edge of the lawn: the mower, if it is still there. Past the
    # mower: the house.
    mower = mowers[zombie.row]
    if zombie.pos.x < LAWN_X - 10 and mower["state"] == "waiting":
        mower["state"] = "going"
    if zombie.pos.x < 20:
        level_lost()


@onUpdate
def mow():
    if over:
        return
    for row in range(ROWS):
        mower = mowers[row]
        if mower["state"] == "going":
            mower["x"] += 500 * dt()
            for zombie in get("zombie"):
                if zombie.row == row and abs(zombie.pos.x - mower["x"]) < 40:
                    zombie.destroy()
            if mower["x"] > width() + 40:
                mower["state"] = "gone"


@onUpdate
def run_waves():
    global wave_number, wave_wait, queue, spawn_wait, banner, banner_time

    banner_time -= dt()
    if over:
        return

    if len(queue) > 0:
        spawn_wait -= dt()
        if spawn_wait <= 0:
            spawn(queue.pop(0))
            spawn_wait = SPAWN_GAP
    elif wave_number < len(WAVES):
        wave_wait -= dt()
        if wave_wait <= 0:
            queue = list(WAVES[wave_number])
            wave_number = wave_number + 1
            wave_wait = WAVE_GAP
            spawn_wait = 0
            if wave_number == len(WAVES):
                banner = "A big wave is coming!"
                banner_time = 3
    elif len(get("zombie")) == 0:
        level_won()


def draw_mower(x, y):
    drawRect(pos=vec2(x - 22, y - 14), width=44, height=22,
             color=(200, 50, 50), radius=5)
    drawRect(pos=vec2(x - 18, y - 30), width=6, height=18,
             color=(90, 90, 90))
    drawCircle(pos=vec2(x - 13, y + 10), radius=7, color=(30, 30, 30))
    drawCircle(pos=vec2(x + 13, y + 10), radius=7, color=(30, 30, 30))


@onDraw
def draw_everything():
    for row in range(ROWS):
        if mowers[row]["state"] != "gone":
            draw_mower(mowers[row]["x"], row_y(row) + 10)

    # The top bar, and how much sun you have.
    drawRect(pos=vec2(0, 0), width=width(), height=LAWN_Y - 8,
             color=(110, 75, 45))
    drawRect(pos=vec2(8, 8), width=78, height=96, color=(80, 55, 35),
             radius=8)
    drawSprite(sprite="sun", pos=vec2(47, 42), width=50, height=50,
               anchor="center")
    drawText(text=str(sun), pos=vec2(47, 88), size=22, anchor="center")

    # A card for each plant.
    for i in range(len(CARDS)):
        name = CARDS[i]
        info = PLANTS[name]
        x = card_x(i)
        drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                 color=(235, 220, 170), radius=8)
        if selected == name:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(255, 240, 60), radius=8, outline=4)
        drawSprite(sprite=name, pos=vec2(x + CARD_W / 2, 46),
                   width=info["w"] * info["scale"] * 0.75,
                   height=info["h"] * info["scale"] * 0.75, anchor="center")
        drawText(text=str(info["cost"]), pos=vec2(x + CARD_W / 2, 90),
                 size=18, color=(60, 40, 20), anchor="center")

        # Grey while it is waiting to be ready, shrinking as it gets
        # nearer; dim if you cannot afford it.
        if not card_ready(name):
            left = (ready_at[name] - time()) / info["wait"]
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96 * left,
                     color=(40, 40, 40), opacity=0.6, radius=8)
        elif sun < info["cost"]:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(40, 40, 40), opacity=0.4, radius=8)

    if wave_number == 0:
        progress = "Get ready!"
    else:
        progress = "Wave " + str(wave_number) + " of " + str(len(WAVES))
    drawText(text=progress, pos=vec2(width() - 20, 62), size=20,
             anchor="topright")

    if banner_time > 0:
        drawText(text=banner, pos=vec2(LAWN_X + COLS * CELL / 2,
                                       LAWN_Y + ROWS * ROW_H / 2),
                 size=44, anchor="center")


start_level()

Now it's a level you can win. The zombies come in six waves, the last one big. Stop them all and the garden is saved. And each row has a lawnmower, a last chance: the first zombie to reach it sets it off, and it clears the whole row. But only once.

The waves are a list of lists. Each inner list is the zombies in one wave. run_waves runs every frame and does one of three things:

  • If queue has zombies still to come in this wave, it counts down spawn_wait and sends the next one, 2.5 seconds apart.
  • Otherwise, if there are waves left, it counts down to the next one, then copies it into queue with list(...). A copy, because queue.pop(0) takes zombies out of it, and taking them out of WAVES itself would leave the waves empty next time.
  • Otherwise every zombie has come. Once they're all gone, you've won.

The lawnmowers are a list of dictionaries, one per row. A mower is "waiting" until a zombie gets past the edge of the lawn, then "going": mow moves it right at 500 pixels a second and destroys every zombie it reaches in its row. Off the right edge, it's "gone" and isn't drawn any more. A zombie that gets past an empty mower spot reaches the house.

The mowers are drawn in onDraw from rectangles and circles, not a picture. That's why they're dictionaries rather than objects: there's nothing to add().

The banner is big words across the lawn, for as long as banner_time is above zero. It counts down every frame, so setting it to 3 shows the words for 3 seconds.

Step 7 — the whole game

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

  • Levels. LEVELS takes the place of CARDS and WAVES: each level is a dictionary with its own plants and its own waves. level says which one is being played, and everything that used WAVES or CARDS now uses LEVELS[level]["waves"] or LEVELS[level]["plants"]. Clearing a level moves on to the next, and each level carries its own "news": the message you're shown when you reach it, about the new plant or the new zombie. The first level's news is "", because nobody reaches the first level by clearing one.
  • The level you reached is remembered, with setData when you clear one and getData at the start, so you can come back later and carry on.
  • Three more plants. The brock does nothing at all; it just takes 60 bites to eat, so zombies are stuck at it for a long time. The bomb explodes 0.8 seconds after it's planted, destroying every zombie within a square and a half of it, in its own row and the rows above and below. The onion shoots like a bean, but its peas are icy. pea.icy is True or False, and an icy pea sets the zombie's slow_left, which halves its speed until it runs out. Slowed zombies get a blue glow.
  • Five more zombies. The tiny zombie is fast and weak; the big one is slow and takes 30 peas. The skeleton is quick and takes 12. The ice zombie can't be slowed: each zombie in ZOMBIES has a "slows" that is True or False, and an icy pea only sets slow_left when it's True. The ogre takes 60 peas, but a bomb still stops it in one go.
  • A preview of the plant you're holding, faint, over the square under the mouse.
  • Sounds, and a message at the start explaining what to do.
"""Garden Guard -- plant a garden, and hold off the zombies.

    click or tap a plant card, then a square of lawn, to plant it
    click or tap a sun to collect it

Zombies walk in from the right, one row at a time, and eat any plant in
their way. Plants cost sun. Sun falls from the sky, and pineapples make more
of it, so plant pineapples first, then beans to shoot the zombies. Each row
has a lawnmower that clears the row the first time a zombie gets through --
but only the first time. If a zombie reaches the house, the level is lost.

There are eight levels. Each of the first four brings a new plant, and
every level after that a new kind of zombie. The level you reached is
remembered.

THE PLANTS

    pineapple   makes sun
    bean        shoots peas down its row
    brock       a wall: takes a long time to eat
    bomb        blows up every zombie in the squares around it
    onion       shoots icy peas that slow zombies down

THE ZOMBIES

    zombie          the ordinary kind
    tiny zombie     fast, but only takes 5 peas
    big zombie      slow, and takes 30
    skeleton        quick, and takes 12
    ice zombie      icy peas don't slow it down
    ogre            very slow, and takes 60 peas -- or one bomb

HOW THIS FILE IS PUT TOGETHER

    1. the window and the pictures
    2. the settings: plants, zombies and levels
    3. the lawn
    4. what the game has to remember
    5. levels: starting, winning, losing
    6. sun
    7. planting: the cards and the lawn
    8. what plants do
    9. zombies, and peas hitting them
   10. the lawnmowers
   11. the waves
   12. drawing the top bar
   13. start the game

Plants, zombies, peas and suns are game objects. Each one carries a few
values of its own as well -- a zombie knows its row and how fast it walks,
a plant knows how long until it next shoots.
"""

from kaypy import *

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

COLS = 9           # squares across the lawn
ROWS = 5           # rows of lawn
CELL = 80          # a square is 80 pixels wide
ROW_H = 90         # and 90 tall

LAWN_X = 90        # the lawn starts here; left of it is the house
LAWN_Y = 120       # and here, under the top bar

kaypy(width=LAWN_X + COLS * CELL + 50, height=LAWN_Y + ROWS * ROW_H + 20,
      background=[60, 45, 35])

loadSprite("sun", "images/sun.png")
loadSprite("pineapple", "images/pineapple.png")
loadSprite("bean", "images/bean.png")
loadSprite("brock", "images/brock.png")
loadSprite("bomb", "images/bomb.png")
loadSprite("onion", "images/onion.png")

# The zombies are strips of tiny pictures, one frame after another.
loadSprite("zombie", "dungeon/zombie.png", sliceX=4, anims={
    "walk": {"from": 0, "to": 3, "speed": 6, "loop": True},
})
loadSprite("tiny_zombie", "dungeon/tiny_zombie.png", sliceX=8, anims={
    "walk": {"from": 4, "to": 7, "speed": 10, "loop": True},
})
loadSprite("big_zombie", "dungeon/big_zombie.png", sliceX=8, anims={
    "walk": {"from": 4, "to": 7, "speed": 6, "loop": True},
})
loadSprite("skelet", "dungeon/skelet.png", sliceX=8, anims={
    "walk": {"from": 4, "to": 7, "speed": 10, "loop": True},
})
loadSprite("ice_zombie", "dungeon/ice_zombie.png", sliceX=4, anims={
    "walk": {"from": 0, "to": 3, "speed": 6, "loop": True},
})
loadSprite("dungeon_ogre", "dungeon/dungeon_ogre.png", sliceX=8, anims={
    "walk": {"from": 4, "to": 7, "speed": 5, "loop": True},
})

loadSound("collect", "sounds/small_beep.wav")
loadSound("plant", "sounds/thump.wav")
loadSound("boom", "sounds/small_boom.wav")
loadSound("mower", "sounds/swoosh.wav")
loadSound("win", "sounds/rising_beep.wav")
loadSound("lose", "sounds/lazer_punch.wav")


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

START_SUN = 50          # sun at the start of a level
SUN_VALUE = 25          # what one sun is worth
SKY_SUN_EVERY = 9       # seconds between suns falling from the sky
SUN_LIFE = 12           # seconds a sun stays before it fades away

PEA_SPEED = 320         # pixels per second
SLOW_TIME = 3           # how long an icy pea slows a zombie, in seconds
BITE_TIME = 0.6         # seconds between a zombie's bites

FIRST_WAVE = 20         # seconds before the first zombies come
WAVE_GAP = 16           # seconds between waves
SPAWN_GAP = 2.5         # seconds between zombies in the same wave

# Each plant: what it costs, how long before you can plant another, how
# many bites it takes to eat, and how often it does its job, in seconds.
# "scale" shrinks the picture to fit a square; "w" and "h" are the size of
# the picture file itself, which the cards need to draw it.
PLANTS = {
    "pineapple": {"cost": 50, "wait": 5, "hp": 6, "every": 12,
                  "scale": 0.75, "w": 64, "h": 80},
    "bean": {"cost": 100, "wait": 5, "hp": 6, "every": 1.4,
             "scale": 0.9, "w": 64, "h": 54},
    "brock": {"cost": 50, "wait": 20, "hp": 60, "every": 0,
              "scale": 0.55, "w": 128, "h": 102},
    "bomb": {"cost": 150, "wait": 30, "hp": 100, "every": 0.8,
             "scale": 0.65, "w": 82, "h": 92},
    "onion": {"cost": 175, "wait": 5, "hp": 6, "every": 1.4,
              "scale": 0.6, "w": 77, "h": 101},
}

# Each zombie: how many peas it takes, how fast it walks, and whether an
# icy pea slows it down.
ZOMBIES = {
    "zombie": {"hp": 10, "speed": 16, "scale": 4, "slows": True},
    "tiny_zombie": {"hp": 5, "speed": 32, "scale": 3, "slows": True},
    "big_zombie": {"hp": 30, "speed": 10, "scale": 2.2, "slows": True},
    "skelet": {"hp": 12, "speed": 22, "scale": 4, "slows": True},
    "ice_zombie": {"hp": 15, "speed": 18, "scale": 4, "slows": False},
    "dungeon_ogre": {"hp": 60, "speed": 8, "scale": 2.2, "slows": True},
}

# Each level: the plants you can use, the waves of zombies, and the news
# you're told when you reach it. A wave is a list of the zombies in it; they
# come one after another, in random rows. The last wave of every level is
# the big one.
LEVELS = [
    {"plants": ["pineapple", "bean"],
     "news": "",
     "waves": [["zombie"], ["zombie"], ["zombie", "zombie"], ["zombie"],
               ["zombie", "zombie"],
               ["zombie", "zombie", "zombie", "zombie"]]},
    {"plants": ["pineapple", "bean", "brock"],
     "news": "You have a new plant: the brock. It doesn't shoot, "
             "but it takes zombies a long time to eat.",
     "waves": [["zombie"], ["tiny_zombie"], ["zombie", "zombie"],
               ["tiny_zombie", "zombie"],
               ["zombie", "tiny_zombie", "zombie"],
               ["zombie", "zombie", "tiny_zombie", "tiny_zombie",
                "zombie"]]},
    {"plants": ["pineapple", "bean", "brock", "bomb"],
     "news": "You have a new plant: the bomb. It blows up every zombie "
             "in the squares around it.",
     "waves": [["zombie"], ["zombie", "tiny_zombie"], ["big_zombie"],
               ["zombie", "zombie", "tiny_zombie"],
               ["big_zombie", "zombie"],
               ["zombie", "tiny_zombie", "zombie", "tiny_zombie"],
               ["big_zombie", "big_zombie", "zombie", "zombie",
                "tiny_zombie", "tiny_zombie"]]},
    {"plants": ["pineapple", "bean", "brock", "bomb", "onion"],
     "news": "You have a new plant: the onion. Its icy peas slow zombies "
             "down.",
     "waves": [["zombie", "zombie"], ["tiny_zombie", "tiny_zombie"],
               ["big_zombie", "zombie"],
               ["zombie", "zombie", "tiny_zombie", "zombie"],
               ["big_zombie", "big_zombie"],
               ["tiny_zombie", "tiny_zombie", "tiny_zombie", "zombie",
                "zombie"],
               ["big_zombie", "zombie", "zombie", "big_zombie"],
               ["big_zombie", "big_zombie", "big_zombie", "zombie",
                "zombie", "zombie", "tiny_zombie", "tiny_zombie",
                "tiny_zombie"]]},
    {"plants": ["pineapple", "bean", "brock", "bomb", "onion"],
     "news": "Skeletons are coming! They're quick, and it takes 12 peas "
             "to stop one.",
     "waves": [["zombie"], ["skelet"],
               ["zombie", "tiny_zombie", "skelet"],
               ["big_zombie", "skelet"],
               ["skelet", "skelet", "zombie"],
               ["tiny_zombie", "tiny_zombie", "skelet", "zombie"],
               ["big_zombie", "skelet", "skelet", "zombie"],
               ["skelet", "skelet", "skelet", "big_zombie", "zombie",
                "zombie", "tiny_zombie", "tiny_zombie", "skelet"]]},
    {"plants": ["pineapple", "bean", "brock", "bomb", "onion"],
     "news": "Ice zombies are coming! Icy peas don't slow them down.",
     "waves": [["zombie"], ["ice_zombie"], ["skelet", "zombie"],
               ["big_zombie", "ice_zombie"],
               ["ice_zombie", "tiny_zombie", "tiny_zombie"],
               ["skelet", "ice_zombie", "zombie"],
               ["big_zombie", "ice_zombie", "skelet"],
               ["ice_zombie", "ice_zombie", "zombie", "zombie", "skelet"],
               ["ice_zombie", "ice_zombie", "ice_zombie", "big_zombie",
                "skelet", "skelet", "zombie", "zombie", "tiny_zombie"]]},
    {"plants": ["pineapple", "bean", "brock", "bomb", "onion"],
     "news": "An ogre is coming! It takes 60 peas to stop one -- "
             "or one bomb.",
     "waves": [["zombie"], ["zombie", "skelet"], ["ice_zombie", "zombie"],
               ["big_zombie", "tiny_zombie", "tiny_zombie"],
               ["dungeon_ogre"],
               ["skelet", "ice_zombie", "zombie"],
               ["big_zombie", "big_zombie", "zombie"],
               ["dungeon_ogre", "skelet", "zombie"],
               ["dungeon_ogre", "dungeon_ogre", "big_zombie", "big_zombie",
                "ice_zombie", "ice_zombie", "skelet", "skelet", "zombie",
                "zombie"]]},
    {"plants": ["pineapple", "bean", "brock", "bomb", "onion"],
     "news": "The last level: everything at once. Good luck!",
     "waves": [["zombie", "zombie", "zombie"], ["skelet", "skelet"],
               ["ice_zombie", "big_zombie"],
               ["tiny_zombie", "tiny_zombie", "tiny_zombie", "skelet"],
               ["dungeon_ogre", "zombie", "zombie"],
               ["big_zombie", "big_zombie", "ice_zombie", "skelet"],
               ["skelet", "skelet", "skelet", "ice_zombie", "ice_zombie"],
               ["dungeon_ogre", "big_zombie", "zombie", "zombie",
                "tiny_zombie", "tiny_zombie"],
               ["dungeon_ogre", "dungeon_ogre", "ice_zombie", "skelet",
                "skelet"],
               ["dungeon_ogre", "dungeon_ogre", "dungeon_ogre",
                "big_zombie", "big_zombie", "ice_zombie", "ice_zombie",
                "skelet", "skelet", "skelet", "zombie", "zombie",
                "tiny_zombie", "tiny_zombie"]]},
]

LEVEL_KEY = "garden_guard_level"


# -------------------------------------------------------------- 3. the lawn
#
# The lawn is made of rectangles that are game objects, not drawn in
# onDraw. onDraw draws on top of every object, so a lawn drawn there would
# cover the plants and the zombies. z(-10) puts these behind everything.

for row in range(ROWS):
    for col in range(COLS):
        if (row + col) % 2 == 0:
            green = (90, 170, 70)
        else:
            green = (80, 155, 62)
        add([rect(CELL, ROW_H), pos(LAWN_X + col * CELL, LAWN_Y + row * ROW_H),
             color(green[0], green[1], green[2]), z(-10)])


def square_x(col):
    """The middle of a square, across."""
    return LAWN_X + col * CELL + CELL / 2


def row_y(row):
    """The middle of a row, down."""
    return LAWN_Y + row * ROW_H + ROW_H / 2


# ------------------------------------------- 4. what the game remembers

level = getData(LEVEL_KEY, 0)   # 0 is the first level in LEVELS
sun = START_SUN
selected = None      # the name of the plant card picked, or None
ready_at = {}        # plant name -> the time() its card is ready again
over = False         # True once the zombies are in, or a level is won

# grid[row][col] is the plant in that square, or None.
grid = []

# One lawnmower per row: where it is, and whether it is waiting, going, or
# gone for good.
mowers = []

wave_number = 0      # how many waves have come so far this level
wave_wait = 0        # seconds until the next wave
queue = []           # zombies of this wave still to come
spawn_wait = 0       # seconds until the next one of them
banner = ""          # big words across the lawn, for a moment
banner_time = 0


# ------------------------------------------------------------- 5. levels

def start_level():
    global sun, selected, ready_at, over, grid, mowers
    global wave_number, wave_wait, queue, spawn_wait, banner, banner_time

    destroyAll("plant")
    destroyAll("zombie")
    destroyAll("pea")
    destroyAll("sun")

    sun = START_SUN
    selected = None
    ready_at = {}
    over = False
    grid = []
    mowers = []
    for row in range(ROWS):
        grid.append([None] * COLS)
        mowers.append({"x": LAWN_X - 40, "state": "waiting"})

    wave_number = 0
    wave_wait = FIRST_WAVE
    queue = []
    spawn_wait = 0
    banner = "Level " + str(level + 1)
    banner_time = 2


def level_won():
    global over, level

    over = True
    play("win")
    if level + 1 < len(LEVELS):
        level = level + 1
        setData(LEVEL_KEY, level)

        @say("Level " + str(level) + " cleared!\n\n" + LEVELS[level]["news"],
             button="Next level")
        def next_level():
            start_level()
    else:
        level = 0
        setData(LEVEL_KEY, level)

        @say("You saved the garden!\n\nEvery level cleared.",
             button="Play again")
        def again():
            start_level()


def level_lost():
    global over

    over = True
    play("lose")
    shake(10)

    @say("The zombies got into the house!", button="Try again")
    def try_again():
        start_level()


# ------------------------------------------------------------------ 6. sun

def make_sun(x, start_y, land_y):
    """A sun that drifts down to land_y, waits, and fades away."""
    s = add([sprite("sun"), pos(x, start_y), anchor("center"), scale(0.8),
             opacity(1), lifespan(SUN_LIFE, fade=2), z(20), "sun"])
    s.land_y = land_y


@onUpdate("sun")
def drift(s):
    if s.pos.y < s.land_y:
        s.pos.y += 60 * dt()


@loop(SKY_SUN_EVERY)
def sun_from_the_sky():
    # It starts under the top bar, so it seems to come out from behind it.
    x = rand(LAWN_X + 30, LAWN_X + COLS * CELL - 30)
    make_sun(x, LAWN_Y - 40, rand(LAWN_Y + 40, LAWN_Y + ROWS * ROW_H - 40))


# ------------------------------------- 7. planting: the cards and the lawn
#
# One handler for every click or tap. It asks, in order: was that a sun?
# a card? the lawn? A sun comes first because suns float over the lawn,
# and a tap on one should collect it, not plant something under it.

CARD_X = 95
CARD_W = 78
CARD_GAP = 6


def card_x(i):
    return CARD_X + i * (CARD_W + CARD_GAP)


def card_ready(name):
    return time() >= ready_at.get(name, 0)


@onMousePress
def click():
    global sun, selected

    if over:
        return
    m = mousePos()

    for s in get("sun"):
        if m.dist(s.pos) < 35:
            sun = sun + SUN_VALUE
            s.destroy()
            play("collect")
            return

    plants = LEVELS[level]["plants"]
    if m.y < LAWN_Y:
        for i in range(len(plants)):
            if card_x(i) <= m.x < card_x(i) + CARD_W:
                name = plants[i]
                if selected == name:
                    selected = None            # tap again to put it back
                elif card_ready(name) and sun >= PLANTS[name]["cost"]:
                    selected = name
        return

    col = int((m.x - LAWN_X) // CELL)
    row = int((m.y - LAWN_Y) // ROW_H)
    if selected is None or col < 0 or col >= COLS or row < 0 or row >= ROWS:
        return
    if grid[row][col] is not None:
        return

    put_plant(selected, row, col)
    sun = sun - PLANTS[selected]["cost"]
    ready_at[selected] = time() + PLANTS[selected]["wait"]
    selected = None
    play("plant")


def put_plant(name, row, col):
    info = PLANTS[name]
    plant = add([sprite(name), pos(square_x(col), row_y(row)),
                 anchor("center"), scale(info["scale"]), health(info["hp"]),
                 z(1), "plant"])
    plant.kind = name
    plant.row = row
    plant.col = col
    plant.timer = info["every"]
    if name == "pineapple":
        plant.timer = 5          # the first sun comes sooner
    grid[row][col] = plant


def remove_plant(plant):
    grid[plant.row][plant.col] = None
    plant.destroy()


# --------------------------------------------------- 8. what plants do

@onUpdate("plant")
def plant_work(plant):
    if over:
        return
    plant.timer -= dt()
    if plant.timer > 0:
        return

    if plant.kind == "pineapple":
        make_sun(plant.pos.x + 15, plant.pos.y - 10, plant.pos.y + 20)
        plant.timer = PLANTS["pineapple"]["every"]

    if plant.kind == "bean" or plant.kind == "onion":
        if zombie_ahead(plant):
            shoot(plant)
            plant.timer = PLANTS[plant.kind]["every"]

    if plant.kind == "bomb":
        explode(plant)


def zombie_ahead(plant):
    """Is there a zombie on the lawn in this plant's row, in front of it?"""
    for zombie in get("zombie"):
        if zombie.row == plant.row and zombie.pos.x > plant.pos.x - 10:
            if zombie.pos.x < LAWN_X + COLS * CELL + 10:
                return True
    return False


def shoot(plant):
    if plant.kind == "onion":
        colour = (160, 220, 255)
    else:
        colour = (120, 220, 80)
    pea = add([circle(9), pos(plant.pos.x + 25, row_y(plant.row) - 6),
               anchor("center"), color(colour[0], colour[1], colour[2]),
               area(), move(vec2(1, 0), PEA_SPEED), z(15), "pea"])
    pea.row = plant.row
    pea.icy = plant.kind == "onion"


@onUpdate("pea")
def pea_gone(pea):
    # Past the end of the lawn, a pea can never hit anything. Take it away,
    # or the game slowly fills up with peas nobody can see.
    if pea.pos.x > width() + 20:
        pea.destroy()


def explode(bomb):
    addKaboom(bomb.pos, scale=1.5)
    play("boom")
    shake(6)
    for zombie in get("zombie"):
        if abs(zombie.row - bomb.row) <= 1:
            if abs(zombie.pos.x - bomb.pos.x) < CELL * 1.5:
                zombie.destroy()
    remove_plant(bomb)


# ------------------------------------- 9. zombies, and peas hitting them

def spawn(kind):
    info = ZOMBIES[kind]
    row = choose(range(ROWS))
    # anchor("bot") means pos is where its feet are.
    zombie = add([sprite(kind, anim="walk"),
                  pos(width() + 30, row_y(row) + ROW_H / 2 - 8),
                  anchor("bot"), scale(info["scale"]), area(),
                  health(info["hp"]), z(3 + row), "zombie"])
    zombie.flipX = True      # the pictures face right; zombies walk left
    zombie.kind = kind
    zombie.row = row
    zombie.walk_speed = info["speed"] * rand(0.9, 1.1)
    zombie.slow_left = 0
    zombie.bite_wait = 0


def plant_in_front(zombie):
    """The plant in the square the zombie's face is in, or None."""
    col = int((zombie.pos.x - 20 - LAWN_X) // CELL)
    if col < 0 or col >= COLS:
        return None
    return grid[zombie.row][col]


@onUpdate("zombie")
def walk(zombie):
    if over:
        return

    speed = zombie.walk_speed
    if zombie.slow_left > 0:
        zombie.slow_left -= dt()
        speed = speed / 2

    plant = plant_in_front(zombie)
    if plant is not None:
        # Stop and eat.
        zombie.bite_wait -= dt()
        if zombie.bite_wait <= 0:
            plant.hurt(1)
            zombie.bite_wait = BITE_TIME
            if not plant.isAlive():
                remove_plant(plant)
    else:
        zombie.pos.x -= speed * dt()

    # At the edge of the lawn: the mower, if it is still there. Past the
    # mower: the house.
    mower = mowers[zombie.row]
    if zombie.pos.x < LAWN_X - 10 and mower["state"] == "waiting":
        mower["state"] = "going"
        play("mower")
    if zombie.pos.x < 20:
        level_lost()


@onCollide("pea", "zombie")
def hit(pea, zombie):
    # A pea only hits zombies in its own row, and only once.
    if pea.row != zombie.row or not pea.exists():
        return
    pea.destroy()
    zombie.hurt(1)
    if pea.icy and ZOMBIES[zombie.kind]["slows"]:
        zombie.slow_left = SLOW_TIME
    if not zombie.isAlive():
        zombie.destroy()


# --------------------------------------------------------- 10. lawnmowers

@onUpdate
def mow():
    if over:
        return
    for row in range(ROWS):
        mower = mowers[row]
        if mower["state"] == "going":
            mower["x"] += 500 * dt()
            for zombie in get("zombie"):
                if zombie.row == row and abs(zombie.pos.x - mower["x"]) < 40:
                    zombie.destroy()
            if mower["x"] > width() + 40:
                mower["state"] = "gone"


# -------------------------------------------------------------- 11. waves

@onUpdate
def run_waves():
    global wave_number, wave_wait, queue, spawn_wait, banner, banner_time

    banner_time -= dt()
    if over:
        return
    waves = LEVELS[level]["waves"]

    if len(queue) > 0:
        spawn_wait -= dt()
        if spawn_wait <= 0:
            spawn(queue.pop(0))
            spawn_wait = SPAWN_GAP
    elif wave_number < len(waves):
        wave_wait -= dt()
        if wave_wait <= 0:
            queue = list(waves[wave_number])
            wave_number = wave_number + 1
            wave_wait = WAVE_GAP
            spawn_wait = 0
            if wave_number == len(waves):
                banner = "A big wave is coming!"
                banner_time = 3
    elif len(get("zombie")) == 0:
        level_won()


# ------------------------------------------------------ 12. the top bar

def draw_mower(x, y):
    drawRect(pos=vec2(x - 22, y - 14), width=44, height=22,
             color=(200, 50, 50), radius=5)
    drawRect(pos=vec2(x - 18, y - 30), width=6, height=18,
             color=(90, 90, 90))
    drawCircle(pos=vec2(x - 13, y + 10), radius=7, color=(30, 30, 30))
    drawCircle(pos=vec2(x + 13, y + 10), radius=7, color=(30, 30, 30))


@onDraw
def draw_everything():
    for row in range(ROWS):
        if mowers[row]["state"] != "gone":
            draw_mower(mowers[row]["x"], row_y(row) + 10)

    # Slowed zombies get a blue glow.
    for zombie in get("zombie"):
        if zombie.slow_left > 0:
            drawCircle(pos=vec2(zombie.pos.x, zombie.pos.y - 30), radius=30,
                       color=(120, 190, 255), opacity=0.35)

    # The plant you are holding, over the square under the mouse.
    m = mousePos()
    if selected is not None and m.y > LAWN_Y and m.x > LAWN_X:
        col = int((m.x - LAWN_X) // CELL)
        row = int((m.y - LAWN_Y) // ROW_H)
        if col < COLS and row < ROWS:
            info = PLANTS[selected]
            drawSprite(sprite=selected, pos=vec2(square_x(col), row_y(row)),
                       width=info["w"] * info["scale"],
                       height=info["h"] * info["scale"],
                       anchor="center", opacity=0.5)

    # The top bar: how much sun, then a card for each plant.
    drawRect(pos=vec2(0, 0), width=width(), height=LAWN_Y - 8,
             color=(110, 75, 45))
    drawRect(pos=vec2(8, 8), width=78, height=96, color=(80, 55, 35),
             radius=8)
    drawSprite(sprite="sun", pos=vec2(47, 42), width=50, height=50,
               anchor="center")
    drawText(text=str(sun), pos=vec2(47, 88), size=22, anchor="center")

    plants = LEVELS[level]["plants"]
    for i in range(len(plants)):
        name = plants[i]
        info = PLANTS[name]
        x = card_x(i)
        drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                 color=(235, 220, 170), radius=8)
        if selected == name:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(255, 240, 60), radius=8, outline=4)
        drawSprite(sprite=name, pos=vec2(x + CARD_W / 2, 46),
                   width=info["w"] * info["scale"] * 0.75,
                   height=info["h"] * info["scale"] * 0.75, anchor="center")
        drawText(text=str(info["cost"]), pos=vec2(x + CARD_W / 2, 90),
                 size=18, color=(60, 40, 20), anchor="center")

        # Grey while it is waiting to be ready, shrinking as it gets
        # nearer; dim if you cannot afford it.
        if not card_ready(name):
            left = (ready_at[name] - time()) / info["wait"]
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96 * left,
                     color=(40, 40, 40), opacity=0.6, radius=8)
        elif sun < info["cost"]:
            drawRect(pos=vec2(x, 8), width=CARD_W, height=96,
                     color=(40, 40, 40), opacity=0.4, radius=8)

    waves = LEVELS[level]["waves"]
    drawText(text="Level " + str(level + 1), pos=vec2(width() - 20, 22),
             size=26, anchor="topright")
    if wave_number == 0:
        progress = "Get ready!"
    else:
        progress = "Wave " + str(wave_number) + " of " + str(len(waves))
    drawText(text=progress, pos=vec2(width() - 20, 62), size=20,
             anchor="topright")

    if banner_time > 0:
        drawText(text=banner, pos=vec2(LAWN_X + COLS * CELL / 2,
                                       LAWN_Y + ROWS * ROW_H / 2),
                 size=44, anchor="center")


# ------------------------------------------------------- 13. start the game

start_level()
say("Zombies are coming for the garden!\n\n"
    "Tap a plant card, then a square of lawn, to plant it. Plants cost sun: "
    "tap the suns to collect them. Pineapples make more sun, and beans "
    "shoot the zombies.", button="Play")

Make it yours

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

  • Design a level. Add a dictionary to the end of LEVELS with the plants you want, its news, and your own waves. Leave out the news and the game stops with KeyError: 'news' the moment you clear the level before it. Every name in a wave has to be in ZOMBIES; spell one wrong, say "zombi", and the game stops with KeyError: 'zombi' the moment that zombie is due, because there's nothing in ZOMBIES to look up.
  • Change the money. START_SUN, SUN_VALUE and SKY_SUN_EVERY decide how much sun there is. More sun is an easier game.
  • Change a plant. Everything about a plant is one line in PLANTS. A bean with "every": 0.7 shoots twice as fast; a brock with "hp": 200 is nearly impossible to eat.
  • A new zombie. The playground's Sprites panel has more dungeon monsters, and clicking one writes its loadSprite line, animations and all. The zombies here walk with an animation called "walk", so rename the monster's "run" to that, give it a line in ZOMBIES, and put it in a wave. The ice zombie has only an "idle" animation, so its "walk" is that one, renamed.