Building Spiral Storm

Play it first → The Gazer swings along the top of the arena and fills the sky with bullets. First it throws single shots aimed straight at Bean, then rings of bullets, then a spiral that never stops turning. Every second you last is a point. 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 Gazer. The bullets are circles.

Build it after Paddle Ball and before Pong. It is put together the way Bomb Dodge is — one score, one life, global, a scene to play again in — and it adds two new things: a camera that zooms out, and bullets that fly off in any direction.

The one big idea: a bullet knows which way it is going

In Paddle Ball, the ball moved because every frame your own code added ball.dx and ball.dy to its position. That's fine for one ball. Spiral Storm has a hundred bullets on the screen at once, and none of them ever turns round, so there's an easier way.

Put move(direction, speed) in a bullet's list, and kaypy moves it for you, every frame, for ever, in a straight line. You never write an onUpdate to move it. All you have to say is which way.

There are two ways to say which way:

  • Towards something. bean.pos - gazer.pos is the way from the Gazer to Bean. Take where you start away from where you want to get to, and what's left is the way there.
  • At an angle. Vec2.fromAngle(angle) turns an angle, in degrees, into a direction. 0 is right, 90 is down, 180 is left and 270 is up. Down is 90, not up, because on a screen y gets bigger going down.

Once you have angles, the patterns are easy. A ring is a for loop that fires one bullet every 15 degrees, all the way round. A spiral fires a few bullets at an angle that turns a little further every time. That's all a bullet hell is.

Step 1 — zoom out, and move

Bean sits near the bottom of a dark arena. The arrow keys move it in four directions, and holding the mouse down somewhere moves Bean there. It stops at the edges. Everything looks a little smaller than in the other games, because the camera has zoomed out.

from kaypy import *

ZOOM = 0.7             # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360       # how fast Bean moves, in pixels per second

kaypy(width=800, height=600, background=[24, 18, 40])

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

# Zoom out. The window now holds a bigger piece of the world: arena_w by
# arena_h, which is 1143 by 857. Point the camera at the middle of that,
# and the arena runs from (0, 0) to (arena_w, arena_h).
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))

bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
            anchor("center")])


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


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


@onKeyDown("up")
def go_up():
    bean.move(0, -BEAN_SPEED)
    keep_on_screen()


@onKeyDown("down")
def go_down():
    bean.move(0, BEAN_SPEED)
    keep_on_screen()


# Holding the mouse down -- or a finger, on a phone -- moves Bean towards
# it. mousePos() is where the mouse is in the WINDOW; with the camera
# zoomed out, toWorld() turns that into where it is in the arena.
@onMouseDown
def go_to_mouse():
    bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
    keep_on_screen()


def keep_on_screen():
    # 32 and 27 are half of Bean's width and height.
    bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
    bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)

Zooming out. setCamScale(ZOOM) tells the camera to draw everything at ZOOM times its size. At 0.7, Bean, 64 pixels wide, is drawn about 45 pixels wide, so the 800-by-600 window now has room for a bigger piece of the world: 800 / 0.7 by 600 / 0.7, which is about 1143 by 857. That's arena_w and arena_h. More room means more bullets on the screen and more space to dodge them.

The camera always shows whatever is around the spot it's pointing at. setCamPos(vec2(arena_w / 2, arena_h / 2)) points it at the middle of the arena, so the arena runs from (0, 0) at the top left to (arena_w, arena_h) at the bottom right, the way the window does in a game with no zoom.

  • From here on, every position is a position in the arena. pos(arena_w / 2, arena_h - 100) is the middle of the arena, 100 pixels up from the bottom.
  • Four @onKeyDown functions, one per arrow, the way Bean ran in Bomb Dodge. bean.move(0, -BEAN_SPEED) is up, because up is minus.
  • bean.moveTo(place, BEAN_SPEED) moves Bean towards place at that speed, a little each frame, and stops when it gets there. It does in one line what Bomb Dodge did with two ifs.
  • mousePos() is where the mouse is in the window. toWorld() turns that into where it is in the arena. With no zoom they are the same. With the camera zoomed out they are not, and Bean needs the arena one.
  • keep_on_screen() uses clamp twice now, once across and once down. 32 and 27 are half Bean's width and height.

