Building Bomb Dodge

Play it first → Bombs fall from the sky and Bean runs left and right along the grass to stay out of the way. Every bomb that lands on the grass is a point. The more points you have, the faster they fall and the more of them there are. One hit and it's 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. There are only two pictures, Bean and the bomb.

It is the smallest game on the site that writes functions of its own. If you haven't got to functions yet, build Ghost Chase first. After this one, and after dictionaries, try Goblin Hit, then Log Hacker, then Paddle Ball, then Spiral Storm, and then Pong.

The one big idea: kaypy calls you

You never write a loop that checks the keyboard sixty times a second. You write a function, and tell kaypy when to run it: @onKeyDown("left") says "run this every frame the left arrow is held", and wait(1, drop_bomb) says "run this in a second". The game is a handful of small functions like that, each one doing a single job.

There is no gravity, and nothing is solid. Nothing has an area() or a body(). Each bomb moves itself a little way down every frame, and asks two questions: am I close to Bean? and have I reached the grass? That's the whole game. Everything else is keeping score.

Step 1 — Bean runs

from kaypy import *

GROUND = 60           # how tall the strip of grass is
BEAN_SPEED = 420      # how fast Bean runs, in pixels per second

kaypy(width=800, height=600, background=[121, 189, 220])

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

# The grass.
add([rect(width(), GROUND), pos(0, height() - GROUND), color(96, 156, 76)])

# anchor("bot") means pos() is where Bean's feet are, so Bean stands on
# the grass.
bean = add([sprite("bean"), pos(width() / 2, height() - GROUND),
            anchor("bot")])


@onKeyDown("left")
def run_left():
    bean.move(-BEAN_SPEED, 0)
    keep_on_screen()


@onKeyDown("right")
def run_right():
    bean.move(BEAN_SPEED, 0)
    keep_on_screen()


# Holding the mouse down -- or a finger, on a phone -- runs Bean
# towards it. Within 10 pixels counts as there, and Bean stops.
@onMouseDown
def run_to_mouse():
    if mousePos().x < bean.pos.x - 10:
        bean.move(-BEAN_SPEED, 0)
    if mousePos().x > bean.pos.x + 10:
        bean.move(BEAN_SPEED, 0)
    keep_on_screen()


def keep_on_screen():
    # 32 is half of Bean's width.
    bean.pos.x = clamp(bean.pos.x, 32, width() - 32)

Hold left or right and Bean runs. Hold the mouse down somewhere and Bean runs towards it. On a phone, a finger does the same thing.

  • anchor("bot") makes pos() mean the bottom middle of Bean, its feet. Put Bean's feet at height() - GROUND, the top of the grass, and Bean stands on it. No gravity is needed, because Bean never jumps.
  • @onKeyDown("left") runs run_left every frame the left arrow is held down, about sixty times a second, so Bean keeps running until you let go. @onKeyDown("right") does the same for the right arrow.
  • bean.move(-BEAN_SPEED, 0) moves Bean left at BEAN_SPEED pixels per second. kaypy works out how far that is for one frame, so Bean runs at the same speed on a fast computer and a slow one.
  • @onMouseDown runs run_to_mouse every frame the mouse button is held, or a finger is on the screen. mousePos().x is where it is, across the window. More than 10 pixels to the left of Bean and Bean runs left; more than 10 to the right and Bean runs right. Within 10 pixels counts as there, and Bean stops.

keep_on_screen() stops Bean at the edges. All three handlers call it straight after moving. clamp(bean.pos.x, 32, width() - 32) gives back bean.pos.x, unless it is less than 32 (then it gives 32) or more than 768 (then it gives 768). Bean is 64 pixels wide, so 32 is half of Bean, and Bean stops with its side touching the edge. It is a function of its own so that the three handlers can share it rather than each having a copy.

Two things to break.

Take keep_on_screen() out of run_left and hold left. There's no error message. Bean runs off the edge of the window and keeps going: three seconds of holding left took it to x = −853. Hold right for as long to bring it back.

Write @onKeyDown without the ("left"). There's no error message either, and the left arrow does nothing at all. kaypy needs to be told which key; without it, the function is never connected to the keyboard.

Step 2 — bombs fall

Every 0.9 seconds a bomb appears just above the window, somewhere along the top, and falls, spinning as it goes.

from kaypy import *

