Building Coin Rush

Play it first → Bean has thirty seconds to grab every coin on the level. Get them all and the next level starts with more in the way: walls, then spikes, then ghosts, then a gazer that follows you through the walls.

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 level is a picture

A level has dozens of walls and coins in it. Typing a position for every one of them would take ages, and moving one would mean working out new numbers.

So a level here is a picture made of letters. One letter is one tile:

LEVEL_2 = [
    "###############",
    "#o    #      o#",
    "#     #   o   #",
    "#  o  #       #",
    "#     ### ### #",
    "#  o    P    o#",
    "### ###   #   #",
    "#       o #   #",
    "#o        #  o#",
    "###############",
]

# is a wall, o is a coin and P is where Bean starts. To move a coin, move an o. Nothing else in the game has to be told about it.

Step 1 — a room

addLevel() reads the picture. Every time it meets a letter it looks it up in tiles and builds whatever it finds there. A letter that isn't listed is ignored, which is why a space does nothing.

from kaypy import *

TILE = 64          # one tile is 64 pixels square
TOP = 56           # a strip at the top for the score and the clock

kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)

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

ROOM = [
    "###############",
    "#             #",
    "#             #",
    "#             #",
    "#             #",
    "#             #",
    "#             #",
    "#             #",
    "#             #",
    "###############",
]

addLevel(ROOM, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "pos": vec2(0, TOP),
    "tiles": {
        "#": lambda: [sprite("steel"), area(), body(isStatic=True)],
    },
})

Run it and you get an empty room with steel walls.

  • setGravity(0): we look down on Bean from above, so nothing should fall. 0 is already the setting when a game starts, so this line changes nothing; it's there to say what kind of game this is. A platform game, where things fall, sets it to about 2400.
  • "pos": vec2(0, TOP) starts the picture 56 pixels down, which leaves a strip across the top for the score and the clock.
  • body(isStatic=True) is what makes a wall solid. It never moves, and anything with a body() stops when it runs into it.

Every row of the picture must be the same length, 15 letters here. If one row is shorter, there's no error message. The walls on the right just don't line up.

Step 2 — Bean runs

Bean is a tile too. Put a P in the picture and add it to tiles, and changing where Bean starts is just moving that letter. get("player") finds Bean again afterwards, using the "player" tag on the end of the list.

from kaypy import *

TILE = 64
TOP = 56
SPEED = 300

kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)

loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")

ROOM = [
    "###############",
    "#             #",
    "#             #",
    "#    ###      #",
    "#             #",
    "#      P  #   #",
    "#         #   #",
    "#         #   #",
    "#             #",
    "###############",
]

addLevel(ROOM, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "pos": vec2(0, TOP),
    "tiles": {
        "#": lambda: [sprite("steel"), area(), body(isStatic=True)],
        "P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
    },
})

bean = get("player")[0]

# True while the arrow is held down.
going_left = False
going_right = False
going_up = False
going_down = False


@onKeyPress("left")
def press_left():
    global going_left
    going_left = True


@onKeyRelease("left")
def release_left():
    global going_left
    going_left = False


@onKeyPress("right")
def press_right():
    global going_right
    going_right = True


@onKeyRelease("right")
def release_right():
    global going_right
    going_right = False


@onKeyPress("up")
def press_up():
    global going_up
    going_up = True


@onKeyRelease("up")
def release_up():
    global going_up
    going_up = False


@onKeyPress("down")
def press_down():
    global going_down
    going_down = True


@onKeyRelease("down")
def release_down():
    global going_down
    going_down = False


@onUpdate
def each_frame():
    if going_left:
        bean.move(-SPEED, 0)
        bean.flipX = True
    if going_right:
        bean.move(SPEED, 0)
        bean.flipX = False
    if going_up:
        bean.move(0, -SPEED)
    if going_down:
        bean.move(0, SPEED)

scale(0.7) is there because the bean picture is a whole tile wide. A Bean exactly as wide as a tile gets stuck in every one-tile gap.

The arrow keys are the fiddly part. The obvious way is onKeyDown, which asks "is the right arrow held down right now?" every frame. That works until a message pauses the game, which happens in step 4. So the game keeps its own note instead: pressing an arrow sets its variable to True, letting go sets it back to False, and every frame Bean moves whichever ways are True.

bean.move(-SPEED, 0) moves Bean left at SPEED pixels per second. It is per second, not per frame, so Bean runs at the same speed on a fast computer and a slow one.