Two things to break.

Take out the setCamPos(...) line. There's no error message, and no Bean either. The camera is still pointing where it started, at (400, 300), so it is looking at the wrong part of the arena, and Bean is just below the bottom of the window. Hold up for a moment and Bean climbs into view. Then hold right: Bean walks off the right-hand side of the window, because the arena's edge is past the window's.

Put the line back, then change toWorld(mousePos()) to just mousePos(). Hold the mouse near the top-left corner, at (100, 100), and Bean stops at (70, 70), a little past the pointer. Hold it near the bottom right, at (700, 500), and Bean stops at (490, 350), nowhere near it. Bean is going to the right numbers, but in the wrong place: a window position used as an arena position.

Step 2 — the Gazer, and shots aimed at Bean

The Gazer appears at the top and swings from side to side. A second in, it starts shooting yellow bullets straight at Bean, one every 0.7 seconds. Move and they miss. Stand still and they go straight through you, because nothing checks yet whether a bullet has hit.

from kaypy import *

ZOOM = 0.7             # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360       # how fast Bean moves, in pixels per second
BULLET_SIZE = 9        # how big a bullet is (its radius)
BULLET_SPEED = 130     # how fast bullets fly
AIM_WAIT = 0.7         # seconds between shots aimed straight at Bean

kaypy(width=800, height=600, background=[24, 18, 40])

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

# Zoom out. The window now holds a bigger piece of the world: arena_w by
# arena_h, which is 1143 by 857. Point the camera at the middle of that,
# and the arena runs from (0, 0) to (arena_w, arena_h).
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))

# The Gazer, along the top. anchor("center") means pos() is the middle.
gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
             z(5)])

bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
            anchor("center"), z(5)])


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


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


@onKeyDown("up")
def go_up():
    bean.move(0, -BEAN_SPEED)
    keep_on_screen()


@onKeyDown("down")
def go_down():
    bean.move(0, BEAN_SPEED)
    keep_on_screen()


# Holding the mouse down -- or a finger, on a phone -- moves Bean towards
# it. mousePos() is where the mouse is in the WINDOW; with the camera
# zoomed out, toWorld() turns that into where it is in the arena.
@onMouseDown
def go_to_mouse():
    bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
    keep_on_screen()


def keep_on_screen():
    # 32 and 27 are half of Bean's width and height.
    bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
    bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)


# The Gazer swings from side to side. wave() goes smoothly back and forth
# between the two numbers as time() goes up.
@onUpdate
def swing():
    gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)


# One bullet, from the Gazer, flying in `direction`. move() keeps it going
# by itself; it only needs to be told which way and how fast.
def shoot(direction, colour):
    add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
         anchor("center"), move(direction, BULLET_SPEED), "bullet"])


# A shot aimed straight at Bean. Bean's position take away the Gazer's is
# the way from the Gazer to Bean.
def aim():
    shoot(bean.pos - gazer.pos, YELLOW)
    wait(AIM_WAIT, aim)


wait(1, aim)
  • wave(150, arena_w - 150, time() * 0.6) is a number that swings smoothly between 150 and arena_w - 150 as time() goes up. Setting the Gazer's x to it every frame swings the Gazer. Make the 0.6 bigger and it swings faster.
  • shoot(direction, colour) makes one bullet. It starts where the Gazer is, pos(gazer.pos), and move(direction, BULLET_SPEED) sends it off. There's nothing else to write: the bullet keeps going by itself. The "bullet" at the end is a tag, so the next step can find all of them.
  • color(colour) takes a colour kaypy already has a name for: YELLOW here, and MAGENTA and CYAN in step 5, so you can tell the kinds of shot apart.
  • bean.pos - gazer.pos is the way from the Gazer to Bean. move() only cares about the way, not the length, so it doesn't matter how far apart they are.
  • aim fires a shot and then asks wait to run aim again in AIM_WAIT seconds. That's how bombs kept dropping in Bomb Dodge: a function that books its own next turn.

