Building Knight's Hall

Play it first → Monsters come through the doors of a hall, and a knight holds it with a sword. On a phone you play with your thumbs: a d-pad on the left, two buttons on the right. On a computer you use the arrow keys, space and z.

This is how it was built, a step at a time. Every numbered step below is a whole program: copy it into the playground, press Run, and it works. Each step is the one before it with something added, so you can build the game the way it was built. Then break something on purpose and see what changes. That teaches you more than reading does.

There is nothing clever in it. No list comprehensions, no tricks, and every kaypy function it uses is on the API page.

The one big idea: the buttons pretend to be keys

The d-pad and the buttons take one word, on the very first line:

kaypy(width=576, height=864, joystick=True)

That draws a d-pad in the bottom-left corner and two buttons in the bottom-right, and they pretend to be the keyboard. A thumb on the left of the d-pad is exactly the same as holding the left arrow. The two buttons are space and z. So nothing else in the game knows the buttons are there. It asks isKeyDown("left") and onKeyPress("space") like any other game, and a thumb answers.

Three things come with it for free:

  • Diagonals. A thumb between up and right holds both keys, so the game sees up and right together.
  • Two thumbs at once. Each finger is tracked on its own, so you can hold the d-pad and tap a button without letting go of the d-pad.
  • The mouse counts as a finger, so you can try it all on a computer by clicking on the d-pad.

The one thing you have to think about is where your thumbs go. They cover the bottom corners of the screen, so a monster down there is a monster you can't see. This game is tall, like a phone held upright: the hall is at the top, and underneath it is a strip that holds only the controls.

Step 1 — a hall, a knight and a d-pad

from kaypy import *

TILE = 48          # a tile is 48 pixels: the 16-pixel art, times 3

# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
      joystick=True)

IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
            "run": {"from": 4, "to": 7, "speed": 10, "loop": True}}

loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")

SPEED = 220        # pixels per second

HALL = [
    "^D^^^D^^^D^^",
    "#dd#BddB#dd#",
    "#..,....:..#",
    "#.:...,....#",
    "#....,...,.#",
    "#,.:.......#",
    "#.....:..,.#",
    "#..,.......#",
    "#.....,.:..#",
    "#.:.,......#",
    "#.......,..#",
    "############",
]

TILES = {
    "^": lambda: [sprite("wall_top"), scale(3)],
    "#": lambda: [sprite("wall"), scale(3)],
    "B": lambda: [sprite("banner"), scale(3)],
    "D": lambda: [sprite("door"), scale(3)],
    ".": lambda: [sprite("floor_1"), scale(3)],
    ",": lambda: [sprite("floor_2"), scale(3)],
    ":": lambda: [sprite("floor_3"), scale(3)],
}

# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11


@scene("hall")
def hall():
    addLevel(HALL, {"tileWidth": TILE, "tileHeight": TILE,
                    "pos": vec2(0, 0), "tiles": TILES})

    knight = add([
        sprite("knight", anim="idle"),
        pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
        anchor("center"),
        scale(2.5),
        area(),
        z(10),
        "knight",
    ])

    @onUpdate
    def walk():
        dx = 0
        dy = 0
        if isKeyDown("left"):
            dx = dx - 1
        if isKeyDown("right"):
            dx = dx + 1
        if isKeyDown("up"):
            dy = dy - 1
        if isKeyDown("down"):
            dy = dy + 1

        if dx != 0 or dy != 0:
            way = vec2(dx, dy).unit()
            knight.move(way.x * SPEED, way.y * SPEED)
            if dx != 0:
                knight.flipX = dx < 0
            if knight.curAnim() != "run":
                knight.play("run")
        elif knight.curAnim() != "idle":
            knight.play("idle")

        # Stay on the floor.
        knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
        knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)


go("hall")

A knight in a hall, walking in eight directions. On a phone, put your thumb on the d-pad. On a computer, use the arrow keys, or click and drag on the d-pad.

The window. A tile is 48 pixels, the 16-pixel dungeon art drawn three times as big. The window is 12 tiles across and 18 down: 12 for the hall and 6 under it for the controls.

The hall is a picture. Each letter in HALL is one tile, and TILES says what each letter turns into. addLevel walks the picture and builds them. There are three kinds of floor, . , and :, so it doesn't look like wallpaper. A door picture is two tiles wide and two tall, drawn from the top-left of the D's tile, so it covers three more tiles than its own. Those are the ds. They aren't in TILES, so nothing is drawn there. Put a # there instead and a piece of wall is drawn over a corner of the door.

Why a scene already? Everything goes inside hall(), and go("hall") at the bottom starts it. That doesn't do anything useful yet. In step 4 the knight can die, and go("hall") again is how the game starts over.

Walking. Every frame, walk asks which arrows are held and adds them up into dx (left is −1, right is +1) and dy (up is −1, down is +1). Up and right together make (1, −1), a diagonal.

Why .unit()? The arrow (1, 1) is longer than (1, 0), about 1.4 times longer. Multiply it by SPEED and the knight walks 1.4 times faster on a diagonal. Try it: change those two lines to knight.move(dx * SPEED, dy * SPEED). There's no error message, and in a test the knight went 140 pixels diagonally in the time it went 99 straight. .unit() keeps the direction but makes the length 1, so every direction is the same speed.

Staying in. There are no walls to bump into. Every frame clamp just puts the knight back inside the floor: LEFT, RIGHT, TOP and BOTTOM are its edges, in pixels.

Step 2 — facing, and the sword

from kaypy import *

TILE = 48          # a tile is 48 pixels: the 16-pixel art, times 3

# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
      joystick=True)

IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
            "run": {"from": 4, "to": 7, "speed": 10, "loop": True}}

loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")

SPEED = 220        # pixels per second
SWORD_REACH = 42   # how far in front of the knight the blade appears
SWORD_TIME = 0.15  # how long a swing lasts, in seconds

HALL = [
    "^D^^^D^^^D^^",
    "#dd#BddB#dd#",
    "#..,....:..#",
    "#.:...,....#",
    "#....,...,.#",
    "#,.:.......#",
    "#.....:..,.#",
    "#..,.......#",
    "#.....,.:..#",
    "#.:.,......#",
    "#.......,..#",
    "############",
]