GROUND = 60           # how tall the strip of grass is
BEAN_SPEED = 420      # how fast Bean runs, in pixels per second
BOMB_SIZE = 0.5       # how big a bomb is: 1 is the whole picture
FALL_SPEED = 200      # how fast the bombs fall, in pixels per second
WAIT = 0.9            # seconds between bombs

kaypy(width=800, height=600, background=[121, 189, 220])

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

# The grass.
add([rect(width(), GROUND), pos(0, height() - GROUND), color(96, 156, 76)])

# anchor("bot") means pos() is where Bean's feet are, so Bean stands on
# the grass. z(5) draws Bean in front of the bombs.
bean = add([sprite("bean"), pos(width() / 2, height() - GROUND),
            anchor("bot"), z(5)])


@onKeyDown("left")
def run_left():
    bean.move(-BEAN_SPEED, 0)
    keep_on_screen()


@onKeyDown("right")
def run_right():
    bean.move(BEAN_SPEED, 0)
    keep_on_screen()


# Holding the mouse down -- or a finger, on a phone -- runs Bean
# towards it. Within 10 pixels counts as there, and Bean stops.
@onMouseDown
def run_to_mouse():
    if mousePos().x < bean.pos.x - 10:
        bean.move(-BEAN_SPEED, 0)
    if mousePos().x > bean.pos.x + 10:
        bean.move(BEAN_SPEED, 0)
    keep_on_screen()


def keep_on_screen():
    # 32 is half of Bean's width.
    bean.pos.x = clamp(bean.pos.x, 32, width() - 32)


def drop_bomb():
    # Somewhere along the top, just above the window. rotate(0) lets it
    # spin.
    x = rand(30, width() - 30)
    add([sprite("bomb"), pos(x, -40), anchor("center"),
         scale(BOMB_SIZE), rotate(0), "bomb"])

    # And the next one, a little later.
    wait(WAIT, drop_bomb)


# The first bomb, a second after the game starts.
wait(1, drop_bomb)


@onUpdate("bomb")
def fall(bomb):
    bomb.move(0, FALL_SPEED)
    bomb.angle = bomb.angle + 90 * dt()

    # Reached the grass? Throw it away.
    if bomb.pos.y > height() - GROUND:
        bomb.destroy()

One bomb leads to the next. wait(1, drop_bomb) at the bottom runs drop_bomb once, a second after the game starts. drop_bomb makes a bomb, and its last line is wait(WAIT, drop_bomb): run me again in 0.9 seconds. So every bomb arranges the next one, and they keep coming without a loop anywhere.

Making a bomb. rand(30, width() - 30) picks a random x across the top, and pos(x, -40) puts the bomb just above the window, so it falls into view rather than popping into it. scale(BOMB_SIZE) draws the picture at half size. The tag "bomb" is a name for every bomb at once.

Falling. @onUpdate("bomb") runs fall once a frame for every object tagged "bomb", handing each one in as bomb. Each one moves down at FALL_SPEED and turns 90 degrees a second. When it gets to the grass it is destroyed. Without that, the game would keep every bomb it ever made, falling for ever out of sight.

Bean now has z(5). Things are drawn in the order they were added, so bombs, added after Bean, would be drawn over it. A higher z is drawn in front.

Two things to break.

Put brackets after drop_bomb in the last line: wait(WAIT, drop_bomb()). A second in, the game stops with

RecursionError: maximum recursion depth exceeded

Without brackets, wait() is handed the function, to run later. With them, Python runs drop_bomb right now, to find out what to hand over. That drop_bomb gets to its last line and runs drop_bomb right now too, and so on, without ever stopping, until Python gives up. "Recursion" means a function running itself.

Take rotate(0) out of the bomb's list and the first bomb to appear stops the game with

AttributeError: GameObj has no attribute 'angle' — did you forget a component that provides it?

A bomb only has an angle if it has rotate(). The message says exactly that, which is worth remembering: "has no attribute" in kaypy nearly always means a component is missing from the list.

Bombs fall straight through Bean for now. That's step 4.

Step 3 — points

Every bomb that reaches the grass is a point, and the score goes at the top of the window.

from kaypy import *

GROUND = 60           # how tall the strip of grass is
BEAN_SPEED = 420      # how fast Bean runs, in pixels per second
BOMB_SIZE = 0.5       # how big a bomb is: 1 is the whole picture
FALL_SPEED = 200      # how fast the bombs fall, in pixels per second
WAIT = 0.9            # seconds between bombs

