Building Flappy Bean

Play it first → Bean is always falling, and every flap pushes it back up. Fly through the gap between each pair of pipes for a point. The more points you have, the faster the pipes come and the smaller the gaps get. Touch a pipe or the grass 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. The pipes are plain rectangles, so the only picture is Bean.

It is one of the shortest games on the site, which makes it a good early one to build. If you have never built a game before, start with Bomb Dodge, which is shorter still. If you want something longer afterwards, try Coin Dash.

The one big idea: Bean never moves sideways

It looks as if Bean flies to the right. It doesn't. Bean only ever goes up and down, and the pipes slide to the left. Your eye can't tell the difference, and the game is much simpler this way: there is no camera to move, and Bean's x is always 180.

Step 1 — Bean falls and flaps

from kaypy import *

FLAP = 520            # how hard a flap pushes Bean up
GROUND = 60           # how tall the strip of grass is

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

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

bean = add([sprite("bean"), pos(180, 240), anchor("center"), area(), body()])

# The grass. It is solid, so Bean lands on it.
add([rect(width(), GROUND), pos(0, height() - GROUND),
     color(96, 156, 76), area(), body(isStatic=True), "ground"])


def flap():
    bean.jump(FLAP)


onKeyPress("space", flap)
onKeyPress("up", flap)
onClick(flap)

Bean drops as soon as the game starts, so be quick. Press space, up or click to flap.

  • setGravity(1600) makes anything with a body() fall. Bean has one, so Bean falls.
  • The grass is a rectangle with body(isStatic=True), so it is solid and never moves. Bean lands on it.
  • bean.jump(FLAP) throws Bean upwards. In most platform games you check isGrounded() first so the player can't jump in mid-air. Here jumping in mid-air is the whole game, so there's no check.

onKeyPress("space", flap) means "when space is pressed, run flap." It's the same as writing @onKeyPress("space") above a function, but because three things all do the same job, it's shorter to write flap once and hand it to all three.

No brackets after flap. onKeyPress("space", flap) hands over the function, to be run later. onKeyPress("space", flap()) runs it right now, once, and hands over what it gave back, which is nothing. There's no error message. Bean flaps once by itself at the start, and after that the space bar does nothing.

Step 2 — pipes

Every 1.55 seconds a pair of pipes appears just off the right-hand edge and slides left.

from kaypy import *

FLAP = 520            # how hard a flap pushes Bean up
GROUND = 60           # how tall the strip of grass is
PIPE_WIDTH = 70
MARGIN = 90           # how close to the top or the grass a gap may be
GAP = 210             # the hole between the pipes
SPEED = 190           # how fast the pipes slide, in pixels per second
WAIT = 1.55           # seconds between pipes

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

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

bean = add([sprite("bean"), pos(180, 240), anchor("center"), area(), body(),
            z(5)])

# The grass. It is solid, so Bean lands on it.
add([rect(width(), GROUND), pos(0, height() - GROUND),
     color(96, 156, 76), area(), body(isStatic=True), "ground"])

next_pipe = WAIT      # seconds until the next pair of pipes


def flap():
    bean.jump(FLAP)


onKeyPress("space", flap)
onKeyPress("up", flap)
onClick(flap)


@onUpdate
def each_frame():
    global next_pipe

    next_pipe = next_pipe - dt()
    if next_pipe <= 0:
        add_pipes()
        next_pipe = WAIT


def add_pipes():
    # Where the gap starts, picked at random.
    top = rand(MARGIN, height() - GROUND - MARGIN - GAP)
    bottom = top + GAP
    x = width()             # just off the right-hand edge

    # The pipe above the gap, from the top of the window down.
    add([rect(PIPE_WIDTH, top), pos(x, 0), color(76, 145, 65),
         outline(4, (46, 100, 40)), area(), "pipe", "slides"])

    # The pipe below the gap, from the gap down to the grass.
    add([rect(PIPE_WIDTH, height() - GROUND - bottom), pos(x, bottom),
         color(76, 145, 65), outline(4, (46, 100, 40)), area(),
         "pipe", "slides"])


@onUpdate("slides")
def slide(thing):
    thing.move(-SPEED, 0)

    # Gone off the left-hand edge? Throw it away.
    if thing.pos.x < -PIPE_WIDTH - 20:
        thing.destroy()