TILES = {
    "^": lambda: [sprite("wall_top"), scale(3)],
    "#": lambda: [sprite("wall"), scale(3)],
    "B": lambda: [sprite("banner"), scale(3)],
    "D": lambda: [sprite("door"), scale(3)],
    ".": lambda: [sprite("floor_1"), scale(3)],
    ",": lambda: [sprite("floor_2"), scale(3)],
    ":": lambda: [sprite("floor_3"), scale(3)],
}

# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11

facing = (0, 1)    # which way the knight faces, as (x, y): (0, 1) is down

# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
    (0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
    (0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}


@scene("hall")
def hall():
    global facing
    facing = (0, 1)

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

    knight = add([
        sprite("knight", anim="idle"),
        pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
        anchor("center"),
        scale(2.5),
        area(),
        z(10),
        "knight",
    ])

    @onUpdate
    def walk():
        global facing

        dx = 0
        dy = 0
        if isKeyDown("left"):
            dx = dx - 1
        if isKeyDown("right"):
            dx = dx + 1
        if isKeyDown("up"):
            dy = dy - 1
        if isKeyDown("down"):
            dy = dy + 1

        if dx != 0 or dy != 0:
            facing = (dx, dy)
            way = vec2(dx, dy).unit()
            knight.move(way.x * SPEED, way.y * SPEED)
            if dx != 0:
                knight.flipX = dx < 0
            if knight.curAnim() != "run":
                knight.play("run")
        elif knight.curAnim() != "idle":
            knight.play("idle")

        # Stay on the floor.
        knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
        knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)

    @onKeyPress("space")
    def swing():
        if get("sword"):       # one swing at a time
            return
        add([
            sprite("sword"),
            pos(sword_spot()),
            anchor("center"),
            scale(2.5),
            rotate(SWORD_ANGLE[facing]),
            area(),
            lifespan(SWORD_TIME),
            z(11),
            "sword",
        ])

    def sword_spot():
        way = vec2(facing[0], facing[1]).unit()
        return knight.pos + way * SWORD_REACH

    @onUpdate("sword")
    def follow_knight(sword):
        sword.pos = sword_spot()


go("hall")

Press S (or space) and the sword flashes out the way you're facing, in any of the eight directions. Hold the d-pad with one thumb and tap S with the other. The knight keeps walking while it swings.

Which way you face. facing is a pair of numbers, (dx, dy), saved whenever you walk. It isn't changed when you stop, so the knight still faces the way it was last going.

Pointing the sword. The sword picture points straight up. SWORD_ANGLE is a dictionary that turns a direction into how far to turn the picture: up is 0, right is 90, and so on round the clock. rotate() turns it. A pair of numbers like (1, 1) can be a dictionary key because it's a tuple, which can't change.

Where the sword goes. sword_spot() is the knight's position plus SWORD_REACH pixels the way it faces. follow_knight puts the sword back there every frame, so it moves with you. lifespan(SWORD_TIME) destroys it after 0.15 seconds, and get("sword") stops a second swing until then: an empty list counts as False.

Break it on purpose. Take global facing out of walk. There's no error message. But now facing = (dx, dy) makes a new variable that only lives inside walk, and the one at the top of the file stays (0, 1). The sword always points down, whichever way you walk. Put it back, then delete (1, 1): 135, from SWORD_ANGLE and swing while walking down and right. The game stops with KeyError: (1, 1), pointing at rotate(SWORD_ANGLE[facing]). It means the dictionary has nothing for (1, 1).

Step 3 — goblins through the doors

from kaypy import *

TILE = 48          # a tile is 48 pixels: the 16-pixel art, times 3

# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
      joystick=True)

IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
            "run": {"from": 4, "to": 7, "speed": 10, "loop": True}}

loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")

SPEED = 220        # pixels per second
SWORD_REACH = 42   # how far in front of the knight the blade appears
SWORD_TIME = 0.15  # how long a swing lasts, in seconds
KNOCKBACK = 40     # how far a monster is shoved when the sword hits it
GOBLIN_SPEED = 80
SPAWN_GAP = 2      # seconds between goblins

HALL = [
    "^D^^^D^^^D^^",
    "#dd#BddB#dd#",
    "#..,....:..#",
    "#.:...,....#",
    "#....,...,.#",
    "#,.:.......#",
    "#.....:..,.#",
    "#..,.......#",
    "#.....,.:..#",
    "#.:.,......#",
    "#.......,..#",
    "############",
]

TILES = {
    "^": lambda: [sprite("wall_top"), scale(3)],
    "#": lambda: [sprite("wall"), scale(3)],
    "B": lambda: [sprite("banner"), scale(3)],
    "D": lambda: [sprite("door"), scale(3)],
    ".": lambda: [sprite("floor_1"), scale(3)],
    ",": lambda: [sprite("floor_2"), scale(3)],
    ":": lambda: [sprite("floor_3"), scale(3)],
}

# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11

# Where the monsters come in: just inside each door in the picture.
DOORS = []
for col in range(len(HALL[0])):
    if HALL[0][col] == "D":
        DOORS.append(vec2(col * TILE + TILE, TOP + 10))

facing = (0, 1)    # which way the knight faces, as (x, y): (0, 1) is down

# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
    (0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
    (0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}


@scene("hall")
def hall():
    global facing
    facing = (0, 1)

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

    knight = add([
        sprite("knight", anim="idle"),
        pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
        anchor("center"),
        scale(2.5),
        area(),
        z(10),
        "knight",
    ])

    @onUpdate
    def walk():
        global facing

        dx = 0
        dy = 0
        if isKeyDown("left"):
            dx = dx - 1
        if isKeyDown("right"):
            dx = dx + 1
        if isKeyDown("up"):
            dy = dy - 1
        if isKeyDown("down"):
            dy = dy + 1

        if dx != 0 or dy != 0:
            facing = (dx, dy)
            way = vec2(dx, dy).unit()
            knight.move(way.x * SPEED, way.y * SPEED)
            if dx != 0:
                knight.flipX = dx < 0
            if knight.curAnim() != "run":
                knight.play("run")
        elif knight.curAnim() != "idle":
            knight.play("idle")

        # Stay on the floor.
        knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
        knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)

    @onKeyPress("space")
    def swing():
        if get("sword"):       # one swing at a time
            return
        add([
            sprite("sword"),
            pos(sword_spot()),
            anchor("center"),
            scale(2.5),
            rotate(SWORD_ANGLE[facing]),
            area(),
            lifespan(SWORD_TIME),
            z(11),
            "sword",
        ])

    def sword_spot():
        way = vec2(facing[0], facing[1]).unit()
        return knight.pos + way * SWORD_REACH

    @onUpdate("sword")
    def follow_knight(sword):
        sword.pos = sword_spot()

    @onCollide("sword", "enemy")
    def sword_hits(sword, enemy):
        enemy.hurt(1)
        away = (enemy.pos - knight.pos).unit()
        enemy.pos = enemy.pos + away * KNOCKBACK

    def add_goblin():
        door = choose(DOORS)
        monster = add([
            sprite("goblin", anim="run"),
            pos(door.x, door.y),
            anchor("center"),
            scale(2.5),
            area(),
            health(1),
            z(5),
            "enemy",
        ])

        @monster.onUpdate
        def chase():
            toward = (knight.pos - monster.pos).unit()
            monster.move(toward.x * GOBLIN_SPEED, toward.y * GOBLIN_SPEED)
            monster.flipX = toward.x < 0
            monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
            monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)

        @monster.onDeath
        def beaten():
            addKaboom(monster.pos, scale=0.5)
            monster.destroy()

    loop(SPAWN_GAP, add_goblin)