kaypy(width=800, height=600, background=[121, 189, 220])

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

# The grass.
add([rect(width(), GROUND), pos(0, height() - GROUND), color(96, 156, 76)])

# anchor("bot") means pos() is where Bean's feet are, so Bean stands on
# the grass. z(5) draws Bean in front of the bombs.
bean = add([sprite("bean"), pos(width() / 2, height() - GROUND),
            anchor("bot"), z(5)])

score_label = add([text("0", size=48), pos(width() / 2, 40),
                   anchor("center"), z(10)])

score = 0


@onKeyDown("left")
def run_left():
    bean.move(-BEAN_SPEED, 0)
    keep_on_screen()


@onKeyDown("right")
def run_right():
    bean.move(BEAN_SPEED, 0)
    keep_on_screen()


# Holding the mouse down -- or a finger, on a phone -- runs Bean
# towards it. Within 10 pixels counts as there, and Bean stops.
@onMouseDown
def run_to_mouse():
    if mousePos().x < bean.pos.x - 10:
        bean.move(-BEAN_SPEED, 0)
    if mousePos().x > bean.pos.x + 10:
        bean.move(BEAN_SPEED, 0)
    keep_on_screen()


def keep_on_screen():
    # 32 is half of Bean's width.
    bean.pos.x = clamp(bean.pos.x, 32, width() - 32)


def drop_bomb():
    # Somewhere along the top, just above the window. rotate(0) lets it
    # spin.
    x = rand(30, width() - 30)
    add([sprite("bomb"), pos(x, -40), anchor("center"),
         scale(BOMB_SIZE), rotate(0), "bomb"])

    # And the next one, a little later.
    wait(WAIT, drop_bomb)


# The first bomb, a second after the game starts.
wait(1, drop_bomb)


@onUpdate("bomb")
def fall(bomb):
    global score

    bomb.move(0, FALL_SPEED)
    bomb.angle = bomb.angle + 90 * dt()

    # Reached the grass? That is a point.
    if bomb.pos.y > height() - GROUND:
        bomb.destroy()
        score = score + 1
        score_label.text = str(score)

Three things were added: score = 0, a text() object to show it, and three lines in fall where a bomb reaches the grass. score_label.text = str(score) changes what the label says. str() turns the number into text, because text has to be text.

fall now changes score, so it says global score first. score was made outside the function, and a function that changes a variable from outside itself has to say global. Without it, Python decides score is a new variable belonging to fall, which has no value yet when fall tries to add 1 to it. Leave it out and nothing goes wrong until the first bomb lands — nearly four seconds in — and then:

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

Stand still and watch: bombs go straight through Bean, and you get the points anyway. Not much of a game yet.

Step 4 — getting hit, and playing again

A bomb that gets close enough to Bean now blows up, and the game is over. Press space or click to play again.

Playing again means throwing away every bomb and building everything fresh. That's what a scene is for. Everything from the grass down moves inside @scene("game"), one step further in, and go("game") at the bottom builds it. Calling go("game") again throws the whole scene away and builds it from the start.

from kaypy import *

GROUND = 60           # how tall the strip of grass is
BEAN_SPEED = 420      # how fast Bean runs, in pixels per second
BOMB_SIZE = 0.5       # how big a bomb is: 1 is the whole picture
HIT_DISTANCE = 42     # how close a bomb has to get to Bean to hit it
FALL_SPEED = 200      # how fast the bombs fall, in pixels per second
WAIT = 0.9            # seconds between bombs

kaypy(width=800, height=600, background=[121, 189, 220])

loadSprite("bean", "images/bean.png")
loadSprite("bomb", "images/bomb.png")
loadSound("boom", "sounds/small_boom.wav")

score = 0
alive = True