The countdown. next_pipe is how many seconds until the next pair. dt() is how long the last frame took, so taking it away every frame counts down in real seconds. When it reaches zero, add_pipes() makes a pair and the countdown starts again.

Two pipes and a gap. The gap is GAP pixels tall, and top is where it starts, picked at random. The top pipe runs from the top of the window down to top. The bottom pipe starts at bottom, the bottom of the gap, and runs down to the grass. MARGIN keeps the gap from being so near the top or the grass that you couldn't get through it.

Sliding. Both pipes are tagged "slides", and @onUpdate("slides") runs slide once a frame for every object with that tag, handing each one in as thing. Once a pipe is off the left-hand edge it is destroyed. Without that, the game would keep every pipe it ever made, and after a few minutes there would be hundreds of them out of sight, all still being moved every frame.

Bean now has z(5) in its list. Things are drawn in the order they were added, so the pipes, added after Bean, would be drawn over it. A higher z is drawn in front, so 5 keeps Bean in front of the pipes, which have none.

The pipes have an area() but no body(). Bean flies straight through them for now. With a body() they would shove Bean out of the way instead, which looks like the game is broken rather than like you lost. Step 4 makes touching one the end of the game.

Step 3 — points

A point is for getting past a pair of pipes. The simplest way to notice that is to put something invisible in the gap and score a point when Bean touches it.

from kaypy import *

FLAP = 520            # how hard a flap pushes Bean up
GROUND = 60           # how tall the strip of grass is
PIPE_WIDTH = 70
MARGIN = 90           # how close to the top or the grass a gap may be
GAP = 210             # the hole between the pipes
SPEED = 190           # how fast the pipes slide, in pixels per second
WAIT = 1.55           # seconds between pipes

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

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

bean = add([sprite("bean"), pos(180, 240), anchor("center"), area(), body(),
            z(5)])

# The grass. It is solid, so Bean lands on it.
add([rect(width(), GROUND), pos(0, height() - GROUND),
     color(96, 156, 76), area(), body(isStatic=True), "ground"])

next_pipe = WAIT      # seconds until the next pair of pipes
score = 0

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


def flap():
    bean.jump(FLAP)


onKeyPress("space", flap)
onKeyPress("up", flap)
onClick(flap)


@onUpdate
def each_frame():
    global next_pipe

    next_pipe = next_pipe - dt()
    if next_pipe <= 0:
        add_pipes()
        next_pipe = WAIT


def add_pipes():
    # Where the gap starts, picked at random.
    top = rand(MARGIN, height() - GROUND - MARGIN - GAP)
    bottom = top + GAP
    x = width()             # just off the right-hand edge

    # The pipe above the gap, from the top of the window down.
    add([rect(PIPE_WIDTH, top), pos(x, 0), color(76, 145, 65),
         outline(4, (46, 100, 40)), area(), "pipe", "slides"])

    # The pipe below the gap, from the gap down to the grass.
    add([rect(PIPE_WIDTH, height() - GROUND - bottom), pos(x, bottom),
         color(76, 145, 65), outline(4, (46, 100, 40)), area(),
         "pipe", "slides"])

    # An invisible strip across the gap, at the BACK of the pipes.
    # Touching it scores the point.
    add([rect(6, GAP), pos(x + PIPE_WIDTH - 6, top), opacity(0),
         area(), "point", "slides"])


@onUpdate("slides")
def slide(thing):
    thing.move(-SPEED, 0)

    # Gone off the left-hand edge? Throw it away.
    if thing.pos.x < -PIPE_WIDTH - 20:
        thing.destroy()


@bean.onCollide("point")
def scored(strip):
    global score

    strip.destroy()
    score = score + 1
    score_label.text = str(score)

The strip is a rectangle 6 pixels wide and as tall as the gap, with opacity(0) so you can't see it. It's tagged "slides" too, so it moves along with its pipes without any code of its own.

It sits at the back of the pipes, x + PIPE_WIDTH - 6, not the front. Put it at the front and you'd get the point the moment you flew into the gap, and could then hit the pipe on the way through. That feels like the game cheating you. A point should mean "got past that one".

