Building Coin Dash

Play it first → Bean runs and jumps around one room, grabbing a coin that moves somewhere new each time it is collected. Red blocks drop in through a gap in the ceiling and run about, and the higher your score, the faster they come. Touch one, or fall through the gap in the floor, and it's game over.

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. Every picture and sound comes with the playground, so there is nothing to find or upload.

If you have done Coin Rush, a lot of this will look familiar. The big difference is that Coin Rush looks down on Bean from above, and Coin Dash looks at Bean from the side. Things fall, and Bean has to jump.

The one big idea: a room is a picture

The room has over a hundred wall tiles in it. Typing a position for every one of them would take ages, and moving a platform would mean working out new numbers.

So the room is a picture made of letters. One letter is one tile:

LEVEL = [
    "##########=====##########",
    "#                       #",
    "#                       #",
    "#         #   #         #",
    "#                       #",
    "#  ######       ######  #",
    ...
]

# is a wall and = is one of the red gaps, at the top and the bottom. To move a platform, move its #s. 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 = 40          # one tile is 40 pixels square

kaypy(width=TILE * 25, height=TILE * 17, background=[52, 152, 219])

LEVEL = [
    "##########=====##########",
    "#                       #",
    "#                       #",
    "#         #   #         #",
    "#                       #",
    "#  ######       ######  #",
    "#                       #",
    "#                       #",
    "#                       #",
    "#####   #########   #####",
    "#       #       #       #",
    "#       #       #       #",
    "#      ##       ##      #",
    "#    ###         ###    #",
    "#                       #",
    "#                       #",
    "##########=====##########",
]

TILES = {
    "#": lambda: [rect(TILE, TILE), color(52, 73, 94), area(),
                  body(isStatic=True), "wall"],
    "=": lambda: [rect(TILE, TILE), color(231, 76, 60)],
}

addLevel(LEVEL, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "tiles": TILES,
})

Run it and you get an empty room with dark walls and a red gap at the top and the bottom.

  • The window is TILE * 25 wide and TILE * 17 tall, because the picture is 25 letters across and 17 rows down. Change the size of a tile and the window changes with it.
  • body(isStatic=True) is what makes a wall solid. It never moves, and anything with a body() stands on it and bumps into it.
  • A red gap has no area() at all, so nothing can touch it. It is only there to be looked at. Later, things fall straight through it.
  • The lambda: in front of each list means "build a new one of these every time you find this letter," so every # is a wall of its own. Leave it off and the game stops with TypeError: 'list' object is not callable: addLevel wants something it can call to make each tile, and a plain list is not that.

Every row of the picture must be the same length, 25 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 and jumps

This is a platform game, so the first new line is setGravity(1000). Anything with a body() now falls until it lands on something.

from kaypy import *

TILE = 40
RUN_SPEED = 400       # how fast Bean runs, in pixels per second
JUMP = 640            # how hard Bean jumps
BEAN_SIZE = 0.6       # Bean's picture is bigger than a tile, so shrink it

kaypy(width=TILE * 25, height=TILE * 17, background=[52, 152, 219])
setGravity(1000)

loadSprite("bean", "images/bean.png")
loadSound("jump", "sounds/swoosh.wav")

LEVEL = [
    "##########=====##########",
    "#                       #",
    "#                       #",
    "#         #   #         #",
    "#                       #",
    "#  ######       ######  #",
    "#                       #",
    "#                       #",
    "#                       #",
    "#####   #########   #####",
    "#       #       #       #",
    "#       #       #       #",
    "#      ##       ##      #",
    "#    ###         ###    #",
    "#                       #",
    "#                       #",
    "##########=====##########",
]

TILES = {
    "#": lambda: [rect(TILE, TILE), color(52, 73, 94), area(),
                  body(isStatic=True), "wall"],
    "=": lambda: [rect(TILE, TILE), color(231, 76, 60)],
}

addLevel(LEVEL, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "tiles": TILES,
})

bean = add([sprite("bean"), scale(BEAN_SIZE), pos(500, 340),
            anchor("center"), area(), body(), "player"])