@scene("game")
def game():
    global score, alive

    # A new game, so everything starts again.
    score = 0
    alive = True

    # The grass.
    add([rect(width(), GROUND), pos(0, height() - GROUND),
         color(96, 156, 76)])

    # anchor("bot") means pos() is where Bean's feet are, so Bean stands
    # on the grass. z(5) draws Bean in front of the bombs.
    bean = add([sprite("bean"), pos(width() / 2, height() - GROUND),
                anchor("bot"), z(5)])

    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), z(10)])

    # width=560 wraps a long message onto more than one line.
    message = add([text("", size=26, width=560),
                   pos(width() / 2, height() / 2), anchor("center"), z(10)])

    @onKeyDown("left")
    def run_left():
        bean.move(-BEAN_SPEED, 0)
        keep_on_screen()

    @onKeyDown("right")
    def run_right():
        bean.move(BEAN_SPEED, 0)
        keep_on_screen()

    # Holding the mouse down -- or a finger, on a phone -- runs Bean
    # towards it. Within 10 pixels counts as there, and Bean stops.
    @onMouseDown
    def run_to_mouse():
        if mousePos().x < bean.pos.x - 10:
            bean.move(-BEAN_SPEED, 0)
        if mousePos().x > bean.pos.x + 10:
            bean.move(BEAN_SPEED, 0)
        keep_on_screen()

    def keep_on_screen():
        # 32 is half of Bean's width.
        bean.pos.x = clamp(bean.pos.x, 32, width() - 32)

    def drop_bomb():
        # Game over: no bomb, and no wait() for the next one either, so
        # the bombs stop here.
        if not alive:
            return

        # Somewhere along the top, just above the window. rotate(0) lets it
        # spin.
        x = rand(30, width() - 30)
        add([sprite("bomb"), pos(x, -40), anchor("center"),
             scale(BOMB_SIZE), rotate(0), "bomb"])

        # And the next one, a little later.
        wait(WAIT, drop_bomb)

    # The first bomb, a second after the game starts.
    wait(1, drop_bomb)

    @onUpdate("bomb")
    def fall(bomb):
        global score

        if not alive:
            return

        bomb.move(0, FALL_SPEED)
        bomb.angle = bomb.angle + 90 * dt()

        # Close enough to Bean to hit it? 27 pixels up from Bean's feet is
        # the middle of Bean.
        middle = vec2(bean.pos.x, bean.pos.y - 27)
        if bomb.pos.dist(middle) < HIT_DISTANCE:
            hit(bomb)
            return

        # Reached the grass? That is a point.
        if bomb.pos.y > height() - GROUND:
            bomb.destroy()
            score = score + 1
            score_label.text = str(score)

    def hit(bomb):
        global alive

        alive = False
        play("boom")
        shake(12)
        addKaboom(bomb.pos)

        # Bean goes, and so does every bomb still falling.
        bean.destroy()
        destroyAll("bomb")

        message.text = (str(score)
                        + " points -- press SPACE or click to play again")

    # Once the game is over, space or a click starts a new one.
    def again():
        if not alive:
            go("game")

    onKeyPress("space", again)
    onClick(again)


go("game")

Close enough. bean.pos is Bean's feet, so 27 pixels up, half of Bean's height, is the middle of Bean. bomb.pos.dist(middle) is how far the middle of the bomb is from the middle of Bean, in a straight line. Less than HIT_DISTANCE, 42 pixels, is a hit. Make HIT_DISTANCE bigger and the bombs get you from further away; smaller, and you can get away with near misses.

One hit. hit() does everything that happens when you lose: a sound, a shake, an explosion where the bomb was, and then Bean and every bomb are destroyed. destroyAll("bomb") gets rid of everything tagged "bomb" in one line.

alive is what stops the game. Once it is False, drop_bomb returns straight away, before making a bomb and before its wait(), so the chain of bombs ends there. fall checks it too. With every bomb destroyed it never has anything to do, but it keeps the rule simple: once alive is False, nothing falls. The key handlers don't need to check: Bean has been destroyed, so there is nothing left on the screen for them to move. And again only starts a new game when you are not alive, so clicking to move Bean doesn't restart the game you are in the middle of.

Starting again clears everything. go("game") throws away every object, every handler and every wait() still waiting, before it builds the scene again. So a new game never has the old game's bombs, or a second chain of them, still going.

The variables live outside the scene. score and alive are made at the top, and the scene sets them back to their starting values every time it is built. Leave out score = 0 inside the scene and the next game carries on from the last one's score. The label says 0 at the start, because it is made new, but if the last game ended on 9 points, the first bomb to land makes it 10.

Take out destroyAll("bomb") and there's no error. The bombs that were still falling stop where they are, because fall does nothing once alive is False, and hang in the air over the game-over message.

Step 5 — harder as you go