Break it. Turn the subtraction round: shoot(gazer.pos - bean.pos, YELLOW). There's no error message. Every shot flies up and away from Bean, off the top of the window. gazer.pos - bean.pos is the way from Bean to the Gazer, so the bullet sets off from the Gazer going the way that points: further away from Bean.

Step 3 — getting hit, and cleaning up

Now a bullet that reaches Bean is a hit: a bang, a shake, and Bean is gone. The Gazer stops swinging and stops shooting.

from kaypy import *

ZOOM = 0.7             # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360       # how fast Bean moves, in pixels per second
HIT_DISTANCE = 16      # how close a bullet's middle has to get to Bean's
BULLET_SIZE = 9        # how big a bullet is (its radius)
BULLET_SPEED = 130     # how fast bullets fly
AIM_WAIT = 0.7         # seconds between shots aimed straight at Bean

kaypy(width=800, height=600, background=[24, 18, 40])

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

alive = True

# Zoom out. The window now holds a bigger piece of the world: arena_w by
# arena_h, which is 1143 by 857. Point the camera at the middle of that,
# and the arena runs from (0, 0) to (arena_w, arena_h).
arena_w = width() / ZOOM
arena_h = height() / ZOOM
setCamScale(ZOOM)
setCamPos(vec2(arena_w / 2, arena_h / 2))

# The Gazer, along the top. anchor("center") means pos() is the middle.
gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
             z(5)])

bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
            anchor("center"), z(5)])


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


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


@onKeyDown("up")
def go_up():
    bean.move(0, -BEAN_SPEED)
    keep_on_screen()


@onKeyDown("down")
def go_down():
    bean.move(0, BEAN_SPEED)
    keep_on_screen()


# Holding the mouse down -- or a finger, on a phone -- moves Bean towards
# it. mousePos() is where the mouse is in the WINDOW; with the camera
# zoomed out, toWorld() turns that into where it is in the arena.
@onMouseDown
def go_to_mouse():
    bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
    keep_on_screen()


def keep_on_screen():
    # 32 and 27 are half of Bean's width and height.
    bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
    bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)


# The Gazer swings from side to side. wave() goes smoothly back and forth
# between the two numbers as time() goes up.
@onUpdate
def swing():
    if alive:
        gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)


# One bullet, from the Gazer, flying in `direction`. move() keeps it going
# by itself; it only needs to be told which way and how fast.
def shoot(direction, colour):
    add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
         anchor("center"), move(direction, BULLET_SPEED), "bullet"])


# A shot aimed straight at Bean. Bean's position take away the Gazer's is
# the way from the Gazer to Bean.
def aim():
    if not alive:
        return
    shoot(bean.pos - gazer.pos, YELLOW)
    wait(AIM_WAIT, aim)


wait(1, aim)


# Every bullet runs this, every frame.
@onUpdate("bullet")
def check(bullet):
    if not alive:
        return

    if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
        hit()
        return

    # Gone out of the arena? Then it is finished with.
    x = bullet.pos.x
    y = bullet.pos.y
    if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
        bullet.destroy()


def hit():
    global alive

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

    # Bean goes, and so does every bullet. move() would keep them flying.
    bean.destroy()
    destroyAll("bullet")
  • @onUpdate("bullet") runs check every frame for every bullet, with that bullet as bullet.
  • bullet.pos.dist(bean.pos) is how far the bullet's middle is from Bean's middle. Under HIT_DISTANCE is a hit.
  • The hit is much smaller than Bean. Bean is 64 pixels wide, and a bullet is 18 across. Measured from middle to middle, a bullet first touches the picture of Bean at about 40. HIT_DISTANCE is 16, so a bullet has to get right into the middle of Bean to count. That's on purpose, and every game like this does it: a bullet that only clips your edge misses, and you can squeeze through gaps that look too small. It feels fair. A hit the size of the picture feels like being hit by something that missed.
  • alive is how the rest of the game knows it's over. hit changes it, so hit says global alive first. aim, swing and check only read it, so they don't.
  • A bullet more than 20 pixels outside the arena is destroyed.