Don't forget global. Without global going_left, the line going_left = True makes a new variable that only exists inside press_left and disappears when the function ends. There's no error message. The left arrow just does nothing, and it looks like a problem with the keys.

Step 3 — coins and a counter

A coin is a tile with an area() but no body(). An area() on its own is something you can touch but walk straight through, which is what a coin should be.

from kaypy import *

TILE = 64
TOP = 56
SPEED = 300

kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)

loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")

loadSound("coin", "sounds/small_beep.wav")

ROOM = [
    "###############",
    "#o           o#",
    "#      o      #",
    "#    ###      #",
    "#  o          #",
    "#      P  #  o#",
    "#         #   #",
    "#   o     #   #",
    "#o           o#",
    "###############",
]

addLevel(ROOM, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "pos": vec2(0, TOP),
    "tiles": {
        "#": lambda: [sprite("steel"), area(), body(isStatic=True)],
        "o": lambda: [sprite("coin"), area(), "coin"],
        "P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
    },
})

bean = get("player")[0]
total = len(get("coin"))
coins = 0

label = add([text("Coins 0 / " + str(total), size=26), pos(16, 14), fixed()])

# True while the arrow is held down.
going_left = False
going_right = False
going_up = False
going_down = False


@onKeyPress("left")
def press_left():
    global going_left
    going_left = True


@onKeyRelease("left")
def release_left():
    global going_left
    going_left = False


@onKeyPress("right")
def press_right():
    global going_right
    going_right = True


@onKeyRelease("right")
def release_right():
    global going_right
    going_right = False


@onKeyPress("up")
def press_up():
    global going_up
    going_up = True


@onKeyRelease("up")
def release_up():
    global going_up
    going_up = False


@onKeyPress("down")
def press_down():
    global going_down
    going_down = True


@onKeyRelease("down")
def release_down():
    global going_down
    going_down = False


@onUpdate
def each_frame():
    if going_left:
        bean.move(-SPEED, 0)
        bean.flipX = True
    if going_right:
        bean.move(SPEED, 0)
        bean.flipX = False
    if going_up:
        bean.move(0, -SPEED)
    if going_down:
        bean.move(0, SPEED)


@bean.onCollide("coin")
def got_coin(coin):
    global coins
    coin.destroy()
    coins = coins + 1
    label.text = "Coins " + str(coins) + " / " + str(total)
    play("coin")

    if coins == total:
        say("You got every coin!")

@bean.onCollide("coin") runs the function every time Bean touches anything tagged "coin", and hands it the coin that was touched, so coin.destroy() removes that one coin and no other.

total = len(get("coin")) counts the coins in the picture. Add an o to the room and the counter goes up by itself.

The str() in the label matters. "Coins " + coins tries to add words to a number, and Python stops with TypeError: can only concatenate str (not "int") to str. str(coins) turns the number into words first.

Step 4 — more than one level

A game with levels needs a way to throw one level away and build the next. That is a scene. @scene("level") is the recipe for building a level, and go("level", 1, 8) clears the screen and follows the recipe again, this time for level 1 with a score of 8 carried over.

from kaypy import *

TILE = 64
TOP = 56
SPEED = 300

kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)

loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")

loadSound("coin", "sounds/small_beep.wav")
loadSound("done", "sounds/rising_beep.wav")

LEVEL_1 = [
    "###############",
    "#             #",
    "#  o       o  #",
    "#      o      #",
    "#             #",
    "#   o     o   #",
    "#      P      #",
    "#  o       o  #",
    "#      o      #",
    "###############",
]

LEVEL_2 = [
    "###############",
    "#o    #      o#",
    "#     #   o   #",
    "#  o  #       #",
    "#     ### ### #",
    "#  o    P    o#",
    "### ###   #   #",
    "#       o #   #",
    "#o        #  o#",
    "###############",
]

LEVELS = [LEVEL_1, LEVEL_2]

TILES = {
    "#": lambda: [sprite("steel"), area(), body(isStatic=True)],
    "o": lambda: [sprite("coin"), area(), "coin"],
    "P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
}

going_left = False
going_right = False
going_up = False
going_down = False

coins = 0
level_over = False


