Building Spike Runner

Play it first → The cube runs by itself; all you do is jump. Over spikes, onto blocks, across gaps, and off yellow orbs in mid-air. One touch of anything and you're back at the start of the level, with the attempt count going up. There are six levels, each a little harder than the one before. It plays like Geometry Dash.

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. Steps 1 to 5 use a short level, a few sections long. Step 6 is the whole game, with all six levels.

The one big idea: a level made of sections

A level is long: the last one is nearly 360 tiles. Drawn as one picture, every row would be a line of text 360 letters wide, which is no fun to edit.

So it is drawn in sections: short pictures, each one an obstacle. This is one spike:

ONE_SPIKE = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^       ",
    "##############",
]

The bottom row is the floor. SECTIONS lists them in order, and join_sections() glues them together side by side into one long level. A new obstacle is a new section; a harder level is the same sections in a different order.

Step 1 — the cube runs

from kaypy import *

TILE = 40          # one tile is 40 pixels square
SPEED = 420        # how fast the cube runs, in pixels per second

kaypy(width=960, height=560, background=[40, 90, 200])
setGravity(5000)

START = [
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "  @       ",
    "##########",
]

ONE_SPIKE = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^       ",
    "##############",
]

STEPS = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                 ##     ",
    "            ##   ##     ",
    "       ##   ##   ##^^^  ",
    "########################",
]

SECTIONS = [START, ONE_SPIKE, STEPS]


def join_sections(sections):
    """Glue the sections together, side by side, into one long level.

    Row 0 of the level is row 0 of every section, one after another, and
    the same for every other row.
    """
    # Every row of a section has to be the same width. If one is a letter
    # short, everything after it on that row slides one tile to the left,
    # and nothing says so -- so check, and say which section it is.
    for number in range(len(sections)):
        section = sections[number]
        for line in section:
            if len(line) != len(section[0]):
                print("Section", number + 1, "in SECTIONS has rows of",
                      "different widths. Count the letters on each row.")

    level = []
    for row in range(len(sections[0])):
        line = ""
        for section in sections:
            line = line + section[row]
        level.append(line)
    return level


LEVEL = join_sections(SECTIONS)
TOP = TILE * 4     # the level starts 4 rows down, leaving sky above it

TILES = {
    "#": lambda: [rect(TILE, TILE), color(20, 30, 70),
                  outline(2, (120, 200, 255)), area(),
                  body(isStatic=True), "block"],
    "@": lambda: [rect(36, 36), color(255, 210, 40), outline(3, (0, 0, 0)),
                  anchor("center"), rotate(0), area(), body(), z(10),
                  "player"],
}

addLevel(LEVEL, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "pos": vec2(0, TOP),
    "tiles": TILES,
})

cube = get("player")[0]


@onUpdate
def each_frame():
    cube.move(SPEED, 0)

    # The camera keeps the cube a quarter of the way across the window, so
    # you can see what is coming.
    setCamPos(vec2(cube.pos.x + width() / 4, height() / 2))

Run it and the cube runs to the right on its own, until it reaches the first block and stops dead against it. Jumping is step 2.

Joining the sections. join_sections builds the level one row at a time. Row 0 of the level is row 0 of every section, one after another, then the same for row 1, and so on. That only works if every row of a section is the same width. If one row is a letter short, everything after it on that row slides a tile to the left. So before joining, it checks every section, and if a row is the wrong width it prints which section, like Section 3 in SECTIONS has rows of different widths. Count the letters on each row.

Running. cube.move(SPEED, 0) every frame, and nothing else. The cube has a body(), so gravity holds it on the floor while it moves.

The camera. It puts the cube a quarter of the way across the window, not in the middle. setCamPos() says what point should be in the middle of the window; aim it a quarter of a window ahead of the cube, and the cube sits a quarter from the left, with three quarters of the window showing what's coming.

The spike in ONE_SPIKE doesn't appear yet, because ^ isn't in TILES. A letter addLevel has no tile for is simply skipped.

Step 2 — jumping and spinning

from kaypy import *

TILE = 40          # one tile is 40 pixels square
SPEED = 420        # how fast the cube runs, in pixels per second
JUMP = 1100        # how hard a jump is: about three tiles high
SPIN = 520         # how fast the cube turns in the air, degrees a second

kaypy(width=960, height=560, background=[40, 90, 200])
setGravity(5000)

START = [
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "  @       ",
    "##########",
]

ONE_SPIKE = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^       ",
    "##############",
]

STEPS = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                 ##     ",
    "            ##   ##     ",
    "       ##   ##   ##^^^  ",
    "########################",
]

SECTIONS = [START, ONE_SPIKE, STEPS]


def join_sections(sections):
    """Glue the sections together, side by side, into one long level.

    Row 0 of the level is row 0 of every section, one after another, and
    the same for every other row.
    """
    # Every row of a section has to be the same width. If one is a letter
    # short, everything after it on that row slides one tile to the left,
    # and nothing says so -- so check, and say which section it is.
    for number in range(len(sections)):
        section = sections[number]
        for line in section:
            if len(line) != len(section[0]):
                print("Section", number + 1, "in SECTIONS has rows of",
                      "different widths. Count the letters on each row.")

    level = []
    for row in range(len(sections[0])):
        line = ""
        for section in sections:
            line = line + section[row]
        level.append(line)
    return level