@bean.onCollide("point") runs once, at the moment Bean first touches a strip, not on every frame they overlap, so one strip is one point. Once it has scored, the strip has done its job, and strip.destroy() gets rid of it.

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

Step 4 — game over, and again

Touching a pipe or the grass, or flying off the top of the window, now ends the game. Flap again and a new one starts.

Starting again means throwing away every pipe and building everything fresh. That's what a scene is for. Everything from Bean 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 *

FLAP = 520            # how hard a flap pushes Bean up
GROUND = 60           # how tall the strip of grass is
PIPE_WIDTH = 70
MARGIN = 90           # how close to the top or the grass a gap may be

GAP = 210             # the hole between the pipes
SPEED = 190           # how fast the pipes slide, in pixels per second
WAIT = 1.55           # seconds between pipes

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

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

score = 0
alive = True
next_pipe = 0


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

    score = 0
    alive = True
    next_pipe = WAIT

    bean = add([sprite("bean"), pos(180, 240), anchor("center"), area(),
                body(), z(5)])

    add([rect(width(), GROUND), pos(0, height() - GROUND),
         color(96, 156, 76), area(), body(isStatic=True), "ground"])

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

    # width=520 wraps a long message onto more than one line.
    message = add([text("Press SPACE to flap", size=26, width=520),
                   pos(width() / 2, height() / 2 + 40), anchor("center"),
                   z(10)])

    def flap():
        if alive:
            bean.jump(FLAP)
        else:
            go("game")

    onKeyPress("space", flap)
    onKeyPress("up", flap)
    onClick(flap)

    @onUpdate
    def each_frame():
        global next_pipe

        if not alive:
            return

        # Flying off the top is losing too.
        if bean.pos.y < -40:
            die()

        next_pipe = next_pipe - dt()
        if next_pipe <= 0:
            add_pipes()
            next_pipe = WAIT

    def add_pipes():
        top = rand(MARGIN, height() - GROUND - MARGIN - GAP)
        bottom = top + GAP
        x = width()

        add([rect(PIPE_WIDTH, top), pos(x, 0), color(76, 145, 65),
             outline(4, (46, 100, 40)), area(), "pipe", "slides"])

        add([rect(PIPE_WIDTH, height() - GROUND - bottom), pos(x, bottom),
             color(76, 145, 65), outline(4, (46, 100, 40)), area(),
             "pipe", "slides"])

        add([rect(6, GAP), pos(x + PIPE_WIDTH - 6, top), opacity(0),
             area(), "point", "slides"])

    @onUpdate("slides")
    def slide(thing):
        if not alive:
            return
        thing.move(-SPEED, 0)
        if thing.pos.x < -PIPE_WIDTH - 20:
            thing.destroy()

    @bean.onCollide("point")
    def scored(strip):
        global score

        if not alive:
            return

        strip.destroy()
        score = score + 1
        score_label.text = str(score)

        if score == 1:
            message.text = ""

    @bean.onCollide("pipe")
    def hit_pipe(pipe):
        die()

    @bean.onCollide("ground")
    def hit_ground(ground):
        die()

    def die():
        global alive

        if not alive:
            return
        alive = False

        shake(10)

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


go("game")

alive is True while you're flying. die() sets it to False, and everything else checks it:

  • flap starts a new game instead of flapping.
  • slide stops moving the pipes, so they freeze where they are.
  • each_frame stops counting down to more pipes.
  • die itself does nothing the second time. Hitting a pipe is nearly always followed by falling onto the grass, and that calls die() again. Without the check you would get a second shake, and the message written all over again.

Bean still has its body(), so after dying it keeps falling until it lands on the grass. That reads as losing. If Bean froze in mid-air too, it would look as if the game had crashed.

The variables are set back to their starting values at the top of the scene, because the scene is built again every game. Leave out score = 0 and your next game would start with the last game's score.

text(..., width=520) wraps the message onto two lines when it is longer than 520 pixels, which the game-over message is.

Step 5 — harder as you go

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

from kaypy import *

FLAP = 520            # how hard a flap pushes Bean up
GROUND = 60           # how tall the strip of grass is
PIPE_WIDTH = 70
MARGIN = 90           # how close to the top or the grass a gap may be

GAP_START = 210       # the hole between the pipes, to begin with
GAP_SMALLEST = 130    # ...the smallest it ever gets
GAP_SHRINK = 6        # ...and how much smaller each point makes it