@scene("level")
def build_level(number, score):
    global going_left, going_right, going_up, going_down
    global coins, level_over

    addLevel(LEVELS[number], {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    bean = get("player")[0]
    total = len(get("coin"))

    # A new level, so everything starts again.
    going_left = False
    going_right = False
    going_up = False
    going_down = False
    coins = 0
    level_over = False

    @onKeyPress("left")
    def press_left():
        global going_left
        going_left = True

    @onKeyRelease("left")
    def release_left():
        global going_left
        going_left = False

    @onKeyPress("right")
    def press_right():
        global going_right
        going_right = True

    @onKeyRelease("right")
    def release_right():
        global going_right
        going_right = False

    @onKeyPress("up")
    def press_up():
        global going_up
        going_up = True

    @onKeyRelease("up")
    def release_up():
        global going_up
        going_up = False

    @onKeyPress("down")
    def press_down():
        global going_down
        going_down = True

    @onKeyRelease("down")
    def release_down():
        global going_down
        going_down = False

    label = add([text("", size=26), pos(16, 14), fixed()])

    def show_score():
        label.text = ("Level " + str(number + 1)
                      + "     Coins " + str(coins) + " / " + str(total)
                      + "     Score " + str(score + coins))

    @onUpdate
    def each_frame():
        if level_over:
            return
        if going_left:
            bean.move(-SPEED, 0)
            bean.flipX = True
        if going_right:
            bean.move(SPEED, 0)
            bean.flipX = False
        if going_up:
            bean.move(0, -SPEED)
        if going_down:
            bean.move(0, SPEED)

    @bean.onCollide("coin")
    def got_coin(coin):
        global coins, level_over

        coin.destroy()
        coins = coins + 1
        play("coin")
        show_score()

        if coins == total:
            level_over = True
            play("done")
            new_score = score + coins

            if number + 1 < len(LEVELS):
                @say("Level " + str(number + 1) + " done!")
                def next_level():
                    go("level", number + 1, new_score)
            else:
                @say("You beat every level!\n\nScore: " + str(new_score))
                def start_again():
                    go("level", 0, 0)

    show_score()


go("level", 0, 0)

Almost everything from step 3 has moved inside build_level. That is because go() throws away everything from the old scene: the objects, and also the key handlers and the onUpdate. Anything that should exist in every level has to be set up again each time the scene is built.

For the same reason the variables go back to the start at the top of build_level. This is where the arrow keys from step 2 pay off. If you are holding an arrow when you grab the last coin, the "Level done!" message pauses the game. If you let go while the message is up, the game never sees you let go. Setting all four back to False means the next level starts with Bean standing still.

@say(...) puts a message up, and the function underneath runs when the player presses OK. Anything that should happen after the message goes inside that function. A line written underneath say() runs straight away, while the message is still on the screen.

level_over stops Bean moving once the level is won, so Bean can't wander off behind the message.

Careful with the name. The scene is called "level", but the function is build_level, not play. kaypy already has a play(), the one that makes sounds. Name your function play and it replaces that one, so the first coin you touch runs play("coin"), which now means "build a level" instead of "make a sound". The game crashes with TypeError: play() missing 1 required positional argument: 'score', which makes no sense if you think play plays sounds.

Step 5 — the clock

The clock is a number of seconds that goes down every frame. dt() is how long the last frame took, a sixtieth of a second or so, and taking it away every frame counts down in real seconds.

from kaypy import *

TILE = 64
TOP = 56
SPEED = 300
TIME_LIMIT = 30      # seconds for each level
BEST_KEY = "coin_rush_best"

kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)

loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")

loadSound("coin", "sounds/small_beep.wav")
loadSound("done", "sounds/rising_beep.wav")
loadSound("ouch", "sounds/thump.wav")

LEVEL_1 = [
    "###############",
    "#             #",
    "#  o       o  #",
    "#      o      #",
    "#             #",
    "#   o     o   #",
    "#      P      #",
    "#  o       o  #",
    "#      o      #",
    "###############",
]

LEVEL_2 = [
    "###############",
    "#o    #      o#",
    "#     #   o   #",
    "#  o  #       #",
    "#     ### ### #",
    "#  o    P    o#",
    "### ###   #   #",
    "#       o #   #",
    "#o        #  o#",
    "###############",
]

LEVELS = [LEVEL_1, LEVEL_2]

TILES = {
    "#": lambda: [sprite("steel"), area(), body(isStatic=True)],
    "o": lambda: [sprite("coin"), area(), "coin"],
    "P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
}

going_left = False
going_right = False
going_up = False
going_down = False

time_left = 0
coins = 0
level_over = False


@scene("level")
def build_level(number, score):
    global going_left, going_right, going_up, going_down
    global time_left, coins, level_over

    addLevel(LEVELS[number], {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    bean = get("player")[0]
    total = len(get("coin"))

    # A new level, so everything starts again.
    going_left = False
    going_right = False
    going_up = False
    going_down = False
    time_left = TIME_LIMIT
    coins = 0
    level_over = False

    @onKeyPress("left")
    def press_left():
        global going_left
        going_left = True

    @onKeyRelease("left")
    def release_left():
        global going_left
        going_left = False

    @onKeyPress("right")
    def press_right():
        global going_right
        going_right = True

    @onKeyRelease("right")
    def release_right():
        global going_right
        going_right = False

    @onKeyPress("up")
    def press_up():
        global going_up
        going_up = True

    @onKeyRelease("up")
    def release_up():
        global going_up
        going_up = False

    @onKeyPress("down")
    def press_down():
        global going_down
        going_down = True

    @onKeyRelease("down")
    def release_down():
        global going_down
        going_down = False

    label = add([text("", size=26), pos(16, 14), fixed()])
    clock = add([text("", size=26), pos(width() - 130, 14), fixed()])

    def show_score():
        label.text = ("Level " + str(number + 1)
                      + "     Coins " + str(coins) + " / " + str(total)
                      + "     Score " + str(score + coins))
        clock.text = "Time " + str(int(time_left))

    @onUpdate
    def each_frame():
        global time_left

        if level_over:
            return
        if going_left:
            bean.move(-SPEED, 0)
            bean.flipX = True
        if going_right:
            bean.move(SPEED, 0)
            bean.flipX = False
        if going_up:
            bean.move(0, -SPEED)
        if going_down:
            bean.move(0, SPEED)

        time_left = time_left - dt()
        if time_left <= 0:
            time_left = 0
            times_up()

        show_score()

    @bean.onCollide("coin")
    def got_coin(coin):
        global coins, level_over

        coin.destroy()
        coins = coins + 1
        play("coin")
        show_score()

        if coins == total:
            level_over = True
            play("done")
            new_score = score + coins

            if number + 1 < len(LEVELS):
                @say("Level " + str(number + 1) + " done!")
                def next_level():
                    go("level", number + 1, new_score)
            else:
                @say("You beat every level!\n\nScore: " + str(new_score))
                def start_again():
                    go("level", 0, 0)

    def times_up():
        global level_over

        level_over = True
        final = score + coins
        best = getData(BEST_KEY, 0)

        if final > best:
            setData(BEST_KEY, final)
            message = "New best score: " + str(final) + "!"
        else:
            message = "Score: " + str(final) + "\nBest: " + str(best)

        play("ouch")

        @say("Time's up!\n\n" + message, button="Play again")
        def play_again():
            go("level", 0, 0)

    show_score()
    say("Level " + str(number + 1), button="Go!")


go("level", 0, 0)

A countdown is easier to change than a deadline. In step 6, touching a spike costs three seconds, and that is just time_left = time_left - PENALTY.

int(time_left) drops everything after the decimal point, so the clock shows Time 7 instead of Time 7.316666666.

The say(..., button="Go!") at the end of build_level doesn't only say which level it is. A message pauses the game, and onUpdate doesn't run while the game is paused, so the clock doesn't start until the player presses Go.

getData and setData remember the best score after the page is closed. The key is "coin_rush_best" rather than just "best" because every game on a website shares the same storage. Another game saving its own "best" would overwrite yours.

Step 6 — spikes and ghosts

Two new levels, three new letters. Everything that hurts is tagged "danger", so one onCollide handles spikes and ghosts both. Each ghost also gets a second tag, "across" or "updown", which decides how it moves.

from kaypy import *

TILE = 64
TOP = 56
SPEED = 300
TIME_LIMIT = 30      # seconds for each level
PENALTY = 3          # seconds lost for touching a spike or a ghost
GHOST_SPEED = 100
BEST_KEY = "coin_rush_best"

kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)

loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("spike", "images/spike.png")
loadSprite("ghosty", "images/ghosty.png")

loadSound("coin", "sounds/small_beep.wav")
loadSound("done", "sounds/rising_beep.wav")
loadSound("ouch", "sounds/thump.wav")

LEVEL_1 = [
    "###############",
    "#             #",
    "#  o       o  #",
    "#      o      #",
    "#             #",
    "#   o     o   #",
    "#      P      #",
    "#  o       o  #",
    "#      o      #",
    "###############",
]

LEVEL_2 = [
    "###############",
    "#o    #      o#",
    "#     #   o   #",
    "#  o  #       #",
    "#     ### ### #",
    "#  o    P    o#",
    "### ###   #   #",
    "#       o #   #",
    "#o        #  o#",
    "###############",
]

LEVEL_3 = [
    "###############",
    "#o   ^   ^   o#",
    "# ##   o   ## #",
    "#  #  ^ ^  #  #",
    "#o    #P#    o#",
    "#  ^  # #  ^  #",
    "# ### # # ### #",
    "#  o  ^ ^  o  #",
    "#o           o#",
    "###############",
]

LEVEL_4 = [
    "###############",
    "#o   h       o#",
    "#  ###   ###  #",
    "#  #o  ^  o#  #",
    "#v #   P   # v#",
    "#  #o     o#  #",
    "#  ###   ###  #",
    "#o     h     o#",
    "#      ^      #",
    "###############",
]

LEVELS = [LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4]

TILES = {
    "#": lambda: [sprite("steel"), area(), body(isStatic=True)],
    "o": lambda: [sprite("coin"), area(), "coin"],
    "^": lambda: [sprite("spike"), area(), "danger"],
    "h": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
                  "danger", "across"],
    "v": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
                  "danger", "updown"],
    "P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
}