LEVEL = join_sections(SECTIONS)
TOP = TILE * 4     # the level starts 4 rows down, leaving sky above it

TILES = {
    "#": lambda: [rect(TILE, TILE), color(20, 30, 70),
                  outline(2, (120, 200, 255)), area(),
                  body(isStatic=True), "block"],
    "@": lambda: [rect(36, 36), color(255, 210, 40), outline(3, (0, 0, 0)),
                  anchor("center"), rotate(0), area(), body(), z(10),
                  "player"],
}

addLevel(LEVEL, {
    "tileWidth": TILE,
    "tileHeight": TILE,
    "pos": vec2(0, TOP),
    "tiles": TILES,
})

cube = get("player")[0]


# Holding the key jumps again the moment the cube lands -- that is why this
# is asked every frame, rather than waiting for a key press.
def holding_jump():
    return isKeyDown("space") or isKeyDown("up") or isMouseDown()


@onUpdate
def each_frame():
    cube.move(SPEED, 0)

    if cube.isGrounded():
        # Land square: turn to the nearest quarter turn.
        cube.angle = round(cube.angle / 90) * 90
        if holding_jump():
            cube.jump(JUMP)
    else:
        cube.angle = cube.angle + SPIN * dt()

    # The camera keeps the cube a quarter of the way across the window, so
    # you can see what is coming.
    setCamPos(vec2(cube.pos.x + width() / 4, height() / 2))

Hold space, up or the mouse button and the cube keeps jumping, each time it lands. That's why holding_jump() asks every frame whether the key is down, rather than waiting for a key to be pressed: holding is how the game is played.

Spinning. In the air, the cube turns SPIN degrees a second. It needs rotate(0) in its list to turn at all, and anchor("center") so it turns about its middle instead of swinging round its corner.

Landing square. A jump doesn't last an exact number of quarter turns, so the cube would usually land tilted. round(cube.angle / 90) * 90 snaps it to the nearest quarter turn as soon as it's on the ground. Landing at 170 degrees: 170 / 90 is about 1.9, round makes it 2, and 2 * 90 is 180.

Step 3 — spikes, and starting again

Touch a spike and the attempt is over. To start again from nothing, the game moves into a scene: everything from addLevel down goes inside @scene("run"), one step further in. go("run", attempt + 1) throws everything away and builds it fresh, one attempt further on.

from kaypy import *

TILE = 40          # one tile is 40 pixels square
SPEED = 420        # how fast the cube runs, in pixels per second
JUMP = 1100        # how hard a jump is: about three tiles high
SPIN = 520         # how fast the cube turns in the air, degrees a second
SPIKE_REACH = 16   # how close to a spike's middle counts as touching it

kaypy(width=960, height=560, background=[40, 90, 200])
setGravity(5000)

loadSprite("spike", "images/triangle.png")
loadSound("crash", "sounds/small_boom.wav")

START = [
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "  @       ",
    "##########",
]

ONE_SPIKE = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^       ",
    "##############",
]

TWO_SPIKES = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^^      ",
    "##############",
]

THREE_SPIKES = [
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "       ^^^      ",
    "################",
]

FINISH = [
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "####################",
]

SECTIONS = [START, ONE_SPIKE, TWO_SPIKES, THREE_SPIKES, FINISH]


def join_sections(sections):
    """Glue the sections together, side by side, into one long level.

    Row 0 of the level is row 0 of every section, one after another, and
    the same for every other row.
    """
    # Every row of a section has to be the same width. If one is a letter
    # short, everything after it on that row slides one tile to the left,
    # and nothing says so -- so check, and say which section it is.
    for number in range(len(sections)):
        section = sections[number]
        for line in section:
            if len(line) != len(section[0]):
                print("Section", number + 1, "in SECTIONS has rows of",
                      "different widths. Count the letters on each row.")

    level = []
    for row in range(len(sections[0])):
        line = ""
        for section in sections:
            line = line + section[row]
        level.append(line)
    return level


LEVEL = join_sections(SECTIONS)
TOP = TILE * 4     # the level starts 4 rows down, leaving sky above it

TILES = {
    "#": lambda: [rect(TILE, TILE), color(20, 30, 70),
                  outline(2, (120, 200, 255)), area(),
                  body(isStatic=True), "block"],
    "^": lambda: [sprite("spike"), scale(TILE / 48, TILE / 39), "spike"],
    "@": lambda: [rect(36, 36), color(255, 210, 40), outline(3, (0, 0, 0)),
                  anchor("center"), rotate(0), area(), body(), z(10),
                  "player"],
}

attempt = 1
crashed = False