Two things to break.

Take out the last two lines of check, the ones that destroy a bullet that has left the arena. There's no error message, and the game looks just the same. But every bullet the Gazer has ever fired is still out there, flying away, and kaypy moves every one of them every frame. Keep Bean alive for a minute and there are 82 bullets, with only about 9 of them on the screen. The whole game fires a lot more: three minutes in there were 4,683, each frame took about 28 milliseconds instead of less than one, and it kept getting slower the longer it ran.

Put them back, and take out destroyAll("bullet") instead. Stand still and get hit. The bullets that were already in the air keep flying after Bean has gone, straight off the edge of the window. move() doesn't know the game is over. Only your own code knows that, so your own code has to stop them.

Step 4 — points, and playing again

A point for every second you last, a best score that's remembered, and space or a click to play again. This step is Bomb Dodge's step 4 all over again, so it is put together the same way: the whole game goes inside a scene, and again() starts the scene over.

from kaypy import *

ZOOM = 0.7             # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360       # how fast Bean moves, in pixels per second
HIT_DISTANCE = 16      # how close a bullet's middle has to get to Bean's
BULLET_SIZE = 9        # how big a bullet is (its radius)
BULLET_SPEED = 130     # how fast bullets fly
AIM_WAIT = 0.7         # seconds between shots aimed straight at Bean
BEST_KEY = "spiral_storm_best"

kaypy(width=800, height=600, background=[24, 18, 40])

loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.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
    best = getData(BEST_KEY, 0)

    # Zoom out. go() puts the camera back to normal, which is why this is
    # in the scene now.
    arena_w = width() / ZOOM
    arena_h = height() / ZOOM
    setCamScale(ZOOM)
    setCamPos(vec2(arena_w / 2, arena_h / 2))

    gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
                 z(5)])

    bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
                anchor("center"), z(5)])

    # fixed() keeps the writing where it is on the screen, at its normal
    # size, whatever the camera does. So these positions are in the window,
    # not the arena.
    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), fixed(), z(10)])
    best_label = add([text("best " + str(best), size=18), pos(12, 12),
                      opacity(0.75), fixed(), z(10)])
    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"),
                   fixed(), z(10)])

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

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

    @onKeyDown("up")
    def go_up():
        bean.move(0, -BEAN_SPEED)
        keep_on_screen()

    @onKeyDown("down")
    def go_down():
        bean.move(0, BEAN_SPEED)
        keep_on_screen()

    @onMouseDown
    def go_to_mouse():
        bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
        keep_on_screen()

    def keep_on_screen():
        bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
        bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)

    @onUpdate
    def swing():
        if alive:
            gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)

    def shoot(direction, colour):
        add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
             anchor("center"), move(direction, BULLET_SPEED), "bullet"])

    def aim():
        if not alive:
            return
        shoot(bean.pos - gazer.pos, YELLOW)
        wait(AIM_WAIT, aim)

    wait(1, aim)

    @onUpdate("bullet")
    def check(bullet):
        if not alive:
            return

        if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
            hit()
            return

        x = bullet.pos.x
        y = bullet.pos.y
        if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
            bullet.destroy()

    # A point for every second you are still here.
    def tick():
        global score
        if not alive:
            return
        score = score + 1
        score_label.text = str(score)
        if score == 1:
            message.text = ""
        wait(1, tick)

    wait(1, tick)

    def hit():
        global alive

        alive = False
        play("boom")
        shake(12)
        addKaboom(bean.pos)
        bean.destroy()
        destroyAll("bullet")

        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)


go("game")
  • tick adds a point and books itself again in a second, the same trick as aim. It stops booking once alive is False, so the points stop too.
  • score, alive and best work as they did in Bomb Dodge. getData and setData keep the best score after the page is closed.
  • The zoom moved into the scene. go("game") throws away everything from the last game before it starts the new one, and that includes the camera: it goes back to no zoom, pointing at the middle of the window. Setting it in the scene sets it again every game.
  • fixed() is on the score, the best score and the message. It means "this belongs to the window, not the arena": the camera leaves it alone, so it stays where it was put, at its own size. Its pos() is a window position, which is why the score is at width() / 2 and not arena_w / 2.