going_left = False
going_right = False
going_up = False
going_down = False

time_left = 0
coins = 0
level_over = False


@scene("level")
def build_level(number, score):
    global going_left, going_right, going_up, going_down
    global time_left, coins, level_over

    addLevel(LEVELS[number], {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    bean = get("player")[0]
    start = vec2(bean.pos.x, bean.pos.y)
    total = len(get("coin"))

    # A new level, so everything starts again.
    going_left = False
    going_right = False
    going_up = False
    going_down = False
    time_left = TIME_LIMIT
    coins = 0
    level_over = False

    @onKeyPress("left")
    def press_left():
        global going_left
        going_left = True

    @onKeyRelease("left")
    def release_left():
        global going_left
        going_left = False

    @onKeyPress("right")
    def press_right():
        global going_right
        going_right = True

    @onKeyRelease("right")
    def release_right():
        global going_right
        going_right = False

    @onKeyPress("up")
    def press_up():
        global going_up
        going_up = True

    @onKeyRelease("up")
    def release_up():
        global going_up
        going_up = False

    @onKeyPress("down")
    def press_down():
        global going_down
        going_down = True

    @onKeyRelease("down")
    def release_down():
        global going_down
        going_down = False

    label = add([text("", size=26), pos(16, 14), fixed()])
    clock = add([text("", size=26), pos(width() - 130, 14), fixed()])

    def show_score():
        label.text = ("Level " + str(number + 1)
                      + "     Coins " + str(coins) + " / " + str(total)
                      + "     Score " + str(score + coins))
        clock.text = "Time " + str(int(time_left))

    @onUpdate
    def each_frame():
        global time_left

        if level_over:
            return
        if going_left:
            bean.move(-SPEED, 0)
            bean.flipX = True
        if going_right:
            bean.move(SPEED, 0)
            bean.flipX = False
        if going_up:
            bean.move(0, -SPEED)
        if going_down:
            bean.move(0, SPEED)

        time_left = time_left - dt()
        if time_left <= 0:
            time_left = 0
            times_up()

        show_score()

    @bean.onCollide("coin")
    def got_coin(coin):
        global coins, level_over

        coin.destroy()
        coins = coins + 1
        play("coin")
        show_score()

        if coins == total:
            level_over = True
            play("done")
            new_score = score + coins

            if number + 1 < len(LEVELS):
                @say("Level " + str(number + 1) + " done!")
                def next_level():
                    go("level", number + 1, new_score)
            else:
                @say("You beat every level!\n\nScore: " + str(new_score))
                def start_again():
                    go("level", 0, 0)

    @bean.onCollide("danger")
    def ouch(thing):
        global time_left

        if level_over:
            return
        time_left = time_left - PENALTY
        bean.pos = vec2(start.x, start.y)
        shake(10)
        play("ouch")

    @onUpdate("across")
    def float_across(ghost):
        ghost.move(wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5), 0)

    @onUpdate("updown")
    def float_updown(ghost):
        ghost.move(0, wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5))

    def times_up():
        global level_over

        level_over = True
        final = score + coins
        best = getData(BEST_KEY, 0)

        if final > best:
            setData(BEST_KEY, final)
            message = "New best score: " + str(final) + "!"
        else:
            message = "Score: " + str(final) + "\nBest: " + str(best)

        play("ouch")

        @say("Time's up!\n\n" + message, button="Play again")
        def play_again():
            go("level", 0, 0)

    show_score()
    say("Level " + str(number + 1), button="Go!")