The fall speed and the time between bombs were fixed numbers. Now each one is worked out from the score by a small function.

from kaypy import *

GROUND = 60           # how tall the strip of grass is
BEAN_SPEED = 420      # how fast Bean runs, in pixels per second
BOMB_SIZE = 0.5       # how big a bomb is: 1 is the whole picture
HIT_DISTANCE = 42     # how close a bomb has to get to Bean to hit it
FALL_START = 200      # how fast the bombs fall, to begin with
FALL_FASTEST = 520    # ...the fastest they ever fall
FALL_GAIN = 8         # ...and how much faster each point makes them

WAIT_START = 0.9      # seconds between bombs, to begin with
WAIT_SHORTEST = 0.3   # ...the shortest wait there ever is
WAIT_DROP = 0.015     # ...and how much shorter each point makes it

kaypy(width=800, height=600, background=[121, 189, 220])

loadSprite("bean", "images/bean.png")
loadSprite("bomb", "images/bomb.png")
loadSound("boom", "sounds/small_boom.wav")

score = 0


def fall_speed():
    speed = FALL_START + score * FALL_GAIN
    if speed > FALL_FASTEST:
        speed = FALL_FASTEST
    return speed


def bomb_wait():
    seconds = WAIT_START - score * WAIT_DROP
    if seconds < WAIT_SHORTEST:
        seconds = WAIT_SHORTEST
    return seconds


alive = True


@scene("game")
def game():
    global score, alive

    # A new game, so everything starts again.
    score = 0
    alive = True

    # The grass.
    add([rect(width(), GROUND), pos(0, height() - GROUND),
         color(96, 156, 76)])

    # anchor("bot") means pos() is where Bean's feet are, so Bean stands
    # on the grass. z(5) draws Bean in front of the bombs.
    bean = add([sprite("bean"), pos(width() / 2, height() - GROUND),
                anchor("bot"), z(5)])

    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), z(10)])

    # width=560 wraps a long message onto more than one line.
    message = add([text("", size=26, width=560),
                   pos(width() / 2, height() / 2), anchor("center"), z(10)])

    @onKeyDown("left")
    def run_left():
        bean.move(-BEAN_SPEED, 0)
        keep_on_screen()

    @onKeyDown("right")
    def run_right():
        bean.move(BEAN_SPEED, 0)
        keep_on_screen()

    # Holding the mouse down -- or a finger, on a phone -- runs Bean
    # towards it. Within 10 pixels counts as there, and Bean stops.
    @onMouseDown
    def run_to_mouse():
        if mousePos().x < bean.pos.x - 10:
            bean.move(-BEAN_SPEED, 0)
        if mousePos().x > bean.pos.x + 10:
            bean.move(BEAN_SPEED, 0)
        keep_on_screen()

    def keep_on_screen():
        # 32 is half of Bean's width.
        bean.pos.x = clamp(bean.pos.x, 32, width() - 32)

    def drop_bomb():
        # Game over: no bomb, and no wait() for the next one either, so
        # the bombs stop here.
        if not alive:
            return

        # Somewhere along the top, just above the window. rotate(0) lets it
        # spin.
        x = rand(30, width() - 30)
        add([sprite("bomb"), pos(x, -40), anchor("center"),
             scale(BOMB_SIZE), rotate(0), "bomb"])

        # And the next one, a little later.
        wait(bomb_wait(), drop_bomb)

    # The first bomb, a second after the game starts.
    wait(1, drop_bomb)

    @onUpdate("bomb")
    def fall(bomb):
        global score

        if not alive:
            return

        bomb.move(0, fall_speed())
        bomb.angle = bomb.angle + 90 * dt()

        # Close enough to Bean to hit it? 27 pixels up from Bean's feet is
        # the middle of Bean.
        middle = vec2(bean.pos.x, bean.pos.y - 27)
        if bomb.pos.dist(middle) < HIT_DISTANCE:
            hit(bomb)
            return

        # Reached the grass? That is a point.
        if bomb.pos.y > height() - GROUND:
            bomb.destroy()
            score = score + 1
            score_label.text = str(score)

    def hit(bomb):
        global alive

        alive = False
        play("boom")
        shake(12)
        addKaboom(bomb.pos)

        # Bean goes, and so does every bomb still falling.
        bean.destroy()
        destroyAll("bomb")

        message.text = (str(score)
                        + " points -- press SPACE or click to play again")

    # Once the game is over, space or a click starts a new one.
    def again():
        if not alive:
            go("game")

    onKeyPress("space", again)
    onClick(again)