go("hall")

Every two seconds a goblin comes in through one of the doors and heads straight for you. One hit of the sword and it's gone. They can't hurt you yet. That's step 4.

Where the doors are. The loop near the top looks along the first row of the picture for each D. A door is two tiles wide and starts at that column, so its middle is col * TILE + TILE. Move the Ds in the picture and the goblins come in through the new doors. Nothing else needs changing.

One every two seconds. loop(SPAWN_GAP, add_goblin) calls add_goblin every SPAWN_GAP seconds, forever. choose(DOORS) picks one of the doors at random.

Chasing. knight.pos - monster.pos is an arrow from the goblin to the knight. .unit() makes it one long, the same trick as step 1, so every goblin walks at the same speed however far away it is. Every goblin gets its own chase, made inside its own call of add_goblin. That's why monster inside it always means that goblin, even with ten of them on the floor.

The hit. onCollide("sword", "enemy") runs once, at the moment a sword starts touching a monster, so one swing is one hit. enemy.hurt(1) takes away one hit point. The goblin then gets shoved KNOCKBACK pixels straight away from the knight.

Break it on purpose. Take health(1), out of the goblin. The game stops when the first goblin is made, with AttributeError: GameObj has no attribute 'onDeath' — did you forget a component that provides it? onDeath, hurt and hp all come from the health() component. Without it, the goblin has none of them.

Step 4 — getting hurt

from kaypy import *

TILE = 48          # a tile is 48 pixels: the 16-pixel art, times 3

# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
      joystick=True)

IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
            "run": {"from": 4, "to": 7, "speed": 10, "loop": True}}

loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("heart_full", "dungeon/ui_heart_full.png")
loadSprite("heart_empty", "dungeon/ui_heart_empty.png")
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")

SPEED = 220        # pixels per second
HEARTS = 3
HURT_TIME = 1.2    # after a hit, how long before you can be hurt again
SWORD_REACH = 42   # how far in front of the knight the blade appears
SWORD_TIME = 0.15  # how long a swing lasts, in seconds
KNOCKBACK = 40     # how far a monster is shoved when the sword hits it
GOBLIN_SPEED = 80
SPAWN_GAP = 2      # seconds between goblins

HALL = [
    "^D^^^D^^^D^^",
    "#dd#BddB#dd#",
    "#..,....:..#",
    "#.:...,....#",
    "#....,...,.#",
    "#,.:.......#",
    "#.....:..,.#",
    "#..,.......#",
    "#.....,.:..#",
    "#.:.,......#",
    "#.......,..#",
    "############",
]

TILES = {
    "^": lambda: [sprite("wall_top"), scale(3)],
    "#": lambda: [sprite("wall"), scale(3)],
    "B": lambda: [sprite("banner"), scale(3)],
    "D": lambda: [sprite("door"), scale(3)],
    ".": lambda: [sprite("floor_1"), scale(3)],
    ",": lambda: [sprite("floor_2"), scale(3)],
    ":": lambda: [sprite("floor_3"), scale(3)],
}

# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11

# Where the monsters come in: just inside each door in the picture.
DOORS = []
for col in range(len(HALL[0])):
    if HALL[0][col] == "D":
        DOORS.append(vec2(col * TILE + TILE, TOP + 10))

facing = (0, 1)    # which way the knight faces, as (x, y): (0, 1) is down
hurt_left = 0      # seconds of safety left after being hit
game_over = False

# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
    (0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
    (0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}


@scene("hall")
def hall():
    global facing, hurt_left, game_over
    facing = (0, 1)
    hurt_left = 0
    game_over = False

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

    knight = add([
        sprite("knight", anim="idle"),
        pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
        anchor("center"),
        scale(2.5),
        area(),
        opacity(1),
        health(HEARTS),
        z(10),
        "knight",
    ])

    @onUpdate
    def walk():
        global facing, hurt_left

        if game_over:
            return

        if hurt_left > 0:
            hurt_left = hurt_left - dt()

        dx = 0
        dy = 0
        if isKeyDown("left"):
            dx = dx - 1
        if isKeyDown("right"):
            dx = dx + 1
        if isKeyDown("up"):
            dy = dy - 1
        if isKeyDown("down"):
            dy = dy + 1

        if dx != 0 or dy != 0:
            facing = (dx, dy)
            way = vec2(dx, dy).unit()
            knight.move(way.x * SPEED, way.y * SPEED)
            if dx != 0:
                knight.flipX = dx < 0
            if knight.curAnim() != "run":
                knight.play("run")
        elif knight.curAnim() != "idle":
            knight.play("idle")

        # Stay on the floor.
        knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
        knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)

        # Flicker while you are safe after a hit.
        if hurt_left > 0:
            knight.opacity = wave(0.2, 1, time() * 20)
        else:
            knight.opacity = 1

    @onKeyPress("space")
    def swing():
        if game_over:
            return
        if get("sword"):       # one swing at a time
            return
        add([
            sprite("sword"),
            pos(sword_spot()),
            anchor("center"),
            scale(2.5),
            rotate(SWORD_ANGLE[facing]),
            area(),
            lifespan(SWORD_TIME),
            z(11),
            "sword",
        ])

    def sword_spot():
        way = vec2(facing[0], facing[1]).unit()
        return knight.pos + way * SWORD_REACH

    @onUpdate("sword")
    def follow_knight(sword):
        sword.pos = sword_spot()

    @onCollide("sword", "enemy")
    def sword_hits(sword, enemy):
        enemy.hurt(1)
        away = (enemy.pos - knight.pos).unit()
        enemy.pos = enemy.pos + away * KNOCKBACK

    def add_goblin():
        door = choose(DOORS)
        monster = add([
            sprite("goblin", anim="run"),
            pos(door.x, door.y),
            anchor("center"),
            scale(2.5),
            area(),
            health(1),
            z(5),
            "enemy",
        ])

        @monster.onUpdate
        def chase():
            if game_over:
                return
            toward = (knight.pos - monster.pos).unit()
            monster.move(toward.x * GOBLIN_SPEED, toward.y * GOBLIN_SPEED)
            monster.flipX = toward.x < 0
            monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
            monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)

        @monster.onDeath
        def beaten():
            addKaboom(monster.pos, scale=0.5)
            monster.destroy()

    loop(SPAWN_GAP, add_goblin)

    @knight.onCollideUpdate("enemy")
    def touched(enemy):
        global hurt_left

        if game_over or hurt_left > 0:
            return
        knight.hurt(1)
        hurt_left = HURT_TIME
        shake(8)

    @knight.onDeath
    def knight_died():
        global game_over

        game_over = True
        addKaboom(knight.pos)
        knight.destroy()

        @say("The hall has fallen.", button="Play again")
        def again():
            go("hall")

    @onDraw
    def hearts():
        hp = 0
        if knight.exists():
            hp = knight.hp
        i = 0
        while i < HEARTS:
            if i < hp:
                name = "heart_full"
            else:
                name = "heart_empty"
            drawSprite(sprite=name, pos=vec2(width() - 150 + i * 46,
                                             TILE * 12 + 8),
                       width=39, height=36)
            i = i + 1