go("level", 0, 0)

@onUpdate("across") runs once per frame for each object tagged "across", and passes that object in as ghost. Two ghosts, two calls, each moving its own ghost.

wave(low, high, angle) swings smoothly between two numbers forever. Here it is used as a speed: the ghost floats one way, slows down, stops, and floats back again. Change the 1.5 and the ghosts swing faster or slower; change GHOST_SPEED and they travel further.

start remembers where Bean began, so that touching a danger can put Bean back there.

Step 7 — the whole game

This is the game exactly as it is on the games page. It adds three things to step 6:

  • Level 5 and the gazer. The G letter has no body(), so walls don't stop it, and gazer.moveTo(bean.pos, CHASE_SPEED) heads it straight at Bean every frame.
  • Laps. After level 5, go("level", 0, new_score, lap + 1) starts again with the score kept and five seconds less on the clock, down to a minimum of SHORTEST. The game ends only when the clock runs out.
  • A tip for each level, in the TIPS list, shown in the Go! message.

It also has all the numbers worth changing in one settings block near the top, and comments that explain why each part is there.

"""Coin Rush -- grab every coin before the clock runs out.

    arrow keys   run

Bean has 30 seconds to collect every coin on the level. Get them all and the
next level starts, with more in the way: first walls, then spikes, then
ghosts, then a gazer that comes after you. Touch a spike or a ghost and you
lose 3 seconds and go back to where you started.

Every coin counts towards your score, and the score carries on from level to
level. Finish all five and they start again with less time on the clock, so
the game only ends when the clock beats you. Your best score is remembered.

HOW THIS FILE IS PUT TOGETHER

    1. the window, the pictures and the sounds
    2. the settings
    3. the levels
    4. what each letter turns into
    5. the level scene: Bean, the keys, the clock, the coins
    6. start the game

The levels are pictures made of letters. Move an 'o' and you have moved a
coin -- you never have to touch the code to change a level.
"""