go("game")

Take fall_speed(). It starts at FALL_START, 200 pixels a second, and adds 8 for every point. At 10 points that's 200 + 10 * 8, which is 280. At 50 points it would be 600, but the if stops it at FALL_FASTEST, 520. bomb_wait() works the same way: 0.9 seconds between bombs to begin with, 0.015 less for every point, and never less than 0.3. It is called in drop_bomb's wait(), so each bomb works out how long until the next one from the score at the moment it falls.

Those stops matter. Without them the bombs keep getting faster and closer together until nobody could dodge them. With them, the game gets hard by about 40 points and then stays there, which is a game people keep playing.

The two functions are outside the scene, near the top, with the other settings. They only read score, so they don't need global. Only a function that changes a variable does.

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 best score that is remembered. getData(BEST_KEY, 0) reads the best score saved last time, or 0 if there isn't one, and setData saves a new one when you beat it. BEST_KEY is "bomb_dodge_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.
  • A message at the start saying how to move, which goes away when you score your first point.
  • "1 point", not "1 points". An if picks the right word.

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

"""Bomb Dodge -- bombs fall from the sky. Don't get hit.

    left and right arrows   move
    or hold the mouse (or a finger) where you want Bean to go

Every bomb that lands on the grass instead of on Bean is a point. The more
points you have, the faster the bombs fall and the more of them there are.
One hit and it is game over. Your best score is remembered.

HOW THIS FILE IS PUT TOGETHER

    1. the window, the pictures and the sound
    2. the settings
    3. how hard the game is
    4. the game scene: Bean, the bombs, points, getting hit
    5. start the game

This is the smallest game on the site, and a good one to start from. Swap
the bomb for an apple, make Bean faster, make the bombs bigger -- every
number is in the settings.
"""

from kaypy import *

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

kaypy(width=800, height=600, background=[121, 189, 220])

loadSprite("bean", "images/bean.png")
loadSprite("bomb", "images/bomb.png")
loadSound("boom", "sounds/small_boom.wav")


# -------------------------------------------------------------- 2. settings
#
# Everything you would want to change is here. Change one at a time and
# play it.

GROUND = 60            # how tall the strip of grass is
BEAN_SPEED = 420       # how fast Bean runs, in pixels per second

BOMB_SIZE = 0.5        # how big a bomb is: 1 is the whole picture
HIT_DISTANCE = 42      # how close a bomb has to get to Bean to hit it

FALL_START = 200       # how fast the bombs fall, to begin with
FALL_FASTEST = 520     # ...the fastest they ever fall
FALL_GAIN = 8          # ...and how much faster each point makes them

WAIT_START = 0.9       # seconds between bombs, to begin with
WAIT_SHORTEST = 0.3    # ...the shortest wait there ever is
WAIT_DROP = 0.015      # ...and how much shorter each point makes it

# 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.
BEST_KEY = "bomb_dodge_best"


# -------------------------------------------------- 3. how hard the game is
#
# Each of these works out a number from the score. They get harder as the
# score goes up, and they stop somewhere, so the game ends up hard but never
# impossible.

score = 0


def fall_speed():
    speed = FALL_START + score * FALL_GAIN
    if speed > FALL_FASTEST:
        speed = FALL_FASTEST
    return speed


def bomb_wait():
    seconds = WAIT_START - score * WAIT_DROP
    if seconds < WAIT_SHORTEST:
        seconds = WAIT_SHORTEST
    return seconds


# ------------------------------------------------------- 4. the game scene
#
# 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".

alive = True