@scene("run")
def run(number):
    global attempt, crashed

    attempt = number
    crashed = False

    addLevel(LEVEL, {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    cube = get("player")[0]

    # Written into the level at the start, so it scrolls away as you go.
    add([text("Attempt " + str(attempt), size=44),
         pos(cube.pos.x + 60, TOP + TILE * 2)])

    def holding_jump():
        return isKeyDown("space") or isKeyDown("up") or isMouseDown()

    @onUpdate
    def each_frame():
        if crashed:
            return

        cube.move(SPEED, 0)

        if cube.isGrounded():
            cube.angle = round(cube.angle / 90) * 90
            if holding_jump():
                cube.jump(JUMP)
        else:
            cube.angle = cube.angle + SPIN * dt()

        if crashed_into_spike():
            crash()
            return

        setCamPos(vec2(cube.pos.x + width() / 4, height() / 2))

    # A spike is a triangle, but a collision box is a rectangle. So measure
    # how far the cube is from the solid middle of the spike instead.
    def crashed_into_spike():
        for spike in get("spike"):
            middle = vec2(spike.pos.x + TILE / 2, spike.pos.y + TILE * 0.65)
            if cube.pos.dist(middle) < SPIKE_REACH:
                return True
        return False

    def crash():
        global crashed

        if crashed:
            return
        crashed = True

        play("crash")
        shake(10)
        cube.destroy()

        # Start again, one attempt further on.
        @wait(0.7)
        def again():
            go("run", attempt + 1)


go("run", 1)

The spike picture. addLevel puts a tile's top-left corner at the corner of its square. The triangle picture is 48 by 39 pixels, so at that size it would hang from the top of its square. Scaling it by TILE / 48 across and TILE / 39 down makes it exactly one tile, so it stands on the floor.

Touching a spike. A spike is a triangle, but every area() is a rectangle. A rectangle round a spike would kill you for clipping the empty corner beside the point, which feels like cheating. So spikes have no area() at all, and crashed_into_spike() measures how far the cube is from the spike's solid middle, a little below its centre. Only closer than SPIKE_REACH, 16 pixels, counts.

Attempts. "Attempt 2" is an ordinary text object in the level, not stuck to the screen, so it scrolls away as you run. crashed stops everything moving while the next attempt is on its way, and stops crash() happening twice.

There's no floor after the end of the level yet. Get that far and the cube falls off the end, for ever. The next step fixes that.

Don't forget global. Take out global crashed in crash() and the first spike stops the game with UnboundLocalError: cannot access local variable 'crashed' where it is not associated with a value. Without global, Python thinks crashed in crash() is a new variable of its own, and if crashed: asks about it before it has a value.

Step 4 — blocks and gaps

In step 1 the cube stopped dead against a block and stayed there. Now running into the side of a block is a crash, and so is falling into a gap in the floor.

from kaypy import *

TILE = 40          # one tile is 40 pixels square
SPEED = 420        # how fast the cube runs, in pixels per second
JUMP = 1100        # how hard a jump is: about three tiles high
SPIN = 520         # how fast the cube turns in the air, degrees a second
SPIKE_REACH = 16   # how close to a spike's middle counts as touching it

kaypy(width=960, height=560, background=[40, 90, 200])
setGravity(5000)

loadSprite("spike", "images/triangle.png")
loadSound("crash", "sounds/small_boom.wav")

START = [
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "  @       ",
    "##########",
]

ONE_SPIKE = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^       ",
    "##############",
]

TWO_SPIKES = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^^      ",
    "##############",
]

STEPS = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                 ##     ",
    "            ##   ##     ",
    "       ##   ##   ##^^^  ",
    "########################",
]

GAP = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "#####   ######",
]

THREE_SPIKES = [
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "       ^^^      ",
    "################",
]

FINISH = [
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "####################",
]

SECTIONS = [START, ONE_SPIKE, TWO_SPIKES, STEPS, GAP, THREE_SPIKES, FINISH]


def join_sections(sections):
    """Glue the sections together, side by side, into one long level.

    Row 0 of the level is row 0 of every section, one after another, and
    the same for every other row.
    """
    # Every row of a section has to be the same width. If one is a letter
    # short, everything after it on that row slides one tile to the left,
    # and nothing says so -- so check, and say which section it is.
    for number in range(len(sections)):
        section = sections[number]
        for line in section:
            if len(line) != len(section[0]):
                print("Section", number + 1, "in SECTIONS has rows of",
                      "different widths. Count the letters on each row.")

    level = []
    for row in range(len(sections[0])):
        line = ""
        for section in sections:
            line = line + section[row]
        level.append(line)
    return level


LEVEL = join_sections(SECTIONS)
TOP = TILE * 4     # the level starts 4 rows down, leaving sky above it

TILES = {
    "#": lambda: [rect(TILE, TILE), color(20, 30, 70),
                  outline(2, (120, 200, 255)), area(),
                  body(isStatic=True), "block"],
    "^": lambda: [sprite("spike"), scale(TILE / 48, TILE / 39), "spike"],
    "@": lambda: [rect(36, 36), color(255, 210, 40), outline(3, (0, 0, 0)),
                  anchor("center"), rotate(0), area(), body(), z(10),
                  "player"],
}

attempt = 1
crashed = False
last_x = 0          # where the cube was last frame
expected = 0        # how far it was told to move last frame