go("hall")

Now the goblins bite. You have three hearts, drawn under the hall. After a hit the knight flickers for a moment and can't be hurt again until it stops. Lose all three and the hall falls. Press Play again and it starts over.

Every frame, not just once. knight.onCollideUpdate("enemy") runs every frame that a monster is touching the knight. onCollide would only run at the moment they start touching, so a goblin that walked into you while you were flickering and then stayed there would never hurt you again. Try it: change it to knight.onCollide("enemy") and stand still. In a test, the knight standing still lasted almost twice as long.

A countdown. hurt_left is how many seconds of safety are left. A hit sets it to HURT_TIME, and every frame walk takes away dt(), how long that frame took. While it's above zero, touched does nothing, and wave() swings the knight's opacity up and down to make it flicker.

Starting over. When the knight's hit points reach zero, onDeath runs. game_over stops everything moving, and say shows a message. When it's closed, go("hall") runs hall() again from the top. That's why the start of hall() sets every variable back: the ones at the top of the file aren't run again, so without those lines the new game would start with the old game's game_over still True. The old scene's loop stops by itself, so the goblins don't start coming twice as fast.

The hearts are drawn every frame with drawSprite, full or empty, under the hall. A heart is a mark on the screen, not a thing in the hall, so it doesn't need to be a game object.

Step 5 — waves

from kaypy import *

TILE = 48          # a tile is 48 pixels: the 16-pixel art, times 3

# 12 tiles across. 18 down: 12 for the hall, 6 under it for the controls.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
      joystick=True)

IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
            "run": {"from": 4, "to": 7, "speed": 10, "loop": True}}

loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("imp", "dungeon/imp.png", sliceX=8, anims=IDLE_RUN)
loadSprite("orc", "dungeon/orc_warrior.png", sliceX=8, anims=IDLE_RUN)
loadSprite("heart_full", "dungeon/ui_heart_full.png")
loadSprite("heart_empty", "dungeon/ui_heart_empty.png")
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")

SPEED = 220        # pixels per second
HEARTS = 3
HURT_TIME = 1.2    # after a hit, how long before you can be hurt again
SWORD_REACH = 42   # how far in front of the knight the blade appears
SWORD_TIME = 0.15  # how long a swing lasts, in seconds
KNOCKBACK = 40     # how far a monster is shoved when the sword hits it
SPAWN_GAP = 0.8    # seconds between monsters coming through a door
WAVE_PAUSE = 2.0   # seconds of quiet between waves

MONSTERS = {
    "goblin": {"hp": 1, "speed": 80},
    "imp": {"hp": 1, "speed": 140},
    "orc": {"hp": 3, "speed": 60},
}

HALL = [
    "^D^^^D^^^D^^",
    "#dd#BddB#dd#",
    "#..,....:..#",
    "#.:...,....#",
    "#....,...,.#",
    "#,.:.......#",
    "#.....:..,.#",
    "#..,.......#",
    "#.....,.:..#",
    "#.:.,......#",
    "#.......,..#",
    "############",
]

TILES = {
    "^": lambda: [sprite("wall_top"), scale(3)],
    "#": lambda: [sprite("wall"), scale(3)],
    "B": lambda: [sprite("banner"), scale(3)],
    "D": lambda: [sprite("door"), scale(3)],
    ".": lambda: [sprite("floor_1"), scale(3)],
    ",": lambda: [sprite("floor_2"), scale(3)],
    ":": lambda: [sprite("floor_3"), scale(3)],
}

# The floor, in pixels: everything inside the walls.
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11

# Where the monsters come in: just inside each door in the picture.
DOORS = []
for col in range(len(HALL[0])):
    if HALL[0][col] == "D":
        DOORS.append(vec2(col * TILE + TILE, TOP + 10))

facing = (0, 1)    # which way the knight faces, as (x, y): (0, 1) is down
hurt_left = 0      # seconds of safety left after being hit
wave_number = 0
score = 0
queue = []         # the monsters still to come in this wave
spawn_wait = 0     # seconds until the next one comes through a door
game_over = False