@onUpdate
def each_frame():
    if isKeyDown("left"):
        bean.move(-RUN_SPEED, 0)
        bean.flipX = True
    if isKeyDown("right"):
        bean.move(RUN_SPEED, 0)
        bean.flipX = False

    if isKeyDown("up") or isKeyDown("space"):
        if bean.isGrounded():
            bean.jump(JUMP)
            play("jump")

Arrow keys to run, up or space to jump.

  • scale(BEAN_SIZE) shrinks Bean. The bean picture is 64 pixels wide and a tile is only 40, and a Bean bigger than a tile looks squashed into the room.
  • anchor("center") means pos(500, 340) is the middle of Bean, not its top-left corner. The coin positions in the next step work the same way.
  • bean.move(-RUN_SPEED, 0) moves Bean left at RUN_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.
  • bean.flipX = True turns the picture round, so Bean faces the way it is running.

bean.isGrounded() asks whether Bean is standing on something. Take that if away and Bean can jump again in mid-air, which means you can fly up the screen by tapping the key. Because the game checks the key every frame, holding up down makes Bean jump again the moment it lands.

Try running into the gap in the floor. Bean falls out of the bottom of the window and keeps falling for ever. Nothing in the game is watching for it yet. Step 5 fixes that.

Step 3 — a coin that moves

There is only ever one coin. When Bean touches it, it moves to another spot. The spots are in a list, COIN_SPOTS.

from kaypy import *

TILE = 40
RUN_SPEED = 400       # how fast Bean runs, in pixels per second
JUMP = 640            # how hard Bean jumps
BEAN_SIZE = 0.6       # Bean's picture is bigger than a tile, so shrink it
COIN_SIZE = 0.7
POINTS = 5            # points for each coin

# Where the coin can be.
COIN_SPOTS = [
    vec2(120, 300),
    vec2(840, 300),
    vec2(800, 160),
    vec2(200, 160),
    vec2(140, 600),
    vec2(900, 600),
    vec2(230, 490),
    vec2(760, 490),
]

kaypy(width=TILE * 25, height=TILE * 17, background=[52, 152, 219])
setGravity(1000)

loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSound("jump", "sounds/swoosh.wav")
loadSound("coin", "sounds/small_beep.wav")

LEVEL = [
    "##########=====##########",
    "#                       #",
    "#                       #",
    "#         #   #         #",
    "#                       #",
    "#  ######       ######  #",
    "#                       #",
    "#                       #",
    "#                       #",
    "#####   #########   #####",
    "#       #       #       #",
    "#       #       #       #",
    "#      ##       ##      #",
    "#    ###         ###    #",
    "#                       #",
    "#                       #",
    "##########=====##########",
]

TILES = {
    "#": lambda: [rect(TILE, TILE), color(52, 73, 94), area(),
                  body(isStatic=True), "wall"],
    "=": lambda: [rect(TILE, TILE), color(231, 76, 60)],
}

addLevel(LEVEL, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "tiles": TILES,
})

bean = add([sprite("bean"), scale(BEAN_SIZE), pos(500, 340),
            anchor("center"), area(), body(), "player"])

coin = add([sprite("coin"), scale(COIN_SIZE), pos(COIN_SPOTS[0]),
            anchor("center"), area(), opacity(1), "coin"])

score = 0
label = add([text("Score: 0", size=36), pos(60, 50), z(100)])


@onUpdate
def each_frame():
    if isKeyDown("left"):
        bean.move(-RUN_SPEED, 0)
        bean.flipX = True
    if isKeyDown("right"):
        bean.move(RUN_SPEED, 0)
        bean.flipX = False

    if isKeyDown("up") or isKeyDown("space"):
        if bean.isGrounded():
            bean.jump(JUMP)
            play("jump")


@bean.onCollide("coin")
def got_coin(c):
    global score

    score = score + POINTS
    label.text = "Score: " + str(score)
    play("coin")
    move_coin()


def move_coin():
    # Every spot except the one the coin is at now.
    spots = []
    for spot in COIN_SPOTS:
        if spot.x != coin.pos.x or spot.y != coin.pos.y:
            spots.append(spot)

    coin.pos = choose(spots)

    # Fade in at the new spot, so you can see where it went.
    tween(0, 1, 0.3, fade_coin)