@scene("run")
def run(number):
    global attempt, crashed, last_x, expected

    attempt = number
    crashed = False
    expected = 0

    addLevel(LEVEL, {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    cube = get("player")[0]
    last_x = cube.pos.x

    # Written into the level at the start, so it scrolls away as you go.
    add([text("Attempt " + str(attempt), size=44),
         pos(cube.pos.x + 60, TOP + TILE * 2)])

    def holding_jump():
        return isKeyDown("space") or isKeyDown("up") or isMouseDown()

    @onUpdate
    def each_frame():
        global last_x, expected

        if crashed:
            return

        # Ran into the side of a block? Then it got less than half as far
        # as it was told to go last frame.
        if cube.pos.x - last_x < expected * 0.5:
            crash()
            return
        last_x = cube.pos.x
        expected = SPEED * dt()

        cube.move(SPEED, 0)

        if cube.isGrounded():
            cube.angle = round(cube.angle / 90) * 90
            if holding_jump():
                cube.jump(JUMP)
        else:
            cube.angle = cube.angle + SPIN * dt()

        if crashed_into_spike():
            crash()
            return

        # Fell into a gap.
        if cube.pos.y > height() + 50:
            crash()
            return

        setCamPos(vec2(cube.pos.x + width() / 4, height() / 2))

    # A spike is a triangle, but a collision box is a rectangle. So measure
    # how far the cube is from the solid middle of the spike instead.
    def crashed_into_spike():
        for spike in get("spike"):
            middle = vec2(spike.pos.x + TILE / 2, spike.pos.y + TILE * 0.65)
            if cube.pos.dist(middle) < SPIKE_REACH:
                return True
        return False

    def crash():
        global crashed

        if crashed:
            return
        crashed = True

        play("crash")
        shake(10)
        cube.destroy()

        # Start again, one attempt further on.
        @wait(0.7)
        def again():
            go("run", attempt + 1)


go("run", 1)

Running into a block. Nothing tells the game the cube hit something. A block's body(isStatic=True) just pushes the cube back out, every frame. So the game measures instead: expected is how far the cube was told to move last frame, and last_x is where it was. If it got less than half as far as it was told to, something stopped it.

It has to be last frame's distance, not this frame's. Frames aren't all the same length, especially in a browser. A long frame straight after a short one would ask "did you move as far as this long frame says?", get "no", and call it a crash when nothing happened.

Landing on top of a block doesn't stop the cube going forward, so that isn't a crash. Only hitting the side is.

Gaps. A gap is just a space in the floor row. Fall through one and the cube drops below the window, and cube.pos.y > height() + 50 catches it. That also catches running off the end of the level, so for now the end counts as a crash and starts you again. Step 6 adds a finish.

Step 5 — orbs

Some gaps are too wide to jump. A yellow orb in mid-air gives you a second jump: hold jump as you go through it.

from kaypy import *

TILE = 40          # one tile is 40 pixels square
SPEED = 420        # how fast the cube runs, in pixels per second
JUMP = 1100        # how hard a jump is: about three tiles high
ORB_JUMP = 1150    # how hard an orb throws you, a little more
SPIN = 520         # how fast the cube turns in the air, degrees a second
SPIKE_REACH = 16   # how close to a spike's middle counts as touching it
ORB_REACH = 56     # how close to an orb counts as going through it

kaypy(width=960, height=560, background=[40, 90, 200])
setGravity(5000)

loadSprite("spike", "images/triangle.png")
loadSound("crash", "sounds/small_boom.wav")

START = [
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "  @       ",
    "##########",
]

ONE_SPIKE = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^       ",
    "##############",
]

STEPS = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                 ##     ",
    "            ##   ##     ",
    "       ##   ##   ##^^^  ",
    "########################",
]

GAP = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "#####   ######",
]

ORB_GAP = [
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "       o            ",
    "                    ",
    "                    ",
    "#####      #########",
]

ORB_CHAIN = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "       o   o            ",
    "                        ",
    "                        ",
    "#####         ##########",
]

FINISH = [
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "####################",
]

SECTIONS = [START, ONE_SPIKE, STEPS, GAP, ORB_GAP, ORB_CHAIN, FINISH]


def join_sections(sections):
    """Glue the sections together, side by side, into one long level.

    Row 0 of the level is row 0 of every section, one after another, and
    the same for every other row.
    """
    # Every row of a section has to be the same width. If one is a letter
    # short, everything after it on that row slides one tile to the left,
    # and nothing says so -- so check, and say which section it is.
    for number in range(len(sections)):
        section = sections[number]
        for line in section:
            if len(line) != len(section[0]):
                print("Section", number + 1, "in SECTIONS has rows of",
                      "different widths. Count the letters on each row.")

    level = []
    for row in range(len(sections[0])):
        line = ""
        for section in sections:
            line = line + section[row]
        level.append(line)
    return level


LEVEL = join_sections(SECTIONS)
TOP = TILE * 4     # the level starts 4 rows down, leaving sky above it

TILES = {
    "#": lambda: [rect(TILE, TILE), color(20, 30, 70),
                  outline(2, (120, 200, 255)), area(),
                  body(isStatic=True), "block"],
    "^": lambda: [sprite("spike"), scale(TILE / 48, TILE / 39), "spike"],
    "o": lambda: [circle(14), color(255, 220, 60), outline(3, (255, 255, 255)),
                  opacity(1), "orb", {"used": False}],
    "@": lambda: [rect(36, 36), color(255, 210, 40), outline(3, (0, 0, 0)),
                  anchor("center"), rotate(0), area(), body(), z(10),
                  "player"],
}