from kaypy import *

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

TILE = 64          # one tile is 64 pixels square
ACROSS = 15        # tiles across
DOWN = 10          # tiles down
TOP = 56           # a strip at the top for the score and the clock

kaypy(width=TILE * ACROSS, height=TOP + TILE * DOWN,
      background=[118, 186, 120])

# We look down on Bean from above, so nothing falls. 0 is already the
# setting when a game starts, so this line changes nothing -- it is here to
# say what kind of game this is. A platform game sets it to about 2400.
setGravity(0)

loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("steel", "images/steel.png")
loadSprite("spike", "images/spike.png")
loadSprite("ghosty", "images/ghosty.png")
loadSprite("gazer", "images/gazer.png")

loadSound("coin", "sounds/small_beep.wav")
loadSound("ouch", "sounds/thump.wav")
loadSound("done", "sounds/rising_beep.wav")


# -------------------------------------------------------------- 2. settings
#
# Every number worth changing is here.

SPEED = 300           # how fast Bean runs, in pixels per second
TIME_LIMIT = 30       # seconds on the clock for each level
FASTER_EACH_LAP = 5   # seconds fewer each time round all five levels
SHORTEST = 10         # ...but never fewer than this
PENALTY = 3           # seconds lost for touching a spike, ghost or gazer
GHOST_SPEED = 100     # how fast a ghost floats
CHASE_SPEED = 60      # how fast the gazer follows you

# The name the best score is saved under. Every game on a website shares
# the same storage, so the game's own name goes in it -- a plain "best"
# would be overwritten by the next game that saved one.
BEST_KEY = "coin_rush_best"


# ---------------------------------------------------------------- 3. levels
#
# One letter is one tile.
#
#   #  wall       o  coin       P  Bean starts here
#   ^  spike      h  ghost that floats side to side
#                 v  ghost that floats up and down
#                 G  gazer, which chases you straight through walls
#
# A space is empty floor. Every row has to be 15 letters long.

LEVEL_1 = [
    "###############",
    "#             #",
    "#  o       o  #",
    "#      o      #",
    "#             #",
    "#   o     o   #",
    "#      P      #",
    "#  o       o  #",
    "#      o      #",
    "###############",
]

LEVEL_2 = [
    "###############",
    "#o    #      o#",
    "#     #   o   #",
    "#  o  #       #",
    "#     ### ### #",
    "#  o    P    o#",
    "### ###   #   #",
    "#       o #   #",
    "#o        #  o#",
    "###############",
]