def fade_coin(amount):
    coin.opacity = amount

The coin has 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. It also has no gravity, so it hangs in the air.

@bean.onCollide("coin") runs got_coin whenever Bean touches something tagged "coin". The tag is the "coin" at the end of the coin's list. If the two are spelled even slightly differently, there is no error message. Bean just walks through the coin and nothing happens.

move_coin has to pick a new spot that isn't the one the coin is already at, or sometimes the coin would "move" without moving. So it makes a new, empty list, goes through COIN_SPOTS one at a time, and adds every spot that isn't where the coin is now. Then choose() picks one of those.

tween(0, 1, 0.3, fade_coin) counts smoothly from 0 to 1 over 0.3 seconds, and calls fade_coin with each number along the way. fade_coin sets the coin's opacity, so the coin fades in at its new spot and your eye catches where it went.

Don't forget global. Take out global score and the first coin you grab crashes the game with UnboundLocalError: cannot access local variable 'score' where it is not associated with a value. Without global, Python thinks score = score + POINTS is about a new variable of got_coin's own, and that one has no value yet to add to.

Step 4 — red blocks

Now something to dodge. Every few seconds a red block drops in through the gap in the ceiling and runs about the room.

from kaypy import *

TILE = 40
RUN_SPEED = 400       # how fast Bean runs, in pixels per second
JUMP = 640            # how hard Bean jumps
BEAN_SIZE = 0.6       # Bean's picture is bigger than a tile, so shrink it
COIN_SIZE = 0.7
POINTS = 5            # points for each coin
ENEMY_SPEED = 200     # how fast a red block runs once it gets going
ENEMY_WAIT = 3        # seconds between red blocks

# Where the coin can be.
COIN_SPOTS = [
    vec2(120, 300),
    vec2(840, 300),
    vec2(800, 160),
    vec2(200, 160),
    vec2(140, 600),
    vec2(900, 600),
    vec2(230, 490),
    vec2(760, 490),
]

kaypy(width=TILE * 25, height=TILE * 17, background=[52, 152, 219])
setGravity(1000)

loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSound("jump", "sounds/swoosh.wav")
loadSound("coin", "sounds/small_beep.wav")

LEVEL = [
    "##########=====##########",
    "#                       #",
    "#                       #",
    "#         #   #         #",
    "#                       #",
    "#  ######       ######  #",
    "#                       #",
    "#                       #",
    "#                       #",
    "#####   #########   #####",
    "#       #       #       #",
    "#       #       #       #",
    "#      ##       ##      #",
    "#    ###         ###    #",
    "#                       #",
    "#                       #",
    "##########=====##########",
]

TILES = {
    "#": lambda: [rect(TILE, TILE), color(52, 73, 94), area(),
                  body(isStatic=True), "wall"],
    "=": lambda: [rect(TILE, TILE), color(231, 76, 60)],
}

addLevel(LEVEL, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "tiles": TILES,
})

bean = add([sprite("bean"), scale(BEAN_SIZE), pos(500, 340),
            anchor("center"), area(), body(), "player"])

coin = add([sprite("coin"), scale(COIN_SIZE), pos(COIN_SPOTS[0]),
            anchor("center"), area(), opacity(1), "coin"])

score = 0
next_enemy = 0      # seconds until the next red block drops in
label = add([text("Score: 0", size=36), pos(60, 50), z(100)])


@onUpdate
def each_frame():
    global next_enemy

    if isKeyDown("left"):
        bean.move(-RUN_SPEED, 0)
        bean.flipX = True
    if isKeyDown("right"):
        bean.move(RUN_SPEED, 0)
        bean.flipX = False

    if isKeyDown("up") or isKeyDown("space"):
        if bean.isGrounded():
            bean.jump(JUMP)
            play("jump")

    # Time for another red block? dt() is how long this frame took, so
    # taking it away every frame counts down in real seconds.
    next_enemy = next_enemy - dt()
    if next_enemy <= 0:
        add_enemy()
        next_enemy = ENEMY_WAIT