attempt = 1
crashed = False
last_x = 0          # where the cube was last frame
expected = 0        # how far it was told to move last frame


@scene("run")
def run(number):
    global attempt, crashed, last_x, expected

    attempt = number
    crashed = False
    expected = 0

    addLevel(LEVEL, {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    cube = get("player")[0]
    last_x = cube.pos.x

    # Written into the level at the start, so it scrolls away as you go.
    add([text("Attempt " + str(attempt), size=44),
         pos(cube.pos.x + 60, TOP + TILE * 2)])

    def holding_jump():
        return isKeyDown("space") or isKeyDown("up") or isMouseDown()

    # An orb fires if jump is held while the cube touches it, and only once.
    def use_orbs():
        for orb in get("orb"):
            # A circle's pos is its top-left corner; its middle is one
            # radius (14) in from there.
            middle = vec2(orb.pos.x + 14, orb.pos.y + 14)
            if not orb.used and cube.pos.dist(middle) < ORB_REACH:
                orb.used = True
                orb.opacity = 0.3
                cube.jump(ORB_JUMP)

    # A sign over the first orb, because nothing else says what they do.
    orbs = get("orb")
    if len(orbs) > 0:
        first = orbs[0]
        add([text("hold jump through the orb!", size=22),
             pos(first.pos.x - 130, first.pos.y - 60)])

    @onUpdate
    def each_frame():
        global last_x, expected

        if crashed:
            return

        # Ran into the side of a block? Then it got less than half as far
        # as it was told to go last frame.
        if cube.pos.x - last_x < expected * 0.5:
            crash()
            return
        last_x = cube.pos.x
        expected = SPEED * dt()

        cube.move(SPEED, 0)

        if cube.isGrounded():
            cube.angle = round(cube.angle / 90) * 90
            if holding_jump():
                cube.jump(JUMP)
        else:
            cube.angle = cube.angle + SPIN * dt()
            if holding_jump():
                use_orbs()

        if crashed_into_spike():
            crash()
            return

        # Fell into a gap.
        if cube.pos.y > height() + 50:
            crash()
            return

        setCamPos(vec2(cube.pos.x + width() / 4, height() / 2))

    # A spike is a triangle, but a collision box is a rectangle. So measure
    # how far the cube is from the solid middle of the spike instead.
    def crashed_into_spike():
        for spike in get("spike"):
            middle = vec2(spike.pos.x + TILE / 2, spike.pos.y + TILE * 0.65)
            if cube.pos.dist(middle) < SPIKE_REACH:
                return True
        return False

    def crash():
        global crashed

        if crashed:
            return
        crashed = True

        play("crash")
        shake(10)
        cube.destroy()

        # Start again, one attempt further on.
        @wait(0.7)
        def again():
            go("run", attempt + 1)


go("run", 1)

Held, not pressed. The cube is only touching an orb for about a tenth of a second, too short to hit with a fresh press. So the game asks whether jump is held while the cube is touching an orb. Holding the key through the orb is how it's meant to be played, which is what the sign above the first one says.

Once each. Holding means the cube would fire the orb again on every frame it was touching it, each time starting the throw over. The cube would go higher than ORB_JUMP says, by however long you happened to be touching the orb, so the same jump would land in a different place each time. So each orb carries its own {"used": False}, set to True the first time.

orb.opacity = 0.3 dims a used orb, so you can see it has gone. That needs opacity(1) in the orb's list. Leave it out and the line still runs, with no error, but the orb never dims.

Where the orb is. A circle()'s pos is the top-left corner of the box around it, not its middle. The middle is one radius, 14 pixels, in from there, which is why use_orbs adds 14 before measuring.

How close is close enough. ORB_REACH is 56 pixels, from the middle of the cube to the middle of the orb: about the cube and the orb touching, plus a bit. The bit is on purpose. An orb that only fires on an exact hit feels broken, because you can see you went through it.

Step 6 — the whole game

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

  • Six levels. A level is a list of sections, so the levels are a list of lists: LEVELS. The scene is now given two numbers, run(level, number): which level, and which attempt at it. A crash goes to the same level, one attempt on; a finish goes to the next level, attempt 1. Lists count from 0 and levels from 1, so level 1 is LEVELS[0], which is why the scene says LEVELS[level - 1]. Write LEVELS[level] instead and level 1 plays the second level, and the last level stops the game with IndexError: list index out of range.
  • A progress bar. @onDraw draws it fresh every frame with drawRect and drawText, with fixed=True so it stays put while the level scrolls. How far along you are is the cube's x divided by where the finish is, and clamp(..., 0, 1) keeps that between 0 and 1. The green part is that fraction of the bar's 400 pixels.
  • A best. Each crash saves your percentage if it is more than your best, with setData. Each level has its own best: the level's number goes on the end of the name it is saved under, so level 2's is spike_runner_best_2.
  • A finish. Six tiles before the end of the level, finish() stops the run and say() puts up "Level complete", with how many attempts it took, and a Next level button. After the last level the button says Play again and goes back to level 1.
  • Pieces. The cube breaks into twelve little squares when it crashes, each flying off in its own direction and fading away.
  • Music, switched off. The playground's music track is 2.4 MB, which would make the game slow to load for a whole class at once. To switch it on, take the # off the loadSound("music", ...) line and set MUSIC = True.

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

"""Spike Runner -- the cube runs by itself. You just jump.

    space, up or click   jump (hold it to keep jumping)

The cube never stops running. Jump over the spikes, onto the blocks and
across the gaps. In mid-air, a yellow orb gives you a second jump: hold
jump as you go through it. Touch a spike, run into the side of a
block or fall into a gap, and you start that level again. Finish a level
and the next one starts, a little harder. The bar at the top shows how far
you got, and your best on each level is remembered.

HOW THIS FILE IS PUT TOGETHER

    1. the window, the pictures and the sounds
    2. the settings
    3. the levels, in sections
    4. what each letter turns into
    5. the run: the cube, jumping, crashing, the camera, the progress bar
    6. start the game

Each level is a row of short sections, each a picture made of letters. Add
a section, swap two round, draw a new one, or make a whole new level -- you
never have to touch the code to change the levels.
"""

from kaypy import *

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

TILE = 40          # one tile is 40 pixels square

kaypy(width=960, height=560, background=[40, 90, 200])

# Strong gravity and a hard jump make short, snappy hops.
setGravity(5000)

loadSprite("spike", "images/triangle.png")

loadSound("crash", "sounds/small_boom.wav")
loadSound("win", "sounds/rising_beep.wav")

# Music is switched off, because the track is 2.4 MB and would make the
# game slow to load. To switch it on: take the # off the line below, and set
# MUSIC = True in the settings.
# loadSound("music", "sounds/background.wav")


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

SPEED = 420           # how fast the cube runs, in pixels per second
JUMP = 1100           # how hard a jump is: about three tiles high
ORB_JUMP = 1150       # how hard an orb throws you, a little more
SPIN = 520            # how fast the cube turns in the air, degrees a second
SPIKE_REACH = 16      # how close to a spike's middle counts as touching it
ORB_REACH = 56        # how close to an orb counts as going through it
MUSIC = False         # True plays music -- see the loadSound line above

# 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. Each level gets its
# own best: the level's number goes on the end, as in spike_runner_best_2.
BEST_KEY = "spike_runner_best_"


# -------------------------------------------------------- 3. the levels
#
# One letter is one tile.
#
#   #  block        ^  spike        o  jump orb
#   @  the cube starts here
#
# A space is empty. The bottom row is the floor: leave a gap in it and the
# cube can fall through.
#
# A level is a row of sections, joined end to end. Every section has to
# have the same number of rows -- 9 -- but each can be as wide as you like.

START = [
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "          ",
    "  @       ",
    "##########",
]

ONE_SPIKE = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^       ",
    "##############",
]

TWO_SPIKES = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "      ^^      ",
    "##############",
]