LEVEL_3 = [
    "###############",
    "#o   ^   ^   o#",
    "# ##   o   ## #",
    "#  #  ^ ^  #  #",
    "#o    #P#    o#",
    "#  ^  # #  ^  #",
    "# ### # # ### #",
    "#  o  ^ ^  o  #",
    "#o           o#",
    "###############",
]

LEVEL_4 = [
    "###############",
    "#o   h       o#",
    "#  ###   ###  #",
    "#  #o  ^  o#  #",
    "#v #   P   # v#",
    "#  #o     o#  #",
    "#  ###   ###  #",
    "#o     h     o#",
    "#      ^      #",
    "###############",
]

LEVEL_5 = [
    "###############",
    "#o   #   #   o#",
    "# ## # ^ # ## #",
    "#  o   h     o#",
    "#v ##  P  ## v#",
    "#     ^ ^     #",
    "# ##o## ##o## #",
    "#o    h      o#",
    "#  ^       ^ G#",
    "###############",
]

LEVELS = [LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4, LEVEL_5]

# What to tell the player before each level.
TIPS = [
    "Grab every coin before the clock runs out!",
    "Walls! Find a way round them.",
    "Spikes! Touch one and you lose 3 seconds.",
    "Ghosts! Watch how they float, then slip past.",
    "The gazer follows you, even through walls. Keep moving!",
]


# ----------------------------------------------- 4. what each letter becomes
#
# addLevel() reads the picture. Each time it finds a letter it looks it up
# here and builds an object from the list. The `lambda:` means "build a new
# one every time you find this letter", so every coin is its own coin.
#
# Everything that hurts Bean is tagged "danger". The ghosts and the gazer
# have a second tag too -- "across", "updown" or "chaser" -- which is what
# makes each of them move the way it does. A spike is only "danger".

TILES = {
    "#": lambda: [sprite("steel"), area(), body(isStatic=True), "wall"],
    "o": lambda: [sprite("coin"), area(), "coin"],
    "^": lambda: [sprite("spike"), area(), "danger"],
    "h": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
                  "danger", "across"],
    "v": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
                  "danger", "updown"],
    "G": lambda: [sprite("gazer"), scale(0.4), area(), z(10),
                  "danger", "chaser"],

    # Bean is a tile like everything else, so moving the 'P' moves where you
    # start. scale(0.7) because the picture is a whole tile wide, and a Bean
    # that fills a tile gets stuck in every gap.
    "P": lambda: [sprite("bean"), scale(0.7), area(), body(), z(20),
                  "player"],
}


# -------------------------------------------- what the game has to remember
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".

going_left = False
going_right = False
going_up = False
going_down = False

time_left = 0          # seconds left on the clock
coins = 0              # coins collected on this level
level_over = False


# ------------------------------------------------------- 5. the level scene
#
# The scene is CALLED "level". The function is NOT called play(), because
# kaypy already has a play() for sounds -- name your function play() and
# every play("coin") would start a level instead of making a noise.
#
# number is which level (0 is the first), score is the coins from earlier
# levels, and lap is how many times you have been through all five.