# The sword picture points up, so up is 0 degrees, and the rest go round
# clockwise.
SWORD_ANGLE = {
    (0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
    (0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}


@scene("hall")
def hall():
    global facing, hurt_left, game_over
    global wave_number, score, queue, spawn_wait
    facing = (0, 1)
    hurt_left = 0
    wave_number = 0
    score = 0
    queue = []
    spawn_wait = WAVE_PAUSE
    game_over = False

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

    knight = add([
        sprite("knight", anim="idle"),
        pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
        anchor("center"),
        scale(2.5),
        area(),
        opacity(1),
        health(HEARTS),
        z(10),
        "knight",
    ])

    @onUpdate
    def walk():
        global facing, hurt_left

        if game_over:
            return

        if hurt_left > 0:
            hurt_left = hurt_left - dt()

        dx = 0
        dy = 0
        if isKeyDown("left"):
            dx = dx - 1
        if isKeyDown("right"):
            dx = dx + 1
        if isKeyDown("up"):
            dy = dy - 1
        if isKeyDown("down"):
            dy = dy + 1

        if dx != 0 or dy != 0:
            facing = (dx, dy)
            way = vec2(dx, dy).unit()
            knight.move(way.x * SPEED, way.y * SPEED)
            if dx != 0:
                knight.flipX = dx < 0
            if knight.curAnim() != "run":
                knight.play("run")
        elif knight.curAnim() != "idle":
            knight.play("idle")

        # Stay on the floor.
        knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
        knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)

        # Flicker while you are safe after a hit.
        if hurt_left > 0:
            knight.opacity = wave(0.2, 1, time() * 20)
        else:
            knight.opacity = 1

    @onKeyPress("space")
    def swing():
        if game_over:
            return
        if get("sword"):       # one swing at a time
            return
        add([
            sprite("sword"),
            pos(sword_spot()),
            anchor("center"),
            scale(2.5),
            rotate(SWORD_ANGLE[facing]),
            area(),
            lifespan(SWORD_TIME),
            z(11),
            "sword",
        ])

    def sword_spot():
        way = vec2(facing[0], facing[1]).unit()
        return knight.pos + way * SWORD_REACH

    @onUpdate("sword")
    def follow_knight(sword):
        sword.pos = sword_spot()

    @onCollide("sword", "enemy")
    def sword_hits(sword, enemy):
        enemy.hurt(1)
        away = (enemy.pos - knight.pos).unit()
        enemy.pos = enemy.pos + away * KNOCKBACK

    def add_monster(kind):
        details = MONSTERS[kind]
        door = choose(DOORS)
        monster = add([
            sprite(kind, anim="run"),
            pos(door.x, door.y),
            anchor("center"),
            scale(2.5),
            area(),
            health(details["hp"]),
            z(5),
            "enemy",
        ])

        speed = details["speed"] * rand(0.85, 1.15)

        @monster.onUpdate
        def chase():
            if game_over:
                return
            toward = (knight.pos - monster.pos).unit()
            monster.move(toward.x * speed, toward.y * speed)
            monster.flipX = toward.x < 0
            monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
            monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)

        @monster.onDeath
        def beaten():
            global score
            score = score + 1
            addKaboom(monster.pos, scale=0.5)
            monster.destroy()

    def next_wave():
        global wave_number, queue

        wave_number = wave_number + 1
        queue = []
        for i in range(wave_number + 2):
            queue.append("goblin")
        for i in range(wave_number - 1):
            queue.append("imp")
        for i in range(wave_number // 3):
            queue.append("orc")

    @onUpdate
    def waves():
        global spawn_wait

        if game_over:
            return
        spawn_wait = spawn_wait - dt()
        if spawn_wait > 0:
            return

        if len(queue) > 0:
            kind = choose(queue)
            queue.remove(kind)
            add_monster(kind)
            spawn_wait = SPAWN_GAP
        elif len(get("enemy")) == 0:
            next_wave()
            spawn_wait = WAVE_PAUSE

    @knight.onCollideUpdate("enemy")
    def touched(enemy):
        global hurt_left

        if game_over or hurt_left > 0:
            return
        knight.hurt(1)
        hurt_left = HURT_TIME
        shake(8)

    @knight.onDeath
    def knight_died():
        global game_over

        game_over = True
        addKaboom(knight.pos)
        knight.destroy()

        @say("The hall has fallen.\n\nYou reached wave "
             + str(wave_number) + " and beat " + str(score) + " monsters.",
             button="Play again")
        def again():
            go("hall")

    @onDraw
    def panel():
        drawText(text="WAVE " + str(wave_number),
                 pos=vec2(310, TILE * 12 + 10), size=24,
                 color=(240, 220, 160))
        drawText(text="SCORE " + str(score),
                 pos=vec2(310, TILE * 12 + 54), size=16,
                 color=(200, 200, 210))

        if len(queue) == 0 and len(get("enemy")) == 0 and not game_over:
            drawText(text="Wave " + str(wave_number + 1) + " is coming...",
                     pos=vec2(width() / 2 - 120, TOP + 190), size=24,
                     color=(255, 255, 255))

        hp = 0
        if knight.exists():
            hp = knight.hp
        i = 0
        while i < HEARTS:
            if i < hp:
                name = "heart_full"
            else:
                name = "heart_empty"
            drawSprite(sprite=name, pos=vec2(width() - 150 + i * 46,
                                             TILE * 12 + 8),
                       width=39, height=36)
            i = i + 1


go("hall")

Now the monsters come in waves, and each wave is bigger than the last. Imps arrive from wave 2: they're fast. Orcs arrive from wave 3, one more every three waves: they're slow, but each takes three hits. The wave number and the score are drawn above the buttons.

Three kinds, one dictionary. MONSTERS holds each kind's hit points and speed. add_monster(kind) looks them up, and the sprite has the same name as the kind. Each monster also gets a speed a little above or below the usual (rand(0.85, 1.15)), so a wave spreads out instead of walking in as one lump.

The wave is a list. next_wave fills queue with the names of every monster in the wave: wave 1 is three goblins, wave 2 is four goblins and an imp, wave 3 adds an orc. Every SPAWN_GAP seconds, waves picks one at random with choose, takes it out of the list with remove, and lets it in. When the list is empty and the floor is clear, the next wave starts after a short pause.

No more loop. A loop goes on forever, and here the gap depends on what's happening: short between monsters, longer between waves. So spawn_wait is a countdown, like hurt_left.

Break it on purpose. Take global spawn_wait out of waves. The game stops at once, with UnboundLocalError: cannot access local variable 'spawn_wait' where it is not associated with a value. Python sees spawn_wait = ... in the function and decides spawn_wait must be a new variable of its own. Then the right side asks for its value before it has one. It's the same mistake as in step 2, but this time there's an error message, because here the variable is read before it's set.

Step 6 — the whole game

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

  • The dash, on the second button. For 0.18 seconds the knight shoots forward the way it's facing and can't be hurt, so you can dash through a monster. Then it has to wait a second before it can dash again, and the word DASH goes grey until it can. The dash speed was 700 at first, and in a test a dash through a goblin ended with the knight still on top of it, and it got hurt anyway. At 900 the knight comes out the other side.
  • Healing flasks. Now and then a monster leaves one behind. Walk over it for a heart back (never more than three). It fades away after eight seconds. Fading needs opacity(1) on the flask. Without it, kaypy prints
    note: lifespan(fade=...) needs an opacity() component to fade — add opacity(1) to this object, or drop the fade argument.
    and the flask just vanishes.
  • Your best score, kept with setData and read back with getData, so it's still there next time.
  • Sounds for swinging, hitting, dashing, getting hurt, drinking and a new wave.
  • Labels for the buttons. kaypy labels each button with the first letter of its key, S and Z. The game writes SWORD and DASH under them, so you know what they're for.
  • A message at the start saying how to play.

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

"""Knight's Hall -- hold the hall against wave after wave of monsters.

    d-pad / arrow keys   walk, in eight directions
    S button / space     swing your sword the way you are facing
    Z button / z         dash -- you cannot be hurt while you dash

Made for a phone held upright, like a handheld game: the hall at the top,
the d-pad and the two buttons underneath it, where your thumbs are. On a
computer the arrow keys, space and z do exactly the same thing.

HOW THE BUTTONS GET ON THE SCREEN

One word, on the first line of the program:

    kaypy(width=576, height=864, joystick=True)

joystick=True draws a d-pad and two buttons, and they PRETEND TO BE KEYS.
Holding the left of the d-pad is the left arrow being held down; the two
buttons are space and z. So nothing else in this file knows about the
buttons at all. It asks isKeyDown("left") and onKeyPress("space"), the same
as any game, and a thumb answers.

HOW THIS FILE IS PUT TOGETHER

    1. the window and the pictures
    2. the settings
    3. the hall, drawn as a picture made of letters
    4. what the game has to remember
    5. the knight: walking, the sword, the dash, getting hurt
    6. the monsters, and the waves they come in
    7. the score, the hearts and the button labels
    8. start the game
"""

from kaypy import *

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

TILE = 48          # a tile is 48 pixels square: the 16-pixel art, times 3

# The hall is 12 tiles square, and the window is taller than that. The
# extra 6 tiles at the bottom are where the d-pad and the buttons go, so a
# thumb on the controls never covers a monster.
kaypy(width=TILE * 12, height=TILE * 18, background=[20, 17, 26],
      joystick=True)

# Sprites. Every path is written out in full, on purpose: the playground
# reads this file for the names of the pictures it needs, and it only finds
# them if they are plain text.
IDLE_RUN = {"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
            "run": {"from": 4, "to": 7, "speed": 10, "loop": True}}

loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims=IDLE_RUN)
loadSprite("goblin", "dungeon/goblin.png", sliceX=8, anims=IDLE_RUN)
loadSprite("imp", "dungeon/imp.png", sliceX=8, anims=IDLE_RUN)
loadSprite("orc", "dungeon/orc_warrior.png", sliceX=8, anims=IDLE_RUN)
loadSprite("sword", "dungeon/weapon_regular_sword.png")
loadSprite("flask", "dungeon/flask_red.png")
loadSprite("heart_full", "dungeon/ui_heart_full.png")
loadSprite("heart_empty", "dungeon/ui_heart_empty.png")