Two things to break.

Move the four zoom lines out of the scene and up to the top of the program, under alive = True. The first game looks fine. Get hit and press space: the second game is zoomed back in, and Bean is nowhere. It's still at (571, 757) in the arena, and with no zoom the window only shows down to 600.

Put them back, and take fixed() off the score. There's no error message. The score shrinks and slides up and to the left, to about (280, 28) in the window, because now it's a thing in the arena at (400, 40), and the camera zooms it like everything else.

Step 5 — rings, and a spiral

At 8 points the Gazer starts throwing a ring of pink bullets every 2.5 seconds. At 20 it adds a blue spiral with four arms that turns and turns and never stops. With everything going, there are about a hundred bullets on the screen.

from kaypy import *

ZOOM = 0.7             # 1 is normal; 0.7 shows more of the world
BEAN_SPEED = 360       # how fast Bean moves, in pixels per second
HIT_DISTANCE = 16      # how close a bullet's middle has to get to Bean's
BULLET_SIZE = 9        # how big a bullet is (its radius)
BULLET_SPEED = 130     # how fast bullets fly
AIM_WAIT = 0.7         # seconds between shots aimed straight at Bean

RING_FROM = 8          # the score the rings start at
RING_GAP = 15          # degrees between the bullets in a ring
RING_WAIT = 2.5        # seconds between rings

SPIRAL_FROM = 20       # the score the spiral starts at
SPIRAL_ARMS = 4        # how many arms the spiral has
SPIRAL_WAIT = 0.25     # seconds between one turn of the spiral and the next
SPIRAL_TURN = 11       # how many degrees it turns each time

BEST_KEY = "spiral_storm_best"

kaypy(width=800, height=600, background=[24, 18, 40])

loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.png")
loadSound("shoot", "sounds/small_beep.wav")
loadSound("boom", "sounds/small_boom.wav")

score = 0
alive = True
spiral_angle = 0


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

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

    # Zoom out. go() puts the camera back to normal, which is why this is
    # in the scene now.
    arena_w = width() / ZOOM
    arena_h = height() / ZOOM
    setCamScale(ZOOM)
    setCamPos(vec2(arena_w / 2, arena_h / 2))

    gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
                 z(5)])

    bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
                anchor("center"), z(5)])

    # fixed() keeps the writing where it is on the screen, at its normal
    # size, whatever the camera does. So these positions are in the window,
    # not the arena.
    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), fixed(), z(10)])
    best_label = add([text("best " + str(best), size=18), pos(12, 12),
                      opacity(0.75), fixed(), z(10)])
    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"),
                   fixed(), z(10)])

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

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

    @onKeyDown("up")
    def go_up():
        bean.move(0, -BEAN_SPEED)
        keep_on_screen()

    @onKeyDown("down")
    def go_down():
        bean.move(0, BEAN_SPEED)
        keep_on_screen()

    @onMouseDown
    def go_to_mouse():
        bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
        keep_on_screen()

    def keep_on_screen():
        bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
        bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)

    @onUpdate
    def swing():
        if alive:
            gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)

    def shoot(direction, colour):
        add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
             anchor("center"), move(direction, BULLET_SPEED), "bullet"])

    def aim():
        if not alive:
            return
        shoot(bean.pos - gazer.pos, YELLOW)
        wait(AIM_WAIT, aim)

    # A ring: one bullet every RING_GAP degrees, all the way round.
    def ring():
        if not alive:
            return
        if score >= RING_FROM:
            for angle in range(0, 360, RING_GAP):
                shoot(Vec2.fromAngle(angle), MAGENTA)
            play("shoot")
        wait(RING_WAIT, ring)

    # A spiral: a bullet for each arm, spread evenly round, at an angle
    # that turns a little further every time.
    def spiral():
        global spiral_angle
        if not alive:
            return
        if score >= SPIRAL_FROM:
            for arm in range(0, 360, 360 // SPIRAL_ARMS):
                shoot(Vec2.fromAngle(spiral_angle + arm), CYAN)
            spiral_angle = spiral_angle + SPIRAL_TURN
        wait(SPIRAL_WAIT, spiral)

    wait(1, aim)
    wait(1, ring)
    wait(1, spiral)

    @onUpdate("bullet")
    def check(bullet):
        if not alive:
            return

        if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
            hit()
            return

        x = bullet.pos.x
        y = bullet.pos.y
        if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
            bullet.destroy()

    # A point for every second you are still here.
    def tick():
        global score
        if not alive:
            return
        score = score + 1
        score_label.text = str(score)
        if score == 1:
            message.text = ""
        wait(1, tick)

    wait(1, tick)

    def hit():
        global alive

        alive = False
        play("boom")
        shake(12)
        addKaboom(bean.pos)
        bean.destroy()
        destroyAll("bullet")

        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)