@bean.onCollide("coin")
def got_coin(c):
    global score

    score = score + POINTS
    label.text = "Score: " + str(score)
    play("coin")
    move_coin()


def move_coin():
    # Every spot except the one the coin is at now.
    spots = []
    for spot in COIN_SPOTS:
        if spot.x != coin.pos.x or spot.y != coin.pos.y:
            spots.append(spot)

    coin.pos = choose(spots)

    # Fade in at the new spot, so you can see where it went.
    tween(0, 1, 0.3, fade_coin)


def fade_coin(amount):
    coin.opacity = amount


def add_enemy():
    # In through the gap in the ceiling. lifespan(15) clears it away
    # after 15 seconds, so they never pile up.
    enemy = add([rect(40, 40), color(231, 76, 60), pos(500, -20),
                 anchor("center"), area(), body(), lifespan(15),
                 "enemy",
                 {"speed": choose([-200, 200, 60, -60]), "last_x": 0}])

    # After two seconds it stops dawdling and picks a direction.
    @wait(2)
    def get_going():
        enemy.speed = choose([-ENEMY_SPEED, ENEMY_SPEED])


@onUpdate("enemy")
def run_about(enemy):
    # If it got nowhere since last frame, a wall is in the way -- so
    # turn round.
    if abs(enemy.pos.x - enemy.last_x) < 0.1:
        enemy.speed = -enemy.speed
    enemy.last_x = enemy.pos.x
    enemy.move(enemy.speed, 0)

The red blocks don't hurt yet. They just shove Bean about. That's the next step.

The countdown. next_enemy is how many seconds until the next block. dt() is how long the last frame took, so taking it away every frame counts down in real seconds. When it gets to zero, a block is added and the countdown starts again from ENEMY_WAIT. It starts at 0, so the first block arrives straight away.

Each block's own values. The {"speed": ..., "last_x": 0} at the end of the list gives each block two variables of its own, read as enemy.speed and enemy.last_x. Each block runs at its own speed, so they need one each.

Getting going. A new block wanders slowly or quickly for two seconds, then @wait(2) gives it a proper speed in one direction or the other. Because get_going is written inside add_enemy, the enemy it changes is the block that was just made, not some other one.

Turning round at walls. This is the one bit of thinking in the game. A block runs into a wall and the wall stops it, but nothing tells the block it hit anything. So every frame it asks: did I get anywhere since last frame? If its x is the same as last_x (to within a tenth of a pixel), something must be in the way, so it turns round. abs() is the size of a number without its minus sign, so abs(enemy.pos.x - enemy.last_x) is how far it moved either way.

last_x starts at 0, far away from where a block appears. Start it at 500 instead and every new block would think it was stuck on its very first frame, and turn round before it had moved.

lifespan(15) removes a block after fifteen seconds, so they don't pile up for ever. Plenty of them fall out through the gap in the floor long before that.

Step 5 — game over, and again

Touching a red block, or falling out through a gap, now ends the game. A second later it starts again.

Starting again means throwing away every wall, coin and block and building them all fresh. That is what a scene is for. Everything from addLevel down moves inside @scene("game"), one step further in, and go("game") builds it. Calling go("game") again throws the whole scene away and builds it from the start.

from kaypy import *

TILE = 40
RUN_SPEED = 400       # how fast Bean runs, in pixels per second
JUMP = 640            # how hard Bean jumps
BEAN_SIZE = 0.6       # Bean's picture is bigger than a tile, so shrink it
COIN_SIZE = 0.7
POINTS = 5            # points for each coin
ENEMY_SPEED = 200     # how fast a red block runs once it gets going
ENEMY_WAIT = 3        # seconds between red blocks

# Where the coin can be.
COIN_SPOTS = [
    vec2(120, 300),
    vec2(840, 300),
    vec2(800, 160),
    vec2(200, 160),
    vec2(140, 600),
    vec2(900, 600),
    vec2(230, 490),
    vec2(760, 490),
]

kaypy(width=TILE * 25, height=TILE * 17, background=[52, 152, 219])
setGravity(1000)

loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSound("jump", "sounds/swoosh.wav")
loadSound("coin", "sounds/small_beep.wav")
loadSound("dead", "sounds/small_boom.wav")