STEPS = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                 ##     ",
    "            ##   ##     ",
    "       ##   ##   ##^^^  ",
    "########################",
]

GAP = [
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "              ",
    "#####   ######",
]

THREE_SPIKES = [
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "                ",
    "       ^^^      ",
    "################",
]

PLATFORMS = [
    "                              ",
    "                              ",
    "                              ",
    "                              ",
    "              ####            ",
    "                              ",
    "         ###                  ",
    "    ^          ^^^^^^^        ",
    "##############################",
]

ORB_GAP = [
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "       o            ",
    "                    ",
    "                    ",
    "#####      #########",
]

TUNNEL = [
    "                          ",
    "                          ",
    "                          ",
    "                          ",
    "      ##############      ",
    "                          ",
    "                          ",
    "          ^    ^          ",
    "##########################",
]

STAIRS_UP = [
    "                          ",
    "                          ",
    "                          ",
    "                     ##   ",
    "                ##   ##   ",
    "           ##   ##   ##   ",
    "      ##   ##   ##   ##   ",
    "      ##^^^##^^^##^^^##^^^",
    "##########################",
]

ORB_CHAIN = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "       o   o            ",
    "                        ",
    "                        ",
    "#####         ##########",
]

HOPS = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "#####  ###  ###  #######",
]

PILLARS = [
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "                        ",
    "     ##^^^##^^^##       ",
    "########################",
]

ORB_SPIKES = [
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "       o            ",
    "                    ",
    "     ^^^^^^         ",
    "####################",
]

FINISH = [
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "                    ",
    "####################",
]

# A level is a list of sections, played left to right. LEVELS is the list
# of levels, easiest first; each starts with START and ends with FINISH.
# The names are shown as the level begins, in the same order.