go("game")

The ring. range(0, 360, RING_GAP) counts from 0 up to 360 in steps of 15: 0, 15, 30, and so on up to 345. That's 24 angles. The loop fires one bullet at each angle, so 24 bullets leave the Gazer at once, spread evenly all the way round. (360 itself is left out, which is right: it's the same as 0.)

The spiral. 360 // SPIRAL_ARMS is 90, so range(0, 360, 90) is 0, 90, 180 and 270: four bullets, a quarter of a turn apart, one for each arm. spiral_angle is added to every one of them. Then spiral_angle goes up by 11, so a quarter of a second later, the next four go out a little further round. Each arm curls a bit more every time, and that is the spiral.

  • spiral_angle has to last from one call of spiral to the next, so it lives up at the top with score and alive. spiral changes it, so spiral says global spiral_angle. The scene sets it back to 0 for each new game, so the scene says it too.
  • ring and spiral start running a second in, like aim, but they don't shoot until the score is high enough. They still book their next turn either way, so they're ready the moment it is.
  • play("shoot") is outside the for loop, so it beeps once for each ring, not once for each of its 24 bullets.

Two things to break.

Make RING_GAP 0. The game runs fine until 8 points, when the first ring is due, and then stops with

    for angle in range(0, 360, RING_GAP):
ValueError: range() arg 3 must not be zero

Counting from 0 to 360 in steps of 0 would never get there, so Python refuses. "arg 3" is the third thing in the brackets.

Make SPIRAL_ARMS 7. There's no error, but count the bullets in one turn: there are 8, and two of them are almost on top of each other. 360 // 7 is 51, because // throws away what's left over. Counting up in 51s gives 0, 51, 102, 153, 204, 255, 306 — and then 357, which is still under 360, so it's in. The eighth bullet is 3 degrees from the first. Arms that divide 360 exactly — 2, 3, 4, 5, 6, 8 — come out even.

Step 6 — the whole game

The last thing: it gets harder as you go. The bullets were all BULLET_SPEED, and the rings came every RING_WAIT. Now each is worked out from the score by a small function, the way Bomb Dodge works out how fast its bombs fall. bullet_speed() starts at 130 and adds 1 for every point, up to 200. ring_wait() starts at 2.5 seconds and takes off 0.02 for every point, down to 2. Both stop somewhere, so the game ends up hard but never impossible.

Here it is, the whole game, exactly as it is on the Games page. It has the settings gathered at the top, and comments saying what every part is for.

"""Spiral Storm -- the Gazer fills the sky with bullets. Don't get hit.

    arrow keys              move
    or hold the mouse (or a finger) where you want Bean to go

Every second you last is a point. The longer you last, the more the Gazer
throws at you: first shots aimed straight at Bean, then rings of bullets,
then a spiral that never stops turning. 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 sounds
    2. the settings
    3. how hard the game is
    4. the game scene: zooming out, Bean, the Gazer, the bullets
    5. start the game

Every bullet is a game object with move() on it, which sends it off in a
straight line by itself. All the Gazer has to do is say which way.
Vec2.fromAngle(angle) turns an angle into a direction: 0 is right, 90 is
down, 180 is left, 270 is up. A ring is a for loop over the angles, and a
spiral is one angle that keeps turning. After this, try Pong.
"""