SPEED_START = 190     # how fast the pipes slide, in pixels per second
SPEED_FASTEST = 420   # ...the fastest they ever go
SPEED_GAIN = 9        # ...and how much faster each point makes them

WAIT_START = 1.55     # seconds between pipes, to begin with
WAIT_SHORTEST = 0.95  # ...the shortest wait there ever is
WAIT_DROP = 0.02      # ...and how much shorter each point makes it

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

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

score = 0
alive = True
next_pipe = 0


def gap_size():
    gap = GAP_START - score * GAP_SHRINK
    if gap < GAP_SMALLEST:
        gap = GAP_SMALLEST
    return gap


def pipe_speed():
    speed = SPEED_START + score * SPEED_GAIN
    if speed > SPEED_FASTEST:
        speed = SPEED_FASTEST
    return speed


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


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

    score = 0
    alive = True
    next_pipe = pipe_wait()

    bean = add([sprite("bean"), pos(180, 240), anchor("center"), area(),
                body(), z(5)])

    add([rect(width(), GROUND), pos(0, height() - GROUND),
         color(96, 156, 76), area(), body(isStatic=True), "ground"])

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

    # width=520 wraps a long message onto more than one line.
    message = add([text("Press SPACE to flap", size=26, width=520),
                   pos(width() / 2, height() / 2 + 40), anchor("center"),
                   z(10)])

    def flap():
        if alive:
            bean.jump(FLAP)
        else:
            go("game")

    onKeyPress("space", flap)
    onKeyPress("up", flap)
    onClick(flap)

    @onUpdate
    def each_frame():
        global next_pipe

        if not alive:
            return

        # Flying off the top is losing too.
        if bean.pos.y < -40:
            die()

        next_pipe = next_pipe - dt()
        if next_pipe <= 0:
            add_pipes()
            next_pipe = pipe_wait()

    def add_pipes():
        gap = gap_size()
        top = rand(MARGIN, height() - GROUND - MARGIN - gap)
        bottom = top + gap
        x = width()

        add([rect(PIPE_WIDTH, top), pos(x, 0), color(76, 145, 65),
             outline(4, (46, 100, 40)), area(), "pipe", "slides"])

        add([rect(PIPE_WIDTH, height() - GROUND - bottom), pos(x, bottom),
             color(76, 145, 65), outline(4, (46, 100, 40)), area(),
             "pipe", "slides"])

        add([rect(6, gap), pos(x + PIPE_WIDTH - 6, top), opacity(0),
             area(), "point", "slides"])

    @onUpdate("slides")
    def slide(thing):
        if not alive:
            return
        thing.move(-pipe_speed(), 0)
        if thing.pos.x < -PIPE_WIDTH - 20:
            thing.destroy()

    @bean.onCollide("point")
    def scored(strip):
        global score

        if not alive:
            return

        strip.destroy()
        score = score + 1
        score_label.text = str(score)

        if score == 1:
            message.text = ""

        if score == 5 or score == 10 or score == 20:
            message.text = "faster!"

            @wait(0.8)
            def clear_message():
                # Only if you are still flying -- otherwise this would wipe
                # out the game-over message.
                if alive:
                    message.text = ""

    @bean.onCollide("pipe")
    def hit_pipe(pipe):
        die()

    @bean.onCollide("ground")
    def hit_ground(ground):
        die()

    def die():
        global alive

        if not alive:
            return
        alive = False

        shake(10)

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


go("game")

Take gap_size(). It starts at GAP_START, 210 pixels, and takes off 6 for every point. At 10 points that's 210 - 10 * 6, which is 150. At 20 points it would be 90, but the if stops it at GAP_SMALLEST, 130. The other two work the same way.

Those stops matter. Without them, the gap keeps shrinking until Bean can't fit through it, and nobody could get past about thirty points. A game that gets harder and then levels off is one people keep playing.

At 5, 10 and 20 points the message says "faster!" for 0.8 seconds. clear_message checks alive before clearing it. If you die within 0.8 seconds of reaching 5 points, the "faster!" timer is still counting, and without that check it would wipe out your game-over message.