LEVEL = [
    "##########=====##########",
    "#                       #",
    "#                       #",
    "#         #   #         #",
    "#                       #",
    "#  ######       ######  #",
    "#                       #",
    "#                       #",
    "#                       #",
    "#####   #########   #####",
    "#       #       #       #",
    "#       #       #       #",
    "#      ##       ##      #",
    "#    ###         ###    #",
    "#                       #",
    "#                       #",
    "##########=====##########",
]

TILES = {
    "#": lambda: [rect(TILE, TILE), color(52, 73, 94), area(),
                  body(isStatic=True), "wall"],
    "=": lambda: [rect(TILE, TILE), color(231, 76, 60)],
}


score = 0
next_enemy = 0
dead = False


@scene("game")
def game():
    global score, next_enemy, dead

    # Back to the start, every time the scene is built.
    score = 0
    next_enemy = 0
    dead = False

    addLevel(LEVEL, {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "tiles": TILES,
    })

    bean = add([sprite("bean"), scale(BEAN_SIZE), pos(500, 340),
                anchor("center"), area(), body(), z(10), "player"])

    coin = add([sprite("coin"), scale(COIN_SIZE), pos(COIN_SPOTS[0]),
                anchor("center"), area(), opacity(1), "coin"])

    label = add([text("Score: 0", size=36), pos(60, 50), z(100)])

    @onUpdate
    def each_frame():
        global next_enemy

        if dead:
            return

        if isKeyDown("left"):
            bean.move(-RUN_SPEED, 0)
            bean.flipX = True
        if isKeyDown("right"):
            bean.move(RUN_SPEED, 0)
            bean.flipX = False

        if isKeyDown("up") or isKeyDown("space"):
            if bean.isGrounded():
                bean.jump(JUMP)
                play("jump")

        # Out through the gap in the floor, or the one in the ceiling.
        if bean.pos.y > height() or bean.pos.y < 0:
            die()

        next_enemy = next_enemy - dt()
        if next_enemy <= 0:
            add_enemy()
            next_enemy = ENEMY_WAIT

    @bean.onCollide("coin")
    def got_coin(c):
        global score

        if dead:
            return

        score = score + POINTS
        label.text = "Score: " + str(score)
        play("coin")
        move_coin()

    def move_coin():
        spots = []
        for spot in COIN_SPOTS:
            if spot.x != coin.pos.x or spot.y != coin.pos.y:
                spots.append(spot)

        coin.pos = choose(spots)
        tween(0, 1, 0.3, fade_coin)

    def fade_coin(amount):
        coin.opacity = amount

    def add_enemy():
        enemy = add([rect(40, 40), color(231, 76, 60), pos(500, -20),
                     anchor("center"), area(), body(), lifespan(15),
                     "enemy",
                     {"speed": choose([-200, 200, 60, -60]), "last_x": 0}])

        @wait(2)
        def get_going():
            enemy.speed = choose([-ENEMY_SPEED, ENEMY_SPEED])

    @onUpdate("enemy")
    def run_about(enemy):
        if abs(enemy.pos.x - enemy.last_x) < 0.1:
            enemy.speed = -enemy.speed
        enemy.last_x = enemy.pos.x
        enemy.move(enemy.speed, 0)

    @bean.onCollide("enemy")
    def hit(enemy):
        die()

    def die():
        global dead

        # Touching two red blocks at once would otherwise die twice.
        if dead:
            return
        dead = True

        play("dead")
        shake(12)

        # A red flash over the whole window, gone in a third of a second.
        add([rect(width(), height()), color(255, 50, 35), opacity(0.6),
             lifespan(0.3, fade=0.3), z(200)])

        # Bean bursts into little white squares.
        for i in range(40):
            direction = vec2(rand(-1, 1), rand(-1, 1))
            add([rect(8, 8), pos(bean.pos), color(255, 255, 255),
                 opacity(1), move(direction, rand(50, 300)),
                 lifespan(1, fade=0.5), z(150)])

        bean.destroy()

        # Start again, a second later.
        @wait(1)
        def start_again():
            go("game")