LEVELS = [
    [START, ONE_SPIKE, ONE_SPIKE, TWO_SPIKES, GAP, ONE_SPIKE, TWO_SPIKES,
     FINISH],
    [START, ONE_SPIKE, STEPS, GAP, TWO_SPIKES, PLATFORMS, THREE_SPIKES,
     FINISH],
    [START, TWO_SPIKES, ORB_GAP, GAP, ORB_GAP, THREE_SPIKES, ORB_CHAIN,
     FINISH],
    [START, STEPS, TUNNEL, HOPS, STAIRS_UP, ORB_SPIKES, GAP, FINISH],
    [START, HOPS, PILLARS, ORB_CHAIN, TUNNEL, ORB_SPIKES, PILLARS, STAIRS_UP,
     FINISH],
    [START, ONE_SPIKE, TWO_SPIKES, STEPS, GAP, THREE_SPIKES, PLATFORMS,
     ORB_GAP, TWO_SPIKES, TUNNEL, GAP, STAIRS_UP, ORB_CHAIN, THREE_SPIKES,
     PLATFORMS, ORB_GAP, STEPS, FINISH],
]

LEVEL_NAMES = ["First jumps", "Blocks", "Orbs", "Up the stairs", "Pillars",
               "The long run"]


def join_sections(sections):
    """Glue the sections together, side by side, into one level.

    Row 0 of the level is row 0 of every section, one after another, and
    the same for every other row.
    """
    # Every row of a section has to be the same width. If one is a letter
    # short, everything after it on that row slides one tile to the left,
    # and nothing says so -- so check, and say which section it is.
    for number in range(len(sections)):
        section = sections[number]
        for line in section:
            if len(line) != len(section[0]):
                print("Section", number + 1, "of this level has rows of",
                      "different widths. Count the letters on each row.")

    level = []
    for row in range(len(sections[0])):
        line = ""
        for section in sections:
            line = line + section[row]
        level.append(line)
    return level


# A level is 9 rows tall and the window is 14. Starting the level 4 rows
# down leaves sky above it and puts the floor near the bottom.
TOP = TILE * 4


# ----------------------------------------------- 4. what each letter becomes

TILES = {
    "#": lambda: [rect(TILE, TILE), color(20, 30, 70),
                  outline(2, (120, 200, 255)), area(),
                  body(isStatic=True), "block"],

    # The triangle picture is 48 x 39. Scaling it to exactly one tile means
    # it sits on the floor of its square instead of hovering at the top.
    # It has no area(): touching a spike is worked out by distance instead.
    # See crashed_into_spike() below for why.
    "^": lambda: [sprite("spike"), scale(TILE / 48, TILE / 39), "spike"],

    # An orb works once per attempt. "used" says whether it has been.
    "o": lambda: [circle(14), color(255, 220, 60), outline(3, (255, 255, 255)),
                  opacity(1), "orb", {"used": False}],

    # The cube. anchor("center") so that it turns about its middle.
    "@": lambda: [rect(36, 36), color(255, 210, 40), outline(3, (0, 0, 0)),
                  anchor("center"), rotate(0), area(), body(), z(10),
                  "player"],
}


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

attempt = 1
crashed = False
finished = False
last_x = 0          # where the cube was last frame
expected = 0        # how far it was told to move last frame
music = None


# ------------------------------------------------------------- 5. the run
#
# The scene is CALLED "run", and it is given two numbers: which level, and
# which attempt at it. A new attempt is the same scene built again, so
# everything starts from the beginning. The function is not called play(),
# because kaypy already has a play() for sounds.