Step 6 — the whole game

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

  • Bean tilts. Nose up while rising, nose down while falling. It's not just for looks: it's the quickest way to see whether you are still going up or already coming down.
  • 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.

The tilt needs to know how fast Bean is falling. Nothing tells the game that, so it works it out: last_y remembers where Bean was last frame, and how far Bean moved divided by how long the frame took is its speed, in pixels per second. Multiply by 0.06 and you get an angle, and clamp() keeps that angle between −28 (nose up) and 75 (nose down). rotate(0) in Bean's list is what lets Bean turn at all; without it, setting bean.angle does nothing.

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

"""Flappy Bean -- flap through the gaps, and don't touch anything.

    space, up or click   flap

Bean is always falling, and every flap pushes it back up. Fly through the
gap between each pair of pipes for a point. The more points you have, the
faster the pipes come and the smaller the gaps get. Touch a pipe or the
grass, or fly off the top, and it is game over. Your best score is
remembered.

HOW THIS FILE IS PUT TOGETHER

    1. the window and the picture
    2. the settings
    3. how hard the game is
    4. the game scene: Bean, the grass, the pipes, points, dying
    5. start the game

The pipes are plain rectangles, so there is no art to find, and every number
that shapes the game is in the settings, ready to be argued about.
"""

from kaypy import *

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

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

# How hard Bean falls. Lower it and Bean floats; raise it and Bean drops
# like a stone.
setGravity(1600)

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


# -------------------------------------------------------------- 2. settings
#
# Everything you would want to change is here. Change one at a time and
# play it -- that is the whole point of having them in one place.

FLAP = 520            # how hard a flap pushes Bean up
GROUND = 60           # how tall the strip of grass is

PIPE_WIDTH = 70
MARGIN = 90           # how close to the top or the grass a gap may be

GAP_START = 210       # the hole between the pipes, to begin with
GAP_SMALLEST = 130    # ...the smallest it ever gets
GAP_SHRINK = 6        # ...and how much smaller each point makes it

SPEED_START = 190     # how fast the pipes slide, in pixels per second
SPEED_FASTEST = 420   # ...the fastest they ever go
SPEED_GAIN = 9        # ...and how much faster each point makes them

WAIT_START = 1.55     # seconds between pipes, to begin with
WAIT_SHORTEST = 0.95  # ...the shortest wait there ever is
WAIT_DROP = 0.02      # ...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 -- a plain "best"
# would be overwritten by the next game that saved one.
BEST_KEY = "flappy_best"


# -------------------------------------------------- 3. how hard the game is
#
# Each of these works out a number from the score. They all get harder as
# the score goes up, and they all stop somewhere, so the game ends up hard
# but never impossible. Without the stops, nobody could get past about
# thirty points.

score = 0


def gap_size():
    gap = GAP_START - score * GAP_SHRINK
    if gap < GAP_SMALLEST:
        gap = GAP_SMALLEST
    return gap


def pipe_speed():
    speed = SPEED_START + score * SPEED_GAIN
    if speed > SPEED_FASTEST:
        speed = SPEED_FASTEST
    return speed