@scene("game")
def game():
    global score, alive

    # A new game, so everything starts again.
    score = 0
    alive = True
    best = getData(BEST_KEY, 0)

    # The grass. It is only a picture: nothing here uses area() or body(),
    # because the code below works out for itself what is touching what.
    add([rect(width(), GROUND), pos(0, height() - GROUND),
         color(96, 156, 76)])

    # anchor("bot") means pos() is where Bean's feet are, so Bean stands
    # on the grass. z(5) draws Bean in front of the bombs.
    bean = add([sprite("bean"), pos(width() / 2, height() - GROUND),
                anchor("bot"), z(5)])

    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), z(10)])
    best_label = add([text("best " + str(best), size=18), pos(12, 12),
                      opacity(0.75), z(10)])

    # width=560 wraps a long message onto more than one line.
    message = add([text("Arrow keys to move, or hold the mouse where you "
                        "want to go. Don't get hit!", size=26, width=560),
                   pos(width() / 2, height() / 2), anchor("center"), z(10)])

    # ------------------------------------------------------------ running
    #
    # onKeyDown runs its function every frame the key is held, so Bean
    # keeps running until you let go.

    @onKeyDown("left")
    def run_left():
        bean.move(-BEAN_SPEED, 0)
        keep_on_screen()

    @onKeyDown("right")
    def run_right():
        bean.move(BEAN_SPEED, 0)
        keep_on_screen()

    # Holding the mouse down -- or a finger, on a phone -- runs Bean
    # towards it. Within 10 pixels counts as there, and Bean stops.
    @onMouseDown
    def run_to_mouse():
        if mousePos().x < bean.pos.x - 10:
            bean.move(-BEAN_SPEED, 0)
        if mousePos().x > bean.pos.x + 10:
            bean.move(BEAN_SPEED, 0)
        keep_on_screen()

    def keep_on_screen():
        # 32 is half of Bean's width.
        bean.pos.x = clamp(bean.pos.x, 32, width() - 32)

    # --------------------------------------------------------------- bombs

    def drop_bomb():
        # Game over: no bomb, and no wait() for the next one either, so
        # the bombs stop here.
        if not alive:
            return

        # Somewhere along the top, just above the window so it falls in
        # rather than popping into view. rotate(0) lets it spin.
        x = rand(30, width() - 30)
        add([sprite("bomb"), pos(x, -40), anchor("center"),
             scale(BOMB_SIZE), rotate(0), "bomb"])

        # And the next one, a little later. No brackets after drop_bomb:
        # wait() is handed the function, to run when the time is up.
        wait(bomb_wait(), drop_bomb)

    # The first bomb, a second after the game starts.
    wait(1, drop_bomb)

    # Every bomb runs this, every frame.
    @onUpdate("bomb")
    def fall(bomb):
        global score

        if not alive:
            return

        bomb.move(0, fall_speed())
        bomb.angle = bomb.angle + 90 * dt()

        # Close enough to Bean to hit it? Bean's pos() is its feet, so
        # 27 pixels up -- half its height -- is the middle of Bean.
        middle = vec2(bean.pos.x, bean.pos.y - 27)
        if bomb.pos.dist(middle) < HIT_DISTANCE:
            hit(bomb)
            return

        # Reached the grass without hitting Bean? That is a point.
        if bomb.pos.y > height() - GROUND:
            bomb.destroy()
            score = score + 1
            score_label.text = str(score)
            if score == 1:
                message.text = ""

    # -------------------------------------------------------- getting hit

    def hit(bomb):
        global alive

        alive = False
        play("boom")
        shake(12)
        addKaboom(bomb.pos)

        # Bean goes, and so does every bomb still falling. Left where they
        # are, they hang in the air and cover up the message below.
        bean.destroy()
        destroyAll("bomb")

        if score > best:
            setData(BEST_KEY, score)
            best_label.text = "best " + str(score)

        if score == 1:
            message.text = "1 point -- press SPACE or click to play again"
        else:
            message.text = (str(score)
                            + " points -- press SPACE or click to play again")

    # Once the game is over, space or a click starts a new one.
    def again():
        if not alive:
            go("game")

    onKeyPress("space", again)
    onClick(again)


# ------------------------------------------------------- 5. start the game

go("game")

Make it yours

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

  • Change the numbers. Make BEAN_SPEED 250 and Bean feels heavy; make it 700 and Bean darts about. Make BOMB_SIZE 1 and the bombs are twice the size, and then HIT_DISTANCE wants to be bigger to match.
  • Something else falls. Change "images/bomb.png" to "images/apple.png" or "images/pizza.png". Click Sprites in the playground to see what there is.
  • A sound for each point. loadSound("point", "sounds/small_beep.wav") near the top, and play("point") where a bomb reaches the grass.
  • Some bombs fall faster than others. Give each bomb its own speed when it is made, with {"speed": rand(150, 300)} in its list, and use bomb.speed in fall instead of fall_speed().