@scene("run")
def run(level, number):
    global attempt, crashed, finished, last_x, expected, music

    attempt = number
    crashed = False
    finished = False
    expected = 0

    # Level 1 is LEVELS[0]: lists count from 0, levels from 1.
    level_map = join_sections(LEVELS[level - 1])
    level_width = len(level_map[0]) * TILE

    addLevel(level_map, {
        "tileWidth": TILE,
        "tileHeight": TILE,
        "pos": vec2(0, TOP),
        "tiles": TILES,
    })

    cube = get("player")[0]
    last_x = cube.pos.x
    best = getData(BEST_KEY + str(level), 0)

    # Written into the level at the start, so it scrolls away as you go.
    add([text("Level " + str(level) + ": " + LEVEL_NAMES[level - 1], size=30),
         pos(cube.pos.x + 60, TOP + TILE)])
    add([text("Attempt " + str(attempt), size=44),
         pos(cube.pos.x + 60, TOP + TILE * 2)])

    # The music starts again with every attempt. The old one is stopped
    # first, or each attempt would add another copy playing on top.
    if music is not None:
        music.paused = True
    if MUSIC:
        music = play("music", loop=True, volume=0.5)

    # --------------------------------------------------------- jumping
    #
    # Holding the key jumps again the moment the cube lands -- that is why
    # this asks every frame rather than waiting for a key press.

    def holding_jump():
        return isKeyDown("space") or isKeyDown("up") or isMouseDown()

    # An orb fires if jump is held while the cube touches it -- held, not
    # just pressed at that moment. The cube is only touching an orb for a
    # tenth of a second, far too short to hit with a fresh press; holding
    # the key as you go in is how it is meant to be played.
    #
    # Each orb fires once, then dims. Without "used", holding the key would
    # fire it again on every frame the cube was touching it.
    def use_orbs():
        for orb in get("orb"):
            # A circle's pos is its top-left corner; its middle is one
            # radius (14) in from there.
            middle = vec2(orb.pos.x + 14, orb.pos.y + 14)
            if not orb.used and cube.pos.dist(middle) < ORB_REACH:
                orb.used = True
                orb.opacity = 0.3
                cube.jump(ORB_JUMP)

    # A sign over the first orb, because nothing else says what they do.
    orbs = get("orb")
    if len(orbs) > 0:
        first = orbs[0]
        add([text("hold jump through the orb!", size=22),
             pos(first.pos.x - 130, first.pos.y - 60)])

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

    @onUpdate
    def each_frame():
        global last_x, expected

        if crashed or finished:
            return

        # Ran into the side of a block? Then the block stopped the cube, and
        # it got less than half as far as it was told to go last frame. That
        # is "last frame", not this one: frames are not all the same length,
        # and comparing against this frame's would call a long frame after a
        # short one a crash.
        if cube.pos.x - last_x < expected * 0.5:
            crash()
            return
        last_x = cube.pos.x
        expected = SPEED * dt()

        cube.move(SPEED, 0)

        if cube.isGrounded():
            # Land square: turn to the nearest quarter turn.
            cube.angle = round(cube.angle / 90) * 90
            if holding_jump():
                cube.jump(JUMP)
        else:
            cube.angle = cube.angle + SPIN * dt()
            if holding_jump():
                use_orbs()

        if crashed_into_spike():
            crash()
            return

        # Fell into a gap.
        if cube.pos.y > height() + 50:
            crash()
            return

        if cube.pos.x > level_width - TILE * 6:
            finish()

        # The camera keeps the cube a quarter of the way across the window,
        # so you can see what is coming.
        setCamPos(vec2(cube.pos.x + width() / 4, height() / 2))

    # A spike is a triangle, but a collision box is a rectangle. A box would
    # kill you for clipping the empty corner beside the point. So instead,
    # measure how far the cube is from the solid middle of the spike, a
    # little below its centre, and only count it if that is close.
    def crashed_into_spike():
        for spike in get("spike"):
            middle = vec2(spike.pos.x + TILE / 2, spike.pos.y + TILE * 0.65)
            if cube.pos.dist(middle) < SPIKE_REACH:
                return True
        return False

    # ------------------------------------------------- the progress bar

    @onDraw
    def progress_bar():
        done = clamp(cube.pos.x / (level_width - TILE * 6), 0, 1)
        drawRect(pos=vec2(280, 18), width=400, height=14,
                 color=(20, 30, 70), fixed=True)
        drawRect(pos=vec2(280, 18), width=400 * done, height=14,
                 color=(120, 255, 140), fixed=True)
        drawText(text=str(int(done * 100)) + "%", pos=vec2(692, 12),
                 size=22, fixed=True)
        drawText(text="level " + str(level) + "   best " + str(best) + "%",
                 pos=vec2(16, 12), size=20, fixed=True)

    # ------------------------------------------------- crashing, winning

    def percent():
        return int(clamp(cube.pos.x / (level_width - TILE * 6), 0, 1) * 100)

    def save_best(score):
        if score > best:
            setData(BEST_KEY + str(level), score)

    def crash():
        global crashed

        if crashed:
            return
        crashed = True

        if music is not None:
            music.paused = True
        play("crash")
        shake(10)
        save_best(percent())

        # The cube breaks into pieces that fly off and fade.
        for i in range(12):
            add([rect(10, 10), pos(cube.pos.x, cube.pos.y),
                 color(255, 210, 40), opacity(1),
                 move(vec2(rand(-1, 1), rand(-1, 1)), rand(100, 400)),
                 lifespan(0.6, fade=0.4)])
        cube.destroy()

        @wait(0.7)
        def again():
            go("run", level, attempt + 1)

    def finish():
        global finished

        finished = True
        if music is not None:
            music.paused = True
        play("win")
        save_best(100)

        if attempt == 1:
            tries = "on your first attempt!"
        else:
            tries = "in " + str(attempt) + " attempts."

        # After the last level there is no next one, so start over.
        if level < len(LEVELS):
            @say("Level " + str(level) + " complete, " + tries,
                 button="Next level")
            def next_level():
                go("run", level + 1, 1)
        else:
            @say("Level " + str(level) + " complete, " + tries +
                 " That was the last one: you beat the game!",
                 button="Play again")
            def play_again():
                go("run", 1, 1)


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

go("run", 1, 1)

Make it yours

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

  • Draw a section. Nine rows, every row the same width, the floor along the bottom. Add its name to a level in LEVELS wherever you want it. Play it yourself before calling it done: a gap too wide or a wall too tall is easy to draw and impossible to beat.
  • Make a level. Add a new list of sections to the end of LEVELS, starting with START and ending with FINISH, and a name for it to the end of LEVEL_NAMES. Forget the name and the game stops when you reach your level, with IndexError: list index out of range.
  • Start on a later level. The last line, go("run", 1, 1), starts on level 1, attempt 1. Make it go("run", 4, 1) to test level 4 without playing the first three.
  • Change the speed. SPEED makes the whole level faster, but every jump also covers more ground, so obstacles that were easy can become impossible, and the other way round.
  • Change the cube. Its colour is the color(255, 210, 40) in the @ line, and the pieces it breaks into are coloured in crash().
  • Switch the music on. One line and one setting; see step 6.