loadSprite("floor_1", "dungeon/floor_1.png")
loadSprite("floor_2", "dungeon/floor_2.png")
loadSprite("floor_3", "dungeon/floor_3.png")
loadSprite("wall_top", "dungeon/wall_top_mid.png")
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("banner", "dungeon/wall_banner_red.png")
loadSprite("door", "dungeon/doors_leaf_open.png")

loadSound("swing", "sounds/swing.wav")
loadSound("hit", "sounds/thump.wav")
loadSound("dash", "sounds/swoosh.wav")
loadSound("hurt", "sounds/lazer_punch.wav")
loadSound("drink", "sounds/small_beep.wav")
loadSound("wave", "sounds/rising_beep.wav")
loadSound("boom", "sounds/small_boom.wav")


# --------------------------------------------------------- 2. the settings

SPEED = 220             # how fast the knight walks, pixels per second
HEARTS = 3

SWORD_REACH = 42        # how far in front of the knight the blade appears
SWORD_TIME = 0.15       # how long a swing lasts, in seconds
KNOCKBACK = 40          # how far a monster is shoved when the sword hits it

DASH_SPEED = 900
DASH_TIME = 0.18        # how long a dash lasts
DASH_RECHARGE = 1.0     # how long until you can dash again
HURT_TIME = 1.2         # after a hit, how long before you can be hurt again

SPAWN_GAP = 0.8         # seconds between monsters coming through a door
WAVE_PAUSE = 2.0        # seconds of quiet between waves
FLASK_CHANCE = 0.1      # how likely a monster is to drop a healing flask

# The three kinds of monster. Each one is a dictionary, so adding a fourth
# is one more line here (and a loadSprite above).
MONSTERS = {
    "goblin": {"hp": 1, "speed": 80},
    "imp": {"hp": 1, "speed": 140},
    "orc": {"hp": 3, "speed": 60},
}

BEST_KEY = "knights_hall_best"


# ----------------------------------------------------------- 3. the hall
#
# One letter is one tile.
#
#   ^  top of the wall     #  wall      B  wall with a banner
#   D  a doorway, two tiles wide and two tall. The 'd's beside and under
#      it are the rest of the same door, so they draw nothing.
#   .  ,  :  three kinds of floor, so it does not look like wallpaper
#
# The monsters come in through the doors, so a door can be moved by moving
# its letters -- nothing in the code knows where they are.

HALL = [
    "^D^^^D^^^D^^",
    "#dd#BddB#dd#",
    "#..,....:..#",
    "#.:...,....#",
    "#....,...,.#",
    "#,.:.......#",
    "#.....:..,.#",
    "#..,.......#",
    "#.....,.:..#",
    "#.:.,......#",
    "#.......,..#",
    "############",
]

# A door is drawn from the top-left of its tile, and the picture is two
# tiles wide and two tall, so it covers three more tiles than its own. Those
# are the 'd's. Put a '#' there instead and that wall gets drawn over a
# corner of the door.

