Building Dungeon Dash
Play it first → Five rooms and a boss. Collect every coin in a room and the door opens; each door gives you a new move, and the next room has a coin you cannot reach without it.
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. Then break something on purpose and see what changes — that teaches you more than reading does.
The one big idea: a level is a picture
The hardest part of a platform game sounds like it should be the maths. It isn't. It's that a level has a hundred walls in it, and typing a hundred positions is miserable and impossible to change.
So a level here is a picture made of letters. One letter is one tile:
LEVEL_1 = [
"####################",
"# #",
"# o D#",
"# #######",
"# o #",
"# ##### #",
"# o# #",
"# # #",
"# ### #",
"# # #",
"# P o g #",
"####################",
]
# is a wall, o is a coin, P is where
you start, D is the door and g is a goblin. Moving a
coin means moving an o. Nothing else in the game has to be told
about it.
Step 1 — a room
addLevel() walks the picture. Every time it meets a letter
it looks it up in tiles and builds whatever it finds there. A
letter that isn't listed is ignored, which is why a space does nothing.
from kaypy import *
TILE = 48
kaypy(width=TILE * 20, height=TILE * 13, background=[22, 20, 30])
setGravity(2400)
loadSprite("wall", "dungeon/wall_mid.png")
ROOM = [
"####################",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"####################",
]
addLevel(ROOM, {
"tileWidth": TILE,
"tileHeight": TILE,
"tiles": {
"#": lambda: [sprite("wall"), scale(3), area(), body(isStatic=True)],
},
})
Run it and you get an empty stone room. body(isStatic=True)
is what makes a wall solid and immovable — without it the walls fall.
Step 2 — a knight who runs and jumps
The player is a tile too. Put a P in the picture, add it
to tiles, and moving where you start means moving that letter.
The arrow keys are the fiddly part, and not for the reason you'd expect.
The obvious way is onKeyDown, which asks the computer "is right
held down right now?" every frame. That works — until something
pauses the game, which it will in step 4. So the game keeps its own note
instead: pressed sets it, released clears it.
from kaypy import *
TILE = 48
SPEED = 270
JUMP = 850
kaypy(width=TILE * 20, height=TILE * 13, background=[22, 20, 30])
setGravity(2400)
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims={
"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True},
})
ROOM = [
"####################",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #### #",
"# #",
"# #### #",
"# #",
"# #",
"# P #",
"####################",
]
addLevel(ROOM, {
"tileWidth": TILE,
"tileHeight": TILE,
"tiles": {
"#": lambda: [sprite("wall"), scale(3), area(), body(isStatic=True)],
"P": lambda: [sprite("knight"), scale(3), area(),
body(jumpForce=JUMP), anchor("bot"), "player"],
},
})
player = get("player")[0]
holding_left = False
holding_right = False
@onKeyPress("left")
def press_left():
global holding_left
holding_left = True
@onKeyRelease("left")
def let_go_left():
global holding_left
holding_left = False
@onKeyPress("right")
def press_right():
global holding_right
holding_right = True
@onKeyRelease("right")
def let_go_right():
global holding_right
holding_right = False
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(JUMP)
@onUpdate
def each_frame():
if holding_left:
player.move(-SPEED, 0)
player.flipX = True
if holding_right:
player.move(SPEED, 0)
player.flipX = False
Try it: change JUMP to 400 and see how
little you can reach. Change setGravity to 800 and float.
Step 3 — coins, and a door that waits
This is the whole game in miniature. The door exists from the start; it just refuses to work until the count is met.
from kaypy import *
TILE = 48
SPEED = 270
JUMP = 850
kaypy(width=TILE * 20, height=TILE * 13, background=[22, 20, 30])
setGravity(2400)
loadSprite("wall", "dungeon/wall_mid.png")
loadSprite("coin", "dungeon/dungeon_coin.png", sliceX=4, anims={
"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
})
loadSprite("door_shut", "dungeon/doors_leaf_closed.png")
loadSprite("door_open", "dungeon/doors_leaf_open.png")
loadSound("ding", "sounds/small_beep.wav")
loadSprite("knight", "dungeon/knight_m.png", sliceX=9, anims={
"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True},
})
ROOM = [
"####################",
"# #",
"# #",
"# #",
"# #",
"# #",
"# o #",
"# #### #",
"# o D#",
"# #### #####",
"# #",
"# P o #",
"####################",
]
addLevel(ROOM, {
"tileWidth": TILE,
"tileHeight": TILE,
"tiles": {
"#": lambda: [sprite("wall"), scale(3), area(), body(isStatic=True)],
"P": lambda: [sprite("knight"), scale(3), area(),
body(jumpForce=JUMP), anchor("bot"), "player"],
"o": lambda: [sprite("coin"), scale(3), area(), anchor("center"),
"coin"],
"D": lambda: [sprite("door_shut"), scale(1.5), area(), anchor("bot"),
"door"],
},
})
player = get("player")[0]
holding_left = False
holding_right = False
@onKeyPress("left")
def press_left():
global holding_left
holding_left = True
@onKeyRelease("left")
def let_go_left():
global holding_left
holding_left = False
@onKeyPress("right")
def press_right():
global holding_right
holding_right = True
@onKeyRelease("right")
def let_go_right():
global holding_right
holding_right = False
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(JUMP)
@onUpdate
def each_frame():
if holding_left:
player.move(-SPEED, 0)
player.flipX = True
if holding_right:
player.move(SPEED, 0)
player.flipX = False
coins_collected = 0
total_coins = len(get("coin"))
label = add([text("0 / " + str(total_coins), size=24), pos(16, 14), fixed()])
@player.onCollide("coin")
def got_coin(coin):
global coins_collected
coin.destroy()
coins_collected = coins_collected + 1
label.text = str(coins_collected) + " / " + str(total_coins)
play("ding")
# The last coin opens the door.
if coins_collected >= total_coins:
for door in get("door"):
door.use(sprite("door_open"))
@player.onCollide("door")
def reached_door(door):
# The door is there the whole time. It only works once you have them all.
if coins_collected < total_coins:
return
say("You got them all!")
coins_collected is an ordinary variable at the top of the
file, and the function that changes it says global first — that
is Python's way of saying "the one at the top, not a new one of my own".
Step 4 — five rooms instead of one
Five levels do not mean five copies of the code. They mean five
pictures and one function. scene gives that function a name and
go runs it, with the level number as an argument:
LEVELS = [LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4, LEVEL_5]
@scene("play")
def build_level(number):
picture = LEVELS[number]
addLevel(picture, {
"tileWidth": TILE,
"tileHeight": TILE,
"tiles": TILES,
})
...
go("play", 0)
And the door moves you on:
@player.onCollide("door")
def reached_door(door):
global level_over
if coins_collected < total_coins:
return
level_over = True
say("Level " + str(number + 1) + " done!",
then=lambda answer: go("play", number + 1))
Careful with the name. The scene
is called "play", but the function must not be, because kaypy
already has a play() for sounds. Calling the function
play quietly replaced it, and every play("ding") in
the game started calling the scene instead — with "ding" as the
level number. It died on LEVELS[number] with
TypeError: list indices must be integers, pointing at a line
nowhere near the mistake.
Step 5 — a new move every room
Which moves you have depends on nothing but which level you are on:
can_double_jump = number >= 1
can_dash = number >= 2
can_wall_jump = number >= 3
and the jump key does whichever of them applies:
@onKeyPress("space")
def jump():
global jumps_left, wall_left
if level_over:
return
# On the ground: an ordinary jump, and your air jump comes back.
if player.isGrounded():
player.jump(JUMP)
jumps_left = 1
return
# Next to a wall: push off it, away from the way you are facing,
# and turn round. The little bit of time in wall_left is
# deliberate -- without it you would have to press space on the
# exact frame you touch the wall, which nobody can do.
if can_wall_jump and wall_left > 0:
player.jump(JUMP)
if player.flipX:
player.move(SPEED * 2, 0)
else:
player.move(-SPEED * 2, 0)
player.flipX = not player.flipX
wall_left = 0
play("swoosh")
return
# In the air with a jump left: the double jump.
if can_double_jump and jumps_left > 0:
player.jump(JUMP)
jumps_left = jumps_left - 1
The dash is just "move very fast for a moment", and the wall jump is
"push away from the way you're facing". Neither needs to know which way is
which, because player.flipX is already true when the sprite faces
left — that is which way you are facing, and keeping a second copy of
it would only give you something that can disagree with the screen.
Step 6 — things that move
Each enemy gets its own onUpdate, which runs every frame
for that one enemy. The goblin's whole patrol is one call to
wave(), which swings a number between two values forever:
for goblin in get("goblin"):
@goblin.onUpdate
def patrol(g=goblin, home=goblin.pos.x):
# g=goblin is not a typo. Without it every one of these
# functions would share the SAME goblin -- whichever one the
# loop finished on -- and all the others would stand still.
#
# wave() swings a number between two values forever, so this one
# line is the whole patrol. It is the same function the moving
# platforms use.
left = home - GOBLIN_RANGE * TILE
right = home + GOBLIN_RANGE * TILE
g.pos.x = wave(left, right, time() * GOBLIN_SPEED)
# Which way to face: work out where it will be in a moment, and
# if that is further left than it is now, it is walking left.
soon = wave(left, right, (time() + 0.05) * GOBLIN_SPEED)
g.flipX = soon < g.pos.x
g=goblin is not a typo. Without it every one of those
functions would share the same goblin — whichever one the loop
finished on — and all the others would stand still. home=goblin.pos.x
works the same way: it is worked out once, now, and the function keeps it.
The boss works on a rhythm rather than remembered timers. The whole fight
is "how far through the current cycle are we", which time()
answers on its own:
point = time() % BOSS_CYCLE
if point < BOSS_WAIT:
# Walking. This is the only part that chooses a direction,
# so the charge below simply keeps going the way it ended up
# facing.
b.play("run")
b.opacity = 1
b.flipX = player.pos.x < b.pos.x
if b.flipX:
b.move(-BOSS_WALK, 0)
else:
b.move(BOSS_WALK, 0)
elif point < BOSS_WAIT + BOSS_TELL:
# The warning. It stands still and flashes, so the charge can
# be learned instead of being a surprise.
b.play("idle")
b.opacity = 0.4 + 0.6 * abs(wave(-1, 1, time() * 20))
else:
# The charge, straight ahead, fast.
b.play("run")
b.opacity = 1
if b.flipX:
b.move(-BOSS_CHARGE, 0)
else:
b.move(BOSS_CHARGE, 0)
Four things that went wrong
None of these produced an error message. That is what makes them worth reading.
The player is taller than a tile
The knight is 28 pixels drawn at scale 3, so 84 tall against 48-pixel tiles — one and three quarter tiles. Standing on the floor its head is up in the tile above, so a gap one tile high is a wall it cannot walk through. Level 1 shipped with the start directly under a platform: pressing right moved the player four pixels and the collision resolver put it straight back, every frame. It looked exactly like the arrow keys not working.
A speed that was not a speed
The goblin's patrol was changed to use wave(), and the old
GOBLIN_SPEED = 90 came along with it. But wave()'s
third argument is an angle, not pixels per second — so 90 meant
fourteen round trips a second. The goblin became a blur. The test that was
supposed to catch it measured how far the goblin moved, which was
correct the whole time, and never how fast.
A clock that starts again
The dash was stored as a deadline: dash_until = time() + 0.16,
checked each frame against time(). But time() restarts at
zero on a new level. Dash 25 seconds into level 3 and you leave behind
a deadline of 25.16; the next level's clock starts at 0, so the dash runs for
the next 25 seconds. Both timers are countdowns now —
dash_left, counted down by dt() — because a
countdown has nothing to compare against and cannot be wrong about what time
it is.
A key that was never released
Walking into the door opens a message, and a message pauses the game. Let go of the arrow key while it is paused and the release can be missed — after which the computer goes on insisting the key is still down, and the next level starts with the knight sprinting into a wall. That is why step 2 keeps its own note of which arrow is held, and forgets both at the start of every level.
Make it yours
Open the whole game in the playground → It goes straight into the editor, ready to change and run.
- Draw a sixth level. Twenty characters wide, thirteen tall, one
P, oneD, and at least fouro. Add it toLEVELS. - Add a letter to
TILES— a spike that hurts you, a key you need before the door opens, a spring that throws you upward. - Give the goblin a longer patrol by changing
GOBLIN_RANGE, or a faster one withGOBLIN_SPEED. Remember which of those is an angle. - Make the boss take more hits, and give it a second attack when it is down to two.
The whole game is one file. Open the playground →