from kaypy import *

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

kaypy(width=800, height=600, background=[24, 18, 40])

loadSprite("bean", "images/bean.png")
loadSprite("gazer", "images/gazer.png")
loadSound("shoot", "sounds/small_beep.wav")
loadSound("boom", "sounds/small_boom.wav")


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

# How far the camera is zoomed out. 1 is normal; 0.7 shows more of the
# world, so everything looks smaller and there is more room to dodge.
ZOOM = 0.7

BEAN_SPEED = 360       # how fast Bean moves, in pixels per second

# How close a bullet's middle has to get to Bean's middle to hit. Bean is
# 64 pixels wide, so this is a lot smaller than Bean, on purpose: a bullet
# that only clips the edge of Bean misses, and you can squeeze through gaps
# that look too small.
HIT_DISTANCE = 16

BULLET_SIZE = 9        # how big a bullet is (its radius)
SPEED_START = 130      # how fast bullets fly, to begin with
SPEED_FASTEST = 200    # ...the fastest they ever fly
SPEED_GAIN = 1         # ...and how much faster each point makes them

AIM_WAIT = 0.7         # seconds between shots aimed straight at Bean

RING_FROM = 8          # the score the rings start at
RING_GAP = 15          # degrees between the bullets in a ring
RING_WAIT_START = 2.5  # seconds between rings, to begin with
RING_WAIT_SHORTEST = 2 # ...the shortest wait there ever is
RING_WAIT_DROP = 0.02  # ...and how much shorter each point makes it

SPIRAL_FROM = 20       # the score the spiral starts at
SPIRAL_ARMS = 4        # how many arms the spiral has
SPIRAL_WAIT = 0.25     # seconds between one turn of the spiral and the next
SPIRAL_TURN = 11       # how many degrees it turns each time

# 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 = "spiral_storm_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 bullet_speed():
    speed = SPEED_START + score * SPEED_GAIN
    if speed > SPEED_FASTEST:
        speed = SPEED_FASTEST
    return speed


def ring_wait():
    seconds = RING_WAIT_START - score * RING_WAIT_DROP
    if seconds < RING_WAIT_SHORTEST:
        seconds = RING_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