go("game")

The variables that change during a game are set back to their starting values at the top of the scene, because the scene is built again every game. If score = 0 wasn't there, your next game would start with the last game's score.

Why dead? Without it, die() would run more than once. Touch two red blocks at once and that's two calls. And Bean is still below the window after falling out, so each_frame would call die() again on every frame until the game restarted, playing the sound and starting a new restart timer each time. So die() sets dead = True the first time and does nothing after that, and each_frame and got_coin stop working once it is set.

The burst. A for loop makes forty small white squares where Bean was. Each gets its own random direction and speed from move(), and lifespan(1, fade=0.5) fades it out and removes it after a second. The fade needs opacity(1) in the list. Leave it out and the squares vanish all at once instead of fading, and kaypy prints note: lifespan(fade=...) needs an opacity() component to fade for every one of them.

Don't call the scene function play. kaypy already has a play() for sounds. Write def play(): and you have replaced it, so every play("coin") afterwards tries to start your scene instead of making a noise. That's why the scene is called "game" and its function is game().

Step 6 — the whole game

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

  • A menu. A second scene, "menu", with the title, "Press Up to Start" and your score. Dying now does go("menu", score), and the score is handed to the menu as last_score. The game starts on the menu with go("menu", 0), since there is no last game yet.
  • A best score that is remembered. getData(BEST_KEY, 0) reads the best score saved last time, or 0 if there isn't one. setData saves a new one. The key is "coin_dash_best" rather than just "best", because every game on a website shares the same storage, and another game saving its own "best" would overwrite yours.
  • Red blocks that speed up. enemy_wait() works out the wait from your score. At 0 points it is 5 seconds; at 25 points, halfway to HARDEST_AT, it is 2.75; at 50 points and above it is half a second.

The title drops in with tween again, this time moving the title's y from above the window down to 160, with easings.easeOutBounce so it bounces as it lands. "Press Up to Start" rocks from side to side because wave(-2, 2, time() * 3) swings between −2 and 2 degrees for ever.

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 Dash -- grab the coin, dodge the red blocks, don't fall in the hole.

    left / right   run
    up or space    jump

One coin at a time. Grab it and it pops up somewhere else, worth 5 points.
Red blocks drop in through the gap in the ceiling and run about, and the
higher your score, the faster they come. Touch one, or fall through the gap
in the floor, and it is game over. Your best score is remembered.

HOW THIS FILE IS PUT TOGETHER

    1. the window, the pictures and the sounds
    2. the settings
    3. the level
    4. the menu scene
    5. the game scene: Bean, the coin, the red blocks, dying
    6. start the game