TILES = {
    "^": lambda: [sprite("wall_top"), scale(3)],
    "#": lambda: [sprite("wall"), scale(3)],
    "B": lambda: [sprite("banner"), scale(3)],
    "D": lambda: [sprite("door"), scale(3)],
    ".": lambda: [sprite("floor_1"), scale(3)],
    ",": lambda: [sprite("floor_2"), scale(3)],
    ":": lambda: [sprite("floor_3"), scale(3)],
}

# The floor, in pixels: everything inside the walls. Nothing that walks may
# leave it. There are no walls to bump into -- each frame the knight and the
# monsters are simply put back inside this box with clamp().
LEFT = TILE * 1
RIGHT = TILE * 11
TOP = TILE * 2
BOTTOM = TILE * 11

# Where the monsters appear: just inside each door. Worked out from the
# picture, so they move with the doors.
DOORS = []
for col in range(len(HALL[0])):
    if HALL[0][col] == "D":
        DOORS.append(vec2(col * TILE + TILE, TOP + 10))


# --------------------------------------------- 4. what the game remembers
#
# These change while you play, so any function that CHANGES one has to say
# `global` first. They all go back to the start in the scene below.

facing = (0, 1)     # which way the knight faces, as (x, y): (0, 1) is down
dash_left = 0       # seconds of dash still to go
dash_wait = 0       # seconds until the next dash is allowed
hurt_left = 0       # seconds of safety left after being hit
wave_number = 0     # not called `wave`: that would hide kaypy's wave()
score = 0
queue = []          # the monsters still to come in this wave
spawn_wait = 0      # seconds until the next one comes through a door
game_over = False

best = getData(BEST_KEY, 0)

# The way the sword points for each of the eight ways the knight can face.
# The picture of the sword points straight up, so up is 0 degrees and the
# rest go round clockwise.
SWORD_ANGLE = {
    (0, -1): 0, (1, -1): 45, (1, 0): 90, (1, 1): 135,
    (0, 1): 180, (-1, 1): 225, (-1, 0): 270, (-1, -1): 315,
}