@scene("level")
def build_level(number, score, lap):
    global going_left, going_right, going_up, going_down
    global time_left, coins, level_over

    addLevel(LEVELS[number], {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    bean = get("player")[0]
    start = vec2(bean.pos.x, bean.pos.y)
    total = len(get("coin"))

    # A new level, so everything starts again.
    going_left = False
    going_right = False
    going_up = False
    going_down = False
    coins = 0
    level_over = False

    time_left = TIME_LIMIT - lap * FASTER_EACH_LAP
    if time_left < SHORTEST:
        time_left = SHORTEST

    # -------------------------------------------------------------- the keys
    #
    # WHY NOT JUST onKeyDown?
    #
    # onKeyDown asks "is the key down right now?" every frame. But a message
    # on the screen pauses the game, and if you let go of an arrow while it
    # is up, the let-go can be missed. Then the game thinks the arrow is
    # still held, and on the next level Bean runs off by itself.
    #
    # So the game keeps its own note: True when a key is pressed, False when
    # it is let go, and all four go back to False at the start of a level.

    @onKeyPress("left")
    def press_left():
        global going_left
        going_left = True

    @onKeyRelease("left")
    def release_left():
        global going_left
        going_left = False

    @onKeyPress("right")
    def press_right():
        global going_right
        going_right = True

    @onKeyRelease("right")
    def release_right():
        global going_right
        going_right = False

    @onKeyPress("up")
    def press_up():
        global going_up
        going_up = True

    @onKeyRelease("up")
    def release_up():
        global going_up
        going_up = False

    @onKeyPress("down")
    def press_down():
        global going_down
        going_down = True

    @onKeyRelease("down")
    def release_down():
        global going_down
        going_down = False

    # ------------------------------------------------- the words at the top

    label = add([text("", size=26), pos(16, 14), fixed(), z(100)])
    clock = add([text("", size=26), pos(width() - 130, 14), fixed(), z(100)])

    def show_score():
        label.text = ("Level " + str(number + 1)
                      + "     Coins " + str(coins) + " / " + str(total)
                      + "     Score " + str(score + coins))
        # int() drops the part after the decimal point: 7.8 becomes 7.
        clock.text = "Time " + str(int(time_left))

    # ------------------------------------------------------- every frame

    @onUpdate
    def each_frame():
        global time_left

        if level_over:
            return

        if going_left:
            bean.move(-SPEED, 0)
            bean.flipX = True
        if going_right:
            bean.move(SPEED, 0)
            bean.flipX = False
        if going_up:
            bean.move(0, -SPEED)
        if going_down:
            bean.move(0, SPEED)

        # The clock. dt() is how long this frame took, so taking it away
        # every frame counts down in real seconds.
        time_left = time_left - dt()
        if time_left <= 0:
            time_left = 0
            times_up()

        show_score()

    # ----------------------------------------------------------- the coins

    @bean.onCollide("coin")
    def got_coin(coin):
        global coins, level_over

        coin.destroy()
        coins = coins + 1
        play("coin")
        show_score()

        if coins == total:
            level_over = True
            play("done")
            new_score = score + coins

            if number + 1 < len(LEVELS):
                @say("Level " + str(number + 1) + " done!\n\n"
                     + "Score: " + str(new_score))
                def next_level():
                    go("level", number + 1, new_score, lap)
            else:
                # Back to level 1, one lap further on, less time.
                @say("You beat all five levels!\n\n"
                     + "Score: " + str(new_score) + "\n\n"
                     + "Again -- with less time!")
                def next_lap():
                    go("level", 0, new_score, lap + 1)

    # -------------------------------------------------- spikes and ghosts

    @bean.onCollide("danger")
    def ouch(thing):
        global time_left

        if level_over:
            return
        time_left = time_left - PENALTY
        bean.pos = vec2(start.x, start.y)
        shake(10)
        play("ouch")

    # wave() swings between two numbers for ever. Used as a SPEED, it makes
    # a ghost float one way, slow down, and float back again.
    @onUpdate("across")
    def float_across(ghost):
        ghost.move(wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5), 0)

    @onUpdate("updown")
    def float_updown(ghost):
        ghost.move(0, wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5))

    # moveTo() heads towards a point. The gazer has no body(), so walls do
    # not stop it.
    @onUpdate("chaser")
    def chase(gazer):
        gazer.moveTo(bean.pos, CHASE_SPEED)

    # ------------------------------------------------------------ time's up

    def times_up():
        global level_over

        level_over = True
        final = score + coins
        best = getData(BEST_KEY, 0)

        if final > best:
            setData(BEST_KEY, final)
            message = "New best score: " + str(final) + "!"
        else:
            message = "Score: " + str(final) + "\nBest: " + str(best)

        shake(8)
        play("ouch")

        @say("Time's up!\n\n" + message, button="Play again")
        def play_again():
            go("level", 0, 0, 0)

    # The message pauses the game, so the clock does not start until you
    # close it.
    show_score()
    say("Level " + str(number + 1) + "\n\n" + TIPS[number], button="Go!")


# ------------------------------------------------------- 6. start the game

go("level", 0, 0, 0)

Make it yours

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

  • Change the numbers. SPEED, TIME_LIMIT, PENALTY, GHOST_SPEED. A game you find too hard is one number away from being too easy.
  • Draw level 6. Copy LEVEL_5, change the letters, and add it to LEVELS. You also need a sixth line in TIPS. Without it, reaching level 6 crashes with IndexError: list index out of range, because there are only five tips to choose from.
  • Add a new letter. A + that gives you five more seconds: a tile tagged "bonus" using the heart picture, and a @bean.onCollide("bonus") that destroys it and adds to time_left. Don't forget to loadSprite the heart.
  • Use WASD as well. Copy the arrow-key handlers and change "left" to "a", and so on. They can set the same variables.