The level is a picture made of letters. Change a '#' and you have moved a
wall -- you never have to touch the code to change the level.
"""

from kaypy import *

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

TILE = 40          # one tile is 40 pixels square
ACROSS = 25        # tiles across
DOWN = 17          # tiles down

kaypy(width=TILE * ACROSS, height=TILE * DOWN, background=[52, 152, 219])

# A platform game, so things fall. Without this line nothing would.
setGravity(1000)

# Every picture and sound here comes with the playground, so you can paste
# this whole file in and press Run. The red blocks are not pictures at all
# -- they are plain rectangles, made with rect().
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")

# The name before the comma is the name the game uses. The file after it is
# the one that gets played -- swap it for any sound in the playground.
loadSound("jump", "sounds/swoosh.wav")
loadSound("coin", "sounds/small_beep.wav")
loadSound("dead", "sounds/small_boom.wav")


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

BEAN_SIZE = 0.6       # Bean's picture is bigger than a tile, so shrink it
COIN_SIZE = 0.7       # ...and the coin a little less
RUN_SPEED = 400       # how fast you run, in pixels per second
JUMP = 640            # how hard you jump
ENEMY_SPEED = 200     # how fast a red block runs once it gets going
POINTS = 5            # points for each coin

# A new red block every 5 seconds to begin with. The more points you have,
# the shorter the wait, until at 50 points there is one every half second.
SLOWEST = 5
FASTEST = 0.5
HARDEST_AT = 50

# Where the coin can be. Each time you grab it, it moves to one of these --
# never the one it was just at.
COIN_SPOTS = [
    vec2(120, 300),
    vec2(840, 300),
    vec2(800, 160),
    vec2(200, 160),
    vec2(140, 600),
    vec2(900, 600),
    vec2(230, 490),
    vec2(760, 490),
]

# 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_dash_best"


# ----------------------------------------------------------------- 3. level
#
# One letter is one tile.
#
#   #  wall
#   =  the red gaps, top and bottom. Red blocks drop in through the top
#      one, and you can fall out through the bottom one.
#
# A space is empty. Every row has to be 25 letters long.

LEVEL = [
    "##########=====##########",
    "#                       #",
    "#                       #",
    "#         #   #         #",
    "#                       #",
    "#  ######       ######  #",
    "#                       #",
    "#                       #",
    "#                       #",
    "#####   #########   #####",
    "#       #       #       #",
    "#       #       #       #",
    "#      ##       ##      #",
    "#    ###         ###    #",
    "#                       #",
    "#                       #",
    "##########=====##########",
]

# What each letter turns into. A wall has body(isStatic=True), so things
# stand on it and bump into it. A gap has no area(), so nothing touches it
# -- it is only there to be looked at.
TILES = {
    "#": lambda: [rect(TILE, TILE), color(52, 73, 94), area(),
                  body(isStatic=True), "wall"],
    "=": lambda: [rect(TILE, TILE), color(231, 76, 60)],
}


# ------------------------------------------------------------ 4. the menu
#
# last_score is what you got last time. The very first time, there is no
# last time, so it starts at 0.

@scene("menu")
def menu(last_score):
    best = getData(BEST_KEY, 0)
    if last_score > best:
        best = last_score
        setData(BEST_KEY, best)

    # The title starts above the top of the window and drops in, bouncing.
    # tween() changes a number smoothly from -100 to 160 over one second,
    # and calls drop_title with each new number along the way.
    title = add([text("Coin Dash", size=120), pos(width() / 2, -100),
                 anchor("center")])

    def drop_title(y):
        title.pos.y = y

    tween(-100, 160, 1, drop_title, easings.easeOutBounce)

    start = add([text("Press Up to Start", size=60), pos(center()),
                 anchor("center"), rotate(0)])

    add([text("Score: " + str(last_score) + "\nBest Score: " + str(best),
              size=44),
         pos(width() / 2, 520), anchor("center")])

    # wave() swings between two numbers for ever, so the words rock from
    # side to side.
    @onUpdate
    def rock():
        start.angle = wave(-2, 2, time() * 3)

    @onKeyPress("up")
    def start_game():
        go("game")

    @onKeyPress("space")
    def start_game_too():
        go("game")

    @onClick
    def clicked():
        go("game")


# ------------------------------------------------------- 5. the game scene
#
# The scene is CALLED "game". 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 game instead of making a noise.
#
# 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".

score = 0
next_enemy = 0      # seconds until the next red block drops in
dead = False


@scene("game")
def game():
    global score, next_enemy, dead

    score = 0
    next_enemy = 0
    dead = False

    addLevel(LEVEL, {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "tiles": TILES,
    })

    bean = add([sprite("bean"), scale(BEAN_SIZE), pos(500, 340),
                anchor("center"), area(), body(), z(10), "player"])

    coin = add([sprite("coin"), scale(COIN_SIZE), pos(COIN_SPOTS[0]),
                anchor("center"), area(), opacity(1), "coin"])

    label = add([text("Score: 0", size=36), pos(60, 50), z(100)])

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

    @onUpdate
    def each_frame():
        global next_enemy

        if dead:
            return

        # Running. flipX turns the picture round, so Bean faces the way
        # it is going.
        if isKeyDown("left"):
            bean.move(-RUN_SPEED, 0)
            bean.flipX = True
        if isKeyDown("right"):
            bean.move(RUN_SPEED, 0)
            bean.flipX = False

        # Jumping. Only from the ground, or you could fly by tapping.
        if isKeyDown("up") or isKeyDown("space"):
            if bean.isGrounded():
                bean.jump(JUMP)
                play("jump")

        # Out through the gap in the floor, or the one in the ceiling.
        if bean.pos.y > height() or bean.pos.y < 0:
            die()

        # Time for another red block? dt() is how long this frame took, so
        # taking it away every frame counts down in real seconds.
        next_enemy = next_enemy - dt()
        if next_enemy <= 0:
            add_enemy()
            next_enemy = enemy_wait()

    # -------------------------------------------------------------- coins

    @bean.onCollide("coin")
    def got_coin(c):
        global score

        if dead:
            return

        score = score + POINTS
        label.text = "Score: " + str(score)
        play("coin")
        move_coin()

    def move_coin():
        # Every spot except the one the coin is at now.
        spots = []
        for spot in COIN_SPOTS:
            if spot.x != coin.pos.x or spot.y != coin.pos.y:
                spots.append(spot)

        coin.pos = choose(spots)

        # Fade in at the new spot, so you can see where it went.
        tween(0, 1, 0.3, fade_coin)

    def fade_coin(amount):
        coin.opacity = amount

    # -------------------------------------------------------- red blocks

    # The longer you survive, the less time between red blocks.
    def enemy_wait():
        progress = score / HARDEST_AT
        if progress > 1:
            progress = 1
        return SLOWEST - (SLOWEST - FASTEST) * progress

    def add_enemy():
        # In through the gap in the ceiling. lifespan(15) clears it away
        # after 15 seconds, so they never pile up.
        enemy = add([rect(40, 40), color(231, 76, 60), pos(500, -20),
                     anchor("center"), area(), body(), lifespan(15),
                     "enemy",
                     {"speed": choose([-200, 200, 60, -60]), "last_x": 0}])

        # After two seconds it stops dawdling and picks a direction.
        @wait(2)
        def get_going():
            enemy.speed = choose([-ENEMY_SPEED, ENEMY_SPEED])

    @onUpdate("enemy")
    def run_about(enemy):
        # If it got nowhere since last frame, a wall is in the way -- so
        # turn round. abs() is the size of a number without its sign, so
        # this asks "did it move less than a tenth of a pixel either way?"
        # (last_x starts at 0, far from where a block starts, so a brand
        # new block does not turn round before it has had a chance to move.)
        if abs(enemy.pos.x - enemy.last_x) < 0.1:
            enemy.speed = -enemy.speed
        enemy.last_x = enemy.pos.x
        enemy.move(enemy.speed, 0)

    @bean.onCollide("enemy")
    def hit(enemy):
        die()

    # ------------------------------------------------------------ dying

    def die():
        global dead

        # Touching two red blocks at once would otherwise die twice.
        if dead:
            return
        dead = True

        play("dead")
        shake(12)

        # A red flash over the whole window, gone in a third of a second.
        add([rect(width(), height()), color(255, 50, 35), opacity(0.6),
             lifespan(0.3, fade=0.3), z(200)])

        # Bean bursts into little white squares, each flying off in
        # its own direction and fading away.
        for i in range(40):
            direction = vec2(rand(-1, 1), rand(-1, 1))
            add([rect(8, 8), pos(bean.pos), color(255, 255, 255),
                 opacity(1), move(direction, rand(50, 300)),
                 lifespan(1, fade=0.5),
                 z(150)])

        bean.destroy()

        @wait(1)
        def back_to_menu():
            go("menu", score)


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

go("menu", 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. RUN_SPEED, JUMP, ENEMY_SPEED, HARDEST_AT. Try setGravity(500) with the same JUMP and Bean floats like it's on the moon.
  • Redraw the room. Move the #s around. Check the COIN_SPOTS afterwards: a spot that is now inside a wall puts the coin where Bean can never reach it. Each tile is 40 pixels, so the tile in column c and row r has its middle at vec2(c * 40 + 20, r * 40 + 20), counting from 0.
  • Add a second kind of block. Copy add_enemy into add_fast_enemy, make the block purple and give it twice the speed. Keep the "enemy" tag and it already turns at walls and kills Bean, because both of those work from the tag.
  • Use WASD as well. Change isKeyDown("left") to isKeyDown("left") or isKeyDown("a"), and the same for the others.