@scene("hall")
def hall():
    global facing, dash_left, dash_wait, hurt_left
    global wave_number, score, queue, spawn_wait, game_over

    facing = (0, 1)
    dash_left = 0
    dash_wait = 0
    hurt_left = 0
    wave_number = 0
    score = 0
    queue = []
    spawn_wait = WAVE_PAUSE
    game_over = False

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

    # ------------------------------------------------------- 5. the knight

    knight = add([
        sprite("knight", anim="idle"),
        pos((LEFT + RIGHT) / 2, (TOP + BOTTOM) / 2),
        anchor("center"),
        scale(2.5),
        area(),
        opacity(1),
        health(HEARTS),
        z(10),
        "knight",
    ])

    @onUpdate
    def walk():
        global facing, dash_left, dash_wait, hurt_left

        if game_over:
            return

        # Count the timers down. dt() is how long this frame took, so
        # taking it off every frame counts real seconds.
        if dash_left > 0:
            dash_left = dash_left - dt()
        if dash_wait > 0:
            dash_wait = dash_wait - dt()
        if hurt_left > 0:
            hurt_left = hurt_left - dt()

        # Which arrows are held. The d-pad holds these too, and a thumb
        # between two arrows holds both -- that is a diagonal.
        dx = 0
        dy = 0
        if isKeyDown("left"):
            dx = dx - 1
        if isKeyDown("right"):
            dx = dx + 1
        if isKeyDown("up"):
            dy = dy - 1
        if isKeyDown("down"):
            dy = dy + 1

        if dash_left > 0:
            # A dash goes the way you were facing when it started, fast,
            # whatever you do with the d-pad meanwhile.
            way = vec2(facing[0], facing[1]).unit()
            knight.move(way.x * DASH_SPEED, way.y * DASH_SPEED)
        elif dx != 0 or dy != 0:
            facing = (dx, dy)
            # unit() makes the arrow one long whichever way it points.
            # Without it, (1, 1) is about 1.4 long and the knight would
            # walk faster diagonally than straight.
            way = vec2(dx, dy).unit()
            knight.move(way.x * SPEED, way.y * SPEED)
            if dx != 0:
                knight.flipX = dx < 0
            if knight.curAnim() != "run":
                knight.play("run")
        elif knight.curAnim() != "idle":
            knight.play("idle")

        # Stay on the floor.
        knight.pos.x = clamp(knight.pos.x, LEFT + 20, RIGHT - 20)
        knight.pos.y = clamp(knight.pos.y, TOP, BOTTOM - 35)

        # See-through while dashing, flickering while safe after a hit, and
        # solid the rest of the time.
        if dash_left > 0:
            knight.opacity = 0.4
        elif hurt_left > 0:
            knight.opacity = wave(0.2, 1, time() * 20)
        else:
            knight.opacity = 1

    # ---- the sword

    @onKeyPress("space")
    def swing():
        if game_over or isShowing():
            return
        # One swing at a time. get() gives back a list, and an empty list
        # counts as False, so this asks "is a sword out already?"
        if get("sword"):
            return
        add([
            sprite("sword"),
            pos(sword_spot()),
            anchor("center"),
            scale(2.5),
            rotate(SWORD_ANGLE[facing]),
            area(),
            lifespan(SWORD_TIME),
            z(11),
            "sword",
        ])
        play("swing")

    def sword_spot():
        way = vec2(facing[0], facing[1]).unit()
        return knight.pos + way * SWORD_REACH

    @onUpdate("sword")
    def follow_knight(sword):
        # The sword stays in the knight's hand as the knight moves.
        sword.pos = sword_spot()

    @onCollide("sword", "enemy")
    def sword_hits(sword, enemy):
        enemy.hurt(1)
        play("hit")
        # Shove it away from the knight, so one monster cannot stand on you.
        away = (enemy.pos - knight.pos).unit()
        enemy.pos = enemy.pos + away * KNOCKBACK

    # ---- the dash

    @onKeyPress("z")
    def dash():
        global dash_left, dash_wait

        if game_over or isShowing():
            return
        if dash_wait > 0:
            return
        dash_left = DASH_TIME
        dash_wait = DASH_RECHARGE
        play("dash")

    # ---- getting hurt

    # onCollideUpdate, not onCollide: onCollide only happens at the moment
    # two things START touching. A goblin that walks into you while you are
    # safe after a hit, and then just stays there, would never hurt you
    # again. This one asks every frame they are touching.
    @knight.onCollideUpdate("enemy")
    def touched(enemy):
        global hurt_left

        if game_over or dash_left > 0 or hurt_left > 0:
            return
        knight.hurt(1)
        hurt_left = HURT_TIME
        shake(8)
        play("hurt")

    @knight.onDeath
    def knight_died():
        global game_over, best

        game_over = True
        play("boom")
        addKaboom(knight.pos)
        knight.destroy()

        if score > best:
            best = score
            setData(BEST_KEY, best)

        @say("The hall has fallen.\n\nYou reached wave "
             + str(wave_number) + " and beat " + str(score) + " monsters.",
             button="Play again")
        def again():
            go("hall")

    @knight.onCollide("flask")
    def drink(flask):
        flask.destroy()
        knight.heal(1)
        play("drink")

    # ------------------------------------------------------ 6. the monsters

    def add_monster(kind):
        details = MONSTERS[kind]
        door = choose(DOORS)
        monster = add([
            sprite(kind, anim="run"),
            pos(door.x, door.y),
            anchor("center"),
            scale(2.5),
            area(),
            health(details["hp"]),
            z(5),
            "enemy",
        ])

        # Not quite all the same speed, so they spread out instead of
        # walking in one lump.
        speed = details["speed"] * rand(0.85, 1.15)

        # Every monster gets its own chase(), made fresh inside this call of
        # add_monster(). So `monster` and `speed` in it mean THIS monster and
        # THIS speed, even when there are ten of them.
        @monster.onUpdate
        def chase():
            if game_over:
                return
            toward = (knight.pos - monster.pos).unit()
            monster.move(toward.x * speed, toward.y * speed)
            monster.flipX = toward.x < 0
            monster.pos.x = clamp(monster.pos.x, LEFT + 16, RIGHT - 16)
            monster.pos.y = clamp(monster.pos.y, TOP, BOTTOM - 20)

        @monster.onDeath
        def beaten():
            global score
            score = score + 1
            addKaboom(monster.pos, scale=0.5)
            if chance(FLASK_CHANCE):
                # opacity(1) is what lets lifespan() fade it out. Without
                # it kaypy prints a note and the flask just vanishes.
                add([sprite("flask"), pos(monster.pos), anchor("center"),
                     scale(2.5), area(), opacity(1), lifespan(8, fade=1),
                     z(4), "flask"])
            monster.destroy()

    def next_wave():
        global wave_number, queue

        wave_number = wave_number + 1
        # More goblins every wave, imps from wave 2, an orc every third.
        queue = []
        for i in range(wave_number + 2):
            queue.append("goblin")
        for i in range(wave_number - 1):
            queue.append("imp")
        for i in range(wave_number // 3):
            queue.append("orc")
        play("wave")

    @onUpdate
    def waves():
        global spawn_wait

        if game_over:
            return
        spawn_wait = spawn_wait - dt()
        if spawn_wait > 0:
            return

        if len(queue) > 0:
            # Let the next one in, chosen at random from those left.
            kind = choose(queue)
            queue.remove(kind)
            add_monster(kind)
            spawn_wait = SPAWN_GAP
        elif len(get("enemy")) == 0:
            # Everyone beaten and nobody waiting: the next wave.
            next_wave()
            spawn_wait = WAVE_PAUSE

    # ----------------------------------------- 7. the score, hearts, labels
    #
    # The bottom of the window, under the hall, is the control panel. The
    # d-pad and buttons are drawn there by kaypy, on top of everything; all
    # this draws is the writing around them.

    PANEL = TILE * 12

    @onDraw
    def panel():
        # Everything is on the right, above the buttons. The d-pad takes up
        # the whole of the left.
        drawText(text="WAVE " + str(wave_number), pos=vec2(310, PANEL + 10),
                 size=24, color=(240, 220, 160))
        drawText(text="SCORE " + str(score) + "   BEST " + str(best),
                 pos=vec2(310, PANEL + 54), size=16, color=(200, 200, 210))

        # A full heart for every hit you can still take.
        hp = 0
        if knight.exists():
            hp = knight.hp
        i = 0
        while i < HEARTS:
            if i < hp:
                name = "heart_full"
            else:
                name = "heart_empty"
            drawSprite(sprite=name, pos=vec2(width() - 150 + i * 46,
                                             PANEL + 8),
                       width=39, height=36)
            i = i + 1

        # What the two buttons do. kaypy labels them with their keys, S and
        # Z, so the game says what they are FOR.
        drawText(text="SWORD", pos=vec2(width() - 130, PANEL + 242),
                 size=16, color=(200, 200, 210))
        if dash_wait > 0:
            dash_colour = (110, 110, 120)
        else:
            dash_colour = (200, 200, 210)
        drawText(text="DASH", pos=vec2(width() - 243, PANEL + 186),
                 size=16, color=dash_colour)

        # A note about the next wave, while it is waiting to start.
        if len(queue) == 0 and len(get("enemy")) == 0 and not game_over:
            drawText(text="Wave " + str(wave_number + 1) + " is coming...",
                     pos=vec2(width() / 2 - 120, TOP + 190), size=24,
                     color=(255, 255, 255))


# ------------------------------------------------------- 8. start the game

go("hall")

say("Monsters are coming through the doors.\n\n"
    "Walk with the d-pad. S swings your sword the way you are facing. "
    "Z dashes, and you can't be hurt while you dash.\n\n"
    "On a computer: arrow keys, space and z.", button="Play")

Make it yours

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

  • A new monster. Add a line to MONSTERS, say "skelet": {"hp": 2, "speed": 100},, load its picture with loadSprite("skelet", "dungeon/skelet.png", sliceX=8, anims=IDLE_RUN), and put some in the wave in next_wave. Forget the loadSprite and the first skeleton stops the game with
    KeyError: 'no sprite called \'skelet\' — loadSprite("skelet", ...) must run before it is used'
  • A different hero. The elves, wizards, dwarves and the other knight all have the same nine frames as the knight, so changing "dungeon/knight_m.png" to "dungeon/elf_f.png" is all it takes.
  • A different button. joystick=["space", "x"] makes the second button send x instead of z, and it says X. Change onKeyPress("z") to onKeyPress("x") as well, or the button does nothing.
  • Move the doors. Move the Ds and ds in HALL. The monsters follow.
  • Harder or easier. HEARTS, HURT_TIME, SPAWN_GAP and the speeds in MONSTERS are where to start.