def pipe_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
next_pipe = 0       # seconds until the next pair of pipes
last_y = 0          # where Bean was last frame, for the tilt


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

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

    # rotate(0) lets Bean tilt. It starts level. z(5) draws Bean in front
    # of the pipes, which are added later and would otherwise cover it.
    bean = add([sprite("bean"), pos(180, 240), anchor("center"), rotate(0),
                area(), body(), z(5)])
    last_y = bean.pos.y

    # The grass. It is solid, so Bean lands on it -- and landing on it is
    # losing.
    add([rect(width(), GROUND), pos(0, height() - GROUND),
         color(96, 156, 76), area(), body(isStatic=True), "ground"])

    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=520 wraps a long message onto more than one line.
    message = add([text("Press SPACE to flap", size=26, width=520),
                   pos(width() / 2, height() / 2 + 40), anchor("center"),
                   z(10)])

    # ------------------------------------------------------------ flapping
    #
    # Three ways to flap, all doing the same thing. Once the game is over,
    # a flap starts a new one.

    def flap():
        if alive:
            bean.jump(FLAP)
        else:
            go("game")

    onKeyPress("space", flap)
    onKeyPress("up", flap)
    onClick(flap)

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

    @onUpdate
    def each_frame():
        global next_pipe, last_y

        if not alive:
            return

        # Tilt Bean nose-up while it rises and nose-down while it falls. How
        # far it moved this frame, divided by how long the frame took, is
        # how fast it is going. (dt() is never 0 while the game runs, but
        # dividing by 0 would crash it, so it is checked anyway.)
        if dt() > 0:
            falling_speed = (bean.pos.y - last_y) / dt()
            bean.angle = clamp(falling_speed * 0.06, -28, 75)
        last_y = bean.pos.y

        # Flying off the top is losing too. Without this you could fly
        # above the pipes and score for ever.
        if bean.pos.y < -40:
            die()

        # Time for more pipes? dt() is how long this frame took, so taking
        # it away every frame counts down in real seconds.
        next_pipe = next_pipe - dt()
        if next_pipe <= 0:
            add_pipes()
            next_pipe = pipe_wait()

    # --------------------------------------------------------------- pipes

    def add_pipes():
        gap = gap_size()

        # Where the gap starts, picked at random -- but never so near the
        # top, or the grass, that you could not fly through it.
        top = rand(MARGIN, height() - GROUND - MARGIN - gap)
        bottom = top + gap
        x = width()             # just off the right-hand edge

        # The pipes have area() but NOT body(). Bean flies into them rather
        # than bouncing off, and touching one ends the game.

        # The pipe above the gap, from the top of the window down.
        add([rect(PIPE_WIDTH, top), pos(x, 0), color(76, 145, 65),
             outline(4, (46, 100, 40)), area(), "pipe", "slides"])

        # The pipe below the gap, from the gap down to the grass.
        add([rect(PIPE_WIDTH, height() - GROUND - bottom), pos(x, bottom),
             color(76, 145, 65), outline(4, (46, 100, 40)), area(),
             "pipe", "slides"])

        # An invisible strip across the gap. Touching it is what scores the
        # point. It sits at the BACK of the pipes, so the point only comes
        # once you are through -- not the moment you fly into the gap.
        add([rect(6, gap), pos(x + PIPE_WIDTH - 6, top), opacity(0),
             area(), "point", "slides"])

    # Everything tagged "slides" -- both pipes and the strip -- moves left.
    @onUpdate("slides")
    def slide(thing):
        if not alive:
            return
        thing.move(-pipe_speed(), 0)

        # Gone off the left-hand edge? Throw it away. Otherwise the game
        # keeps every pipe it ever made, and after a few minutes there are
        # hundreds of them out of sight, all still being moved.
        if thing.pos.x < -PIPE_WIDTH - 20:
            thing.destroy()

    # -------------------------------------------------------------- points

    @bean.onCollide("point")
    def scored(strip):
        global score

        if not alive:
            return

        strip.destroy()
        score = score + 1
        score_label.text = str(score)

        if score == 1:
            message.text = ""

        if score == 5 or score == 10 or score == 20:
            message.text = "faster!"

            @wait(0.8)
            def clear_message():
                # Only if you are still flying -- otherwise this would wipe
                # out the game-over message.
                if alive:
                    message.text = ""

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

    @bean.onCollide("pipe")
    def hit_pipe(pipe):
        die()

    @bean.onCollide("ground")
    def hit_ground(ground):
        die()

    def die():
        global alive

        # Hit a pipe and Bean falls onto the grass -- that is a second call
        # to die(), and only the first one should count.
        if not alive:
            return
        alive = False

        shake(10)

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

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

        # Nothing else is needed to stop the pipes: slide() does nothing
        # once alive is False. Bean has a body(), so it keeps falling until
        # it lands on the grass.


# ------------------------------------------------------- 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. FLAP and the gravity together decide how Bean feels. Try setGravity(800) with FLAP = 360 for a floatier Bean.
  • Change the steps. Make GAP_SMALLEST bigger and the game never gets as hard. Make SPEED_GAIN 0 and the pipes never speed up at all.
  • A sound for each point. loadSound("point", "sounds/small_beep.wav") near the top, and play("point") in scored. Then one in die as well.
  • Coloured pipes. Change color(76, 145, 65) to pick a colour at random for each pair, with randi(0, 255) for each of the three numbers. Work it out once in add_pipes so both pipes of a pair match.