spiral_angle = 0


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

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

    # ----------------------------------------------------- zooming out
    #
    # The camera shows the world at ZOOM times its size, so the window
    # now holds a bigger piece of the world: arena_w by arena_h, which is
    # 1143 by 857 when ZOOM is 0.7. Point the camera at the middle of that
    # and the arena runs from (0, 0) to (arena_w, arena_h), the way the
    # window did before. go() puts the camera back to normal, which is why
    # this is in the scene.
    arena_w = width() / ZOOM
    arena_h = height() / ZOOM
    setCamScale(ZOOM)
    setCamPos(vec2(arena_w / 2, arena_h / 2))

    # The Gazer, along the top. anchor("center") means pos() is the middle.
    gazer = add([sprite("gazer"), pos(arena_w / 2, 110), anchor("center"),
                 z(5)])

    bean = add([sprite("bean"), pos(arena_w / 2, arena_h - 100),
                anchor("center"), z(5)])

    # fixed() keeps the writing where it is on the screen, at its normal
    # size, whatever the camera does. So these positions are in the window,
    # not the arena.
    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), fixed(), z(10)])
    best_label = add([text("best " + str(best), size=18), pos(12, 12),
                      opacity(0.75), fixed(), 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"),
                   fixed(), z(10)])

    # ------------------------------------------------------------ moving

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

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

    @onKeyDown("up")
    def go_up():
        bean.move(0, -BEAN_SPEED)
        keep_on_screen()

    @onKeyDown("down")
    def go_down():
        bean.move(0, BEAN_SPEED)
        keep_on_screen()

    # Holding the mouse down -- or a finger, on a phone -- moves Bean
    # towards it. mousePos() is where the mouse is in the WINDOW; with the
    # camera zoomed out, toWorld() turns that into where it is in the arena.
    @onMouseDown
    def go_to_mouse():
        bean.moveTo(toWorld(mousePos()), BEAN_SPEED)
        keep_on_screen()

    def keep_on_screen():
        # 32 and 27 are half of Bean's width and height.
        bean.pos.x = clamp(bean.pos.x, 32, arena_w - 32)
        bean.pos.y = clamp(bean.pos.y, 27, arena_h - 27)

    # ------------------------------------------------------------- the Gazer
    #
    # It swings from side to side. wave() goes smoothly back and forth
    # between the two numbers as time() goes up.

    @onUpdate
    def swing():
        if alive:
            gazer.pos.x = wave(150, arena_w - 150, time() * 0.6)

    # ------------------------------------------------------------ bullets
    #
    # One bullet, from the Gazer, flying in `direction`. move() keeps it
    # going by itself; it only needs to be told which way and how fast.
    # Each kind of shot has its own colour, so you can tell them apart.

    def shoot(direction, colour):
        add([circle(BULLET_SIZE), color(colour), pos(gazer.pos),
             anchor("center"), move(direction, bullet_speed()), "bullet"])

    # A shot aimed straight at Bean. Bean's position take away the Gazer's
    # is the way from the Gazer to Bean.
    def aim():
        if not alive:
            return
        shoot(bean.pos - gazer.pos, YELLOW)
        wait(AIM_WAIT, aim)

    # A ring: one bullet every RING_GAP degrees, all the way round.
    def ring():
        if not alive:
            return
        if score >= RING_FROM:
            for angle in range(0, 360, RING_GAP):
                shoot(Vec2.fromAngle(angle), MAGENTA)
            play("shoot")
        wait(ring_wait(), ring)

    # A spiral: a bullet for each arm, spread evenly round, at an angle
    # that turns a little further every time.
    def spiral():
        global spiral_angle
        if not alive:
            return
        if score >= SPIRAL_FROM:
            for arm in range(0, 360, 360 // SPIRAL_ARMS):
                shoot(Vec2.fromAngle(spiral_angle + arm), CYAN)
            spiral_angle = spiral_angle + SPIRAL_TURN
        wait(SPIRAL_WAIT, spiral)

    wait(1, aim)
    wait(1, ring)
    wait(1, spiral)

    # Every bullet runs this, every frame.
    @onUpdate("bullet")
    def check(bullet):
        if not alive:
            return

        if bullet.pos.dist(bean.pos) < HIT_DISTANCE:
            hit(bullet)
            return

        # Gone out of the arena? Then it is finished with. Left alone, it
        # would fly on for ever, and thousands of them would slow the game
        # down to a crawl.
        x = bullet.pos.x
        y = bullet.pos.y
        if x < -20 or x > arena_w + 20 or y < -20 or y > arena_h + 20:
            bullet.destroy()

    # ------------------------------------------------------------- points
    #
    # A point for every second you are still here.

    def tick():
        global score
        if not alive:
            return
        score = score + 1
        score_label.text = str(score)
        if score == 1:
            message.text = ""
        wait(1, tick)

    wait(1, tick)

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

    def hit(bullet):
        global alive

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

        # Bean goes, and so does every bullet. move() would keep them
        # flying, over the top of the message below.
        bean.destroy()
        destroyAll("bullet")

        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 →

  • Zoom out further. Make ZOOM 0.5. The arena is now 1600 by 1200, everything is drawn half size, and there's room for a lot more bullets. Nothing else needs changing, because everything is worked out from arena_w and arena_h.
  • A ring with a way out. In ring, put the shoot inside an if: if angle < 75 or angle > 105:. The angles 75, 90 and 105 are skipped, and every ring has a gap pointing straight down, where Bean usually is.
  • A spiral that turns the other way. Make SPIRAL_TURN -11. Then try 45: each turn lands exactly halfway between the arms of the one before, and the spiral turns into eight straight lines.
  • Now play Pong. Pong goes back to a ball that carries its own speed, the way Paddle Ball's did, and adds a second paddle that the computer moves.