Building Coin Rush
Play it first → Bean has thirty seconds to grab every coin on the level. Get them all and the next level starts with more in the way: walls, then spikes, then ghosts, then a gazer that follows you through the walls.
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: a level is a picture
A level has dozens of walls and coins in it. Typing a position for every one of them would take ages, and moving one would mean working out new numbers.
So a level here is a picture made of letters. One letter is one tile:
LEVEL_2 = [
"###############",
"#o # o#",
"# # o #",
"# o # #",
"# ### ### #",
"# o P o#",
"### ### # #",
"# o # #",
"#o # o#",
"###############",
]
# is a wall, o is a coin and P is where
Bean starts. To move a coin, move an o. Nothing else in the game
has to be told about it.
Step 1 — a room
addLevel() reads 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 = 64 # one tile is 64 pixels square
TOP = 56 # a strip at the top for the score and the clock
kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)
loadSprite("steel", "images/steel.png")
ROOM = [
"###############",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"# #",
"###############",
]
addLevel(ROOM, {
"tileWidth": TILE,
"tileHeight": TILE,
"pos": vec2(0, TOP),
"tiles": {
"#": lambda: [sprite("steel"), area(), body(isStatic=True)],
},
})
Run it and you get an empty room with steel walls.
setGravity(0): we look down on Bean from above, so nothing should fall. 0 is already the setting when a game starts, so this line changes nothing; it's there to say what kind of game this is. A platform game, where things fall, sets it to about 2400."pos": vec2(0, TOP)starts the picture 56 pixels down, which leaves a strip across the top for the score and the clock.body(isStatic=True)is what makes a wall solid. It never moves, and anything with abody()stops when it runs into it.
Every row of the picture must be the same length, 15 letters here. If one row is shorter, there's no error message. The walls on the right just don't line up.
Step 2 — Bean runs
Bean is a tile too. Put a P in the picture and add it to
tiles, and changing where Bean starts is just moving that
letter. get("player") finds Bean again afterwards, using the
"player" tag on the end of the list.
from kaypy import *
TILE = 64
TOP = 56
SPEED = 300
kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)
loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
ROOM = [
"###############",
"# #",
"# #",
"# ### #",
"# #",
"# P # #",
"# # #",
"# # #",
"# #",
"###############",
]
addLevel(ROOM, {
"tileWidth": TILE,
"tileHeight": TILE,
"pos": vec2(0, TOP),
"tiles": {
"#": lambda: [sprite("steel"), area(), body(isStatic=True)],
"P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
},
})
bean = get("player")[0]
# True while the arrow is held down.
going_left = False
going_right = False
going_up = False
going_down = False
@onKeyPress("left")
def press_left():
global going_left
going_left = True
@onKeyRelease("left")
def release_left():
global going_left
going_left = False
@onKeyPress("right")
def press_right():
global going_right
going_right = True
@onKeyRelease("right")
def release_right():
global going_right
going_right = False
@onKeyPress("up")
def press_up():
global going_up
going_up = True
@onKeyRelease("up")
def release_up():
global going_up
going_up = False
@onKeyPress("down")
def press_down():
global going_down
going_down = True
@onKeyRelease("down")
def release_down():
global going_down
going_down = False
@onUpdate
def each_frame():
if going_left:
bean.move(-SPEED, 0)
bean.flipX = True
if going_right:
bean.move(SPEED, 0)
bean.flipX = False
if going_up:
bean.move(0, -SPEED)
if going_down:
bean.move(0, SPEED)
scale(0.7) is there because the bean picture is a whole tile
wide. A Bean exactly as wide as a tile gets stuck in every one-tile gap.
The arrow keys are the fiddly part. The obvious way is
onKeyDown, which asks "is the right arrow held down right
now?" every frame. That works until a message pauses the game, which
happens in step 4. So the game keeps its own note instead: pressing an arrow
sets its variable to True, letting go sets it back to
False, and every frame Bean moves whichever ways are
True.
bean.move(-SPEED, 0) moves Bean left at SPEED
pixels per second. It is per second, not per frame, so Bean runs at the same
speed on a fast computer and a slow one.
Don't forget global.
Without global going_left, the line
going_left = True makes a new variable that only exists
inside press_left and disappears when the function ends. There's
no error message. The left arrow just does nothing, and it looks like a
problem with the keys.
Step 3 — coins and a counter
A coin is a tile with an area() but no body().
An area() on its own is something you can touch but walk
straight through, which is what a coin should be.
from kaypy import *
TILE = 64
TOP = 56
SPEED = 300
kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)
loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSound("coin", "sounds/small_beep.wav")
ROOM = [
"###############",
"#o o#",
"# o #",
"# ### #",
"# o #",
"# P # o#",
"# # #",
"# o # #",
"#o o#",
"###############",
]
addLevel(ROOM, {
"tileWidth": TILE,
"tileHeight": TILE,
"pos": vec2(0, TOP),
"tiles": {
"#": lambda: [sprite("steel"), area(), body(isStatic=True)],
"o": lambda: [sprite("coin"), area(), "coin"],
"P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
},
})
bean = get("player")[0]
total = len(get("coin"))
coins = 0
label = add([text("Coins 0 / " + str(total), size=26), pos(16, 14), fixed()])
# True while the arrow is held down.
going_left = False
going_right = False
going_up = False
going_down = False
@onKeyPress("left")
def press_left():
global going_left
going_left = True
@onKeyRelease("left")
def release_left():
global going_left
going_left = False
@onKeyPress("right")
def press_right():
global going_right
going_right = True
@onKeyRelease("right")
def release_right():
global going_right
going_right = False
@onKeyPress("up")
def press_up():
global going_up
going_up = True
@onKeyRelease("up")
def release_up():
global going_up
going_up = False
@onKeyPress("down")
def press_down():
global going_down
going_down = True
@onKeyRelease("down")
def release_down():
global going_down
going_down = False
@onUpdate
def each_frame():
if going_left:
bean.move(-SPEED, 0)
bean.flipX = True
if going_right:
bean.move(SPEED, 0)
bean.flipX = False
if going_up:
bean.move(0, -SPEED)
if going_down:
bean.move(0, SPEED)
@bean.onCollide("coin")
def got_coin(coin):
global coins
coin.destroy()
coins = coins + 1
label.text = "Coins " + str(coins) + " / " + str(total)
play("coin")
if coins == total:
say("You got every coin!")
@bean.onCollide("coin") runs the function every time Bean
touches anything tagged "coin", and hands it the coin that was
touched, so coin.destroy() removes that one coin and no
other.
total = len(get("coin")) counts the coins in the picture. Add
an o to the room and the counter goes up by itself.
The str() in the label matters. "Coins " + coins
tries to add words to a number, and Python stops with
TypeError: can only concatenate str (not "int") to str.
str(coins) turns the number into words first.
Step 4 — more than one level
A game with levels needs a way to throw one level away and build the
next. That is a scene. @scene("level") is the
recipe for building a level, and go("level", 1, 8) clears the
screen and follows the recipe again, this time for level 1 with a score of 8
carried over.
from kaypy import *
TILE = 64
TOP = 56
SPEED = 300
kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)
loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSound("coin", "sounds/small_beep.wav")
loadSound("done", "sounds/rising_beep.wav")
LEVEL_1 = [
"###############",
"# #",
"# o o #",
"# o #",
"# #",
"# o o #",
"# P #",
"# o o #",
"# o #",
"###############",
]
LEVEL_2 = [
"###############",
"#o # o#",
"# # o #",
"# o # #",
"# ### ### #",
"# o P o#",
"### ### # #",
"# o # #",
"#o # o#",
"###############",
]
LEVELS = [LEVEL_1, LEVEL_2]
TILES = {
"#": lambda: [sprite("steel"), area(), body(isStatic=True)],
"o": lambda: [sprite("coin"), area(), "coin"],
"P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
}
going_left = False
going_right = False
going_up = False
going_down = False
coins = 0
level_over = False
@scene("level")
def build_level(number, score):
global going_left, going_right, going_up, going_down
global coins, level_over
addLevel(LEVELS[number], {
"tileWidth": TILE,
"tileHeight": TILE,
"pos": vec2(0, TOP),
"tiles": TILES,
})
bean = get("player")[0]
total = len(get("coin"))
# A new level, so everything starts again.
going_left = False
going_right = False
going_up = False
going_down = False
coins = 0
level_over = False
@onKeyPress("left")
def press_left():
global going_left
going_left = True
@onKeyRelease("left")
def release_left():
global going_left
going_left = False
@onKeyPress("right")
def press_right():
global going_right
going_right = True
@onKeyRelease("right")
def release_right():
global going_right
going_right = False
@onKeyPress("up")
def press_up():
global going_up
going_up = True
@onKeyRelease("up")
def release_up():
global going_up
going_up = False
@onKeyPress("down")
def press_down():
global going_down
going_down = True
@onKeyRelease("down")
def release_down():
global going_down
going_down = False
label = add([text("", size=26), pos(16, 14), fixed()])
def show_score():
label.text = ("Level " + str(number + 1)
+ " Coins " + str(coins) + " / " + str(total)
+ " Score " + str(score + coins))
@onUpdate
def each_frame():
if level_over:
return
if going_left:
bean.move(-SPEED, 0)
bean.flipX = True
if going_right:
bean.move(SPEED, 0)
bean.flipX = False
if going_up:
bean.move(0, -SPEED)
if going_down:
bean.move(0, SPEED)
@bean.onCollide("coin")
def got_coin(coin):
global coins, level_over
coin.destroy()
coins = coins + 1
play("coin")
show_score()
if coins == total:
level_over = True
play("done")
new_score = score + coins
if number + 1 < len(LEVELS):
@say("Level " + str(number + 1) + " done!")
def next_level():
go("level", number + 1, new_score)
else:
@say("You beat every level!\n\nScore: " + str(new_score))
def start_again():
go("level", 0, 0)
show_score()
go("level", 0, 0)
Almost everything from step 3 has moved inside build_level.
That is because go() throws away everything from the
old scene: the objects, and also the key handlers and the
onUpdate. Anything that should exist in every level has to be
set up again each time the scene is built.
For the same reason the variables go back to the start at the top of
build_level. This is where the arrow keys from step 2 pay off.
If you are holding an arrow when you grab the last coin, the "Level done!"
message pauses the game. If you let go while the message is up, the game
never sees you let go. Setting all four back to False means the
next level starts with Bean standing still.
@say(...) puts a message up, and the function underneath runs
when the player presses OK. Anything that should happen after the
message goes inside that function. A line written underneath
say() runs straight away, while the message is still on the
screen.
level_over stops Bean moving once the level is won, so Bean
can't wander off behind the message.
Careful with the name. The scene is
called "level", but the function is build_level,
not play. kaypy already has a play(), the one that
makes sounds. Name your function play and it replaces that one,
so the first coin you touch runs play("coin"), which now
means "build a level" instead of "make a sound". The game crashes with
TypeError: play() missing 1 required positional argument: 'score',
which makes no sense if you think play plays sounds.
Step 5 — the clock
The clock is a number of seconds that goes down every frame.
dt() is how long the last frame took, a sixtieth of a second or
so, and taking it away every frame counts down in real seconds.
from kaypy import *
TILE = 64
TOP = 56
SPEED = 300
TIME_LIMIT = 30 # seconds for each level
BEST_KEY = "coin_rush_best"
kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)
loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSound("coin", "sounds/small_beep.wav")
loadSound("done", "sounds/rising_beep.wav")
loadSound("ouch", "sounds/thump.wav")
LEVEL_1 = [
"###############",
"# #",
"# o o #",
"# o #",
"# #",
"# o o #",
"# P #",
"# o o #",
"# o #",
"###############",
]
LEVEL_2 = [
"###############",
"#o # o#",
"# # o #",
"# o # #",
"# ### ### #",
"# o P o#",
"### ### # #",
"# o # #",
"#o # o#",
"###############",
]
LEVELS = [LEVEL_1, LEVEL_2]
TILES = {
"#": lambda: [sprite("steel"), area(), body(isStatic=True)],
"o": lambda: [sprite("coin"), area(), "coin"],
"P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
}
going_left = False
going_right = False
going_up = False
going_down = False
time_left = 0
coins = 0
level_over = False
@scene("level")
def build_level(number, score):
global going_left, going_right, going_up, going_down
global time_left, coins, level_over
addLevel(LEVELS[number], {
"tileWidth": TILE,
"tileHeight": TILE,
"pos": vec2(0, TOP),
"tiles": TILES,
})
bean = get("player")[0]
total = len(get("coin"))
# A new level, so everything starts again.
going_left = False
going_right = False
going_up = False
going_down = False
time_left = TIME_LIMIT
coins = 0
level_over = False
@onKeyPress("left")
def press_left():
global going_left
going_left = True
@onKeyRelease("left")
def release_left():
global going_left
going_left = False
@onKeyPress("right")
def press_right():
global going_right
going_right = True
@onKeyRelease("right")
def release_right():
global going_right
going_right = False
@onKeyPress("up")
def press_up():
global going_up
going_up = True
@onKeyRelease("up")
def release_up():
global going_up
going_up = False
@onKeyPress("down")
def press_down():
global going_down
going_down = True
@onKeyRelease("down")
def release_down():
global going_down
going_down = False
label = add([text("", size=26), pos(16, 14), fixed()])
clock = add([text("", size=26), pos(width() - 130, 14), fixed()])
def show_score():
label.text = ("Level " + str(number + 1)
+ " Coins " + str(coins) + " / " + str(total)
+ " Score " + str(score + coins))
clock.text = "Time " + str(int(time_left))
@onUpdate
def each_frame():
global time_left
if level_over:
return
if going_left:
bean.move(-SPEED, 0)
bean.flipX = True
if going_right:
bean.move(SPEED, 0)
bean.flipX = False
if going_up:
bean.move(0, -SPEED)
if going_down:
bean.move(0, SPEED)
time_left = time_left - dt()
if time_left <= 0:
time_left = 0
times_up()
show_score()
@bean.onCollide("coin")
def got_coin(coin):
global coins, level_over
coin.destroy()
coins = coins + 1
play("coin")
show_score()
if coins == total:
level_over = True
play("done")
new_score = score + coins
if number + 1 < len(LEVELS):
@say("Level " + str(number + 1) + " done!")
def next_level():
go("level", number + 1, new_score)
else:
@say("You beat every level!\n\nScore: " + str(new_score))
def start_again():
go("level", 0, 0)
def times_up():
global level_over
level_over = True
final = score + coins
best = getData(BEST_KEY, 0)
if final > best:
setData(BEST_KEY, final)
message = "New best score: " + str(final) + "!"
else:
message = "Score: " + str(final) + "\nBest: " + str(best)
play("ouch")
@say("Time's up!\n\n" + message, button="Play again")
def play_again():
go("level", 0, 0)
show_score()
say("Level " + str(number + 1), button="Go!")
go("level", 0, 0)
A countdown is easier to change than a deadline. In step 6, touching a
spike costs three seconds, and that is just
time_left = time_left - PENALTY.
int(time_left) drops everything after the decimal point, so
the clock shows Time 7 instead of
Time 7.316666666.
The say(..., button="Go!") at the end of
build_level doesn't only say which level it is. A message pauses
the game, and onUpdate doesn't run while the game is paused, so
the clock doesn't start until the player presses Go.
getData and setData remember the best score
after the page is closed. The key is "coin_rush_best" rather
than just "best" because every game on a website shares the same
storage. Another game saving its own "best" would overwrite
yours.
Step 6 — spikes and ghosts
Two new levels, three new letters. Everything that hurts is tagged
"danger", so one onCollide handles spikes
and ghosts both. Each ghost also gets a second tag, "across" or
"updown", which decides how it moves.
from kaypy import *
TILE = 64
TOP = 56
SPEED = 300
TIME_LIMIT = 30 # seconds for each level
PENALTY = 3 # seconds lost for touching a spike or a ghost
GHOST_SPEED = 100
BEST_KEY = "coin_rush_best"
kaypy(width=TILE * 15, height=TOP + TILE * 10, background=[118, 186, 120])
setGravity(0)
loadSprite("steel", "images/steel.png")
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("spike", "images/spike.png")
loadSprite("ghosty", "images/ghosty.png")
loadSound("coin", "sounds/small_beep.wav")
loadSound("done", "sounds/rising_beep.wav")
loadSound("ouch", "sounds/thump.wav")
LEVEL_1 = [
"###############",
"# #",
"# o o #",
"# o #",
"# #",
"# o o #",
"# P #",
"# o o #",
"# o #",
"###############",
]
LEVEL_2 = [
"###############",
"#o # o#",
"# # o #",
"# o # #",
"# ### ### #",
"# o P o#",
"### ### # #",
"# o # #",
"#o # o#",
"###############",
]
LEVEL_3 = [
"###############",
"#o ^ ^ o#",
"# ## o ## #",
"# # ^ ^ # #",
"#o #P# o#",
"# ^ # # ^ #",
"# ### # # ### #",
"# o ^ ^ o #",
"#o o#",
"###############",
]
LEVEL_4 = [
"###############",
"#o h o#",
"# ### ### #",
"# #o ^ o# #",
"#v # P # v#",
"# #o o# #",
"# ### ### #",
"#o h o#",
"# ^ #",
"###############",
]
LEVELS = [LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4]
TILES = {
"#": lambda: [sprite("steel"), area(), body(isStatic=True)],
"o": lambda: [sprite("coin"), area(), "coin"],
"^": lambda: [sprite("spike"), area(), "danger"],
"h": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
"danger", "across"],
"v": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
"danger", "updown"],
"P": lambda: [sprite("bean"), scale(0.7), area(), body(), "player"],
}
going_left = False
going_right = False
going_up = False
going_down = False
time_left = 0
coins = 0
level_over = False
@scene("level")
def build_level(number, score):
global going_left, going_right, going_up, going_down
global time_left, coins, level_over
addLevel(LEVELS[number], {
"tileWidth": TILE,
"tileHeight": TILE,
"pos": vec2(0, TOP),
"tiles": TILES,
})
bean = get("player")[0]
start = vec2(bean.pos.x, bean.pos.y)
total = len(get("coin"))
# A new level, so everything starts again.
going_left = False
going_right = False
going_up = False
going_down = False
time_left = TIME_LIMIT
coins = 0
level_over = False
@onKeyPress("left")
def press_left():
global going_left
going_left = True
@onKeyRelease("left")
def release_left():
global going_left
going_left = False
@onKeyPress("right")
def press_right():
global going_right
going_right = True
@onKeyRelease("right")
def release_right():
global going_right
going_right = False
@onKeyPress("up")
def press_up():
global going_up
going_up = True
@onKeyRelease("up")
def release_up():
global going_up
going_up = False
@onKeyPress("down")
def press_down():
global going_down
going_down = True
@onKeyRelease("down")
def release_down():
global going_down
going_down = False
label = add([text("", size=26), pos(16, 14), fixed()])
clock = add([text("", size=26), pos(width() - 130, 14), fixed()])
def show_score():
label.text = ("Level " + str(number + 1)
+ " Coins " + str(coins) + " / " + str(total)
+ " Score " + str(score + coins))
clock.text = "Time " + str(int(time_left))
@onUpdate
def each_frame():
global time_left
if level_over:
return
if going_left:
bean.move(-SPEED, 0)
bean.flipX = True
if going_right:
bean.move(SPEED, 0)
bean.flipX = False
if going_up:
bean.move(0, -SPEED)
if going_down:
bean.move(0, SPEED)
time_left = time_left - dt()
if time_left <= 0:
time_left = 0
times_up()
show_score()
@bean.onCollide("coin")
def got_coin(coin):
global coins, level_over
coin.destroy()
coins = coins + 1
play("coin")
show_score()
if coins == total:
level_over = True
play("done")
new_score = score + coins
if number + 1 < len(LEVELS):
@say("Level " + str(number + 1) + " done!")
def next_level():
go("level", number + 1, new_score)
else:
@say("You beat every level!\n\nScore: " + str(new_score))
def start_again():
go("level", 0, 0)
@bean.onCollide("danger")
def ouch(thing):
global time_left
if level_over:
return
time_left = time_left - PENALTY
bean.pos = vec2(start.x, start.y)
shake(10)
play("ouch")
@onUpdate("across")
def float_across(ghost):
ghost.move(wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5), 0)
@onUpdate("updown")
def float_updown(ghost):
ghost.move(0, wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5))
def times_up():
global level_over
level_over = True
final = score + coins
best = getData(BEST_KEY, 0)
if final > best:
setData(BEST_KEY, final)
message = "New best score: " + str(final) + "!"
else:
message = "Score: " + str(final) + "\nBest: " + str(best)
play("ouch")
@say("Time's up!\n\n" + message, button="Play again")
def play_again():
go("level", 0, 0)
show_score()
say("Level " + str(number + 1), button="Go!")
go("level", 0, 0)
@onUpdate("across") runs once per frame for each
object tagged "across", and passes that object in as
ghost. Two ghosts, two calls, each moving its own ghost.
wave(low, high, angle) swings smoothly between two numbers
forever. Here it is used as a speed: the ghost floats one way, slows
down, stops, and floats back again. Change the 1.5 and the
ghosts swing faster or slower; change GHOST_SPEED and they
travel further.
start remembers where Bean began, so that touching a danger
can put Bean back there.
Step 7 — the whole game
This is the game exactly as it is on the games page. It adds three things to step 6:
- Level 5 and the gazer. The
Gletter has nobody(), so walls don't stop it, andgazer.moveTo(bean.pos, CHASE_SPEED)heads it straight at Bean every frame. - Laps. After level 5,
go("level", 0, new_score, lap + 1)starts again with the score kept and five seconds less on the clock, down to a minimum ofSHORTEST. The game ends only when the clock runs out. - A tip for each level, in the
TIPSlist, shown in the Go! message.
It also has all the numbers worth changing in one settings block near the top, and comments that explain why each part is there.
"""Coin Rush -- grab every coin before the clock runs out.
arrow keys run
Bean has 30 seconds to collect every coin on the level. Get them all and the
next level starts, with more in the way: first walls, then spikes, then
ghosts, then a gazer that comes after you. Touch a spike or a ghost and you
lose 3 seconds and go back to where you started.
Every coin counts towards your score, and the score carries on from level to
level. Finish all five and they start again with less time on the clock, so
the game only ends when the clock beats you. Your best score is remembered.
HOW THIS FILE IS PUT TOGETHER
1. the window, the pictures and the sounds
2. the settings
3. the levels
4. what each letter turns into
5. the level scene: Bean, the keys, the clock, the coins
6. start the game
The levels are pictures made of letters. Move an 'o' and you have moved a
coin -- you never have to touch the code to change a level.
"""
from kaypy import *
# ----------------------------------------------------------------- 1. setup
TILE = 64 # one tile is 64 pixels square
ACROSS = 15 # tiles across
DOWN = 10 # tiles down
TOP = 56 # a strip at the top for the score and the clock
kaypy(width=TILE * ACROSS, height=TOP + TILE * DOWN,
background=[118, 186, 120])
# We look down on Bean from above, so nothing falls. 0 is already the
# setting when a game starts, so this line changes nothing -- it is here to
# say what kind of game this is. A platform game sets it to about 2400.
setGravity(0)
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")
loadSprite("steel", "images/steel.png")
loadSprite("spike", "images/spike.png")
loadSprite("ghosty", "images/ghosty.png")
loadSprite("gazer", "images/gazer.png")
loadSound("coin", "sounds/small_beep.wav")
loadSound("ouch", "sounds/thump.wav")
loadSound("done", "sounds/rising_beep.wav")
# -------------------------------------------------------------- 2. settings
#
# Every number worth changing is here.
SPEED = 300 # how fast Bean runs, in pixels per second
TIME_LIMIT = 30 # seconds on the clock for each level
FASTER_EACH_LAP = 5 # seconds fewer each time round all five levels
SHORTEST = 10 # ...but never fewer than this
PENALTY = 3 # seconds lost for touching a spike, ghost or gazer
GHOST_SPEED = 100 # how fast a ghost floats
CHASE_SPEED = 60 # how fast the gazer follows you
# The name the best score is saved under. Every game on a website shares
# the same storage, so the game's own name goes in it -- a plain "best"
# would be overwritten by the next game that saved one.
BEST_KEY = "coin_rush_best"
# ---------------------------------------------------------------- 3. levels
#
# One letter is one tile.
#
# # wall o coin P Bean starts here
# ^ spike h ghost that floats side to side
# v ghost that floats up and down
# G gazer, which chases you straight through walls
#
# A space is empty floor. Every row has to be 15 letters long.
LEVEL_1 = [
"###############",
"# #",
"# o o #",
"# o #",
"# #",
"# o o #",
"# P #",
"# o o #",
"# o #",
"###############",
]
LEVEL_2 = [
"###############",
"#o # o#",
"# # o #",
"# o # #",
"# ### ### #",
"# o P o#",
"### ### # #",
"# o # #",
"#o # o#",
"###############",
]
LEVEL_3 = [
"###############",
"#o ^ ^ o#",
"# ## o ## #",
"# # ^ ^ # #",
"#o #P# o#",
"# ^ # # ^ #",
"# ### # # ### #",
"# o ^ ^ o #",
"#o o#",
"###############",
]
LEVEL_4 = [
"###############",
"#o h o#",
"# ### ### #",
"# #o ^ o# #",
"#v # P # v#",
"# #o o# #",
"# ### ### #",
"#o h o#",
"# ^ #",
"###############",
]
LEVEL_5 = [
"###############",
"#o # # o#",
"# ## # ^ # ## #",
"# o h o#",
"#v ## P ## v#",
"# ^ ^ #",
"# ##o## ##o## #",
"#o h o#",
"# ^ ^ G#",
"###############",
]
LEVELS = [LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4, LEVEL_5]
# What to tell the player before each level.
TIPS = [
"Grab every coin before the clock runs out!",
"Walls! Find a way round them.",
"Spikes! Touch one and you lose 3 seconds.",
"Ghosts! Watch how they float, then slip past.",
"The gazer follows you, even through walls. Keep moving!",
]
# ----------------------------------------------- 4. what each letter becomes
#
# addLevel() reads the picture. Each time it finds a letter it looks it up
# here and builds an object from the list. The `lambda:` means "build a new
# one every time you find this letter", so every coin is its own coin.
#
# Everything that hurts Bean is tagged "danger". The ghosts and the gazer
# have a second tag too -- "across", "updown" or "chaser" -- which is what
# makes each of them move the way it does. A spike is only "danger".
TILES = {
"#": lambda: [sprite("steel"), area(), body(isStatic=True), "wall"],
"o": lambda: [sprite("coin"), area(), "coin"],
"^": lambda: [sprite("spike"), area(), "danger"],
"h": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
"danger", "across"],
"v": lambda: [sprite("ghosty"), scale(0.6), area(), z(10),
"danger", "updown"],
"G": lambda: [sprite("gazer"), scale(0.4), area(), z(10),
"danger", "chaser"],
# Bean is a tile like everything else, so moving the 'P' moves where you
# start. scale(0.7) because the picture is a whole tile wide, and a Bean
# that fills a tile gets stuck in every gap.
"P": lambda: [sprite("bean"), scale(0.7), area(), body(), z(20),
"player"],
}
# -------------------------------------------- what the game has to remember
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".
going_left = False
going_right = False
going_up = False
going_down = False
time_left = 0 # seconds left on the clock
coins = 0 # coins collected on this level
level_over = False
# ------------------------------------------------------- 5. the level scene
#
# The scene is CALLED "level". The function is NOT called play(), because
# kaypy already has a play() for sounds -- name your function play() and
# every play("coin") would start a level instead of making a noise.
#
# number is which level (0 is the first), score is the coins from earlier
# levels, and lap is how many times you have been through all five.
@scene("level")
def build_level(number, score, lap):
global going_left, going_right, going_up, going_down
global time_left, coins, level_over
addLevel(LEVELS[number], {
"tileWidth": TILE,
"tileHeight": TILE,
"pos": vec2(0, TOP),
"tiles": TILES,
})
bean = get("player")[0]
start = vec2(bean.pos.x, bean.pos.y)
total = len(get("coin"))
# A new level, so everything starts again.
going_left = False
going_right = False
going_up = False
going_down = False
coins = 0
level_over = False
time_left = TIME_LIMIT - lap * FASTER_EACH_LAP
if time_left < SHORTEST:
time_left = SHORTEST
# -------------------------------------------------------------- the keys
#
# WHY NOT JUST onKeyDown?
#
# onKeyDown asks "is the key down right now?" every frame. But a message
# on the screen pauses the game, and if you let go of an arrow while it
# is up, the let-go can be missed. Then the game thinks the arrow is
# still held, and on the next level Bean runs off by itself.
#
# So the game keeps its own note: True when a key is pressed, False when
# it is let go, and all four go back to False at the start of a level.
@onKeyPress("left")
def press_left():
global going_left
going_left = True
@onKeyRelease("left")
def release_left():
global going_left
going_left = False
@onKeyPress("right")
def press_right():
global going_right
going_right = True
@onKeyRelease("right")
def release_right():
global going_right
going_right = False
@onKeyPress("up")
def press_up():
global going_up
going_up = True
@onKeyRelease("up")
def release_up():
global going_up
going_up = False
@onKeyPress("down")
def press_down():
global going_down
going_down = True
@onKeyRelease("down")
def release_down():
global going_down
going_down = False
# ------------------------------------------------- the words at the top
label = add([text("", size=26), pos(16, 14), fixed(), z(100)])
clock = add([text("", size=26), pos(width() - 130, 14), fixed(), z(100)])
def show_score():
label.text = ("Level " + str(number + 1)
+ " Coins " + str(coins) + " / " + str(total)
+ " Score " + str(score + coins))
# int() drops the part after the decimal point: 7.8 becomes 7.
clock.text = "Time " + str(int(time_left))
# ------------------------------------------------------- every frame
@onUpdate
def each_frame():
global time_left
if level_over:
return
if going_left:
bean.move(-SPEED, 0)
bean.flipX = True
if going_right:
bean.move(SPEED, 0)
bean.flipX = False
if going_up:
bean.move(0, -SPEED)
if going_down:
bean.move(0, SPEED)
# The clock. dt() is how long this frame took, so taking it away
# every frame counts down in real seconds.
time_left = time_left - dt()
if time_left <= 0:
time_left = 0
times_up()
show_score()
# ----------------------------------------------------------- the coins
@bean.onCollide("coin")
def got_coin(coin):
global coins, level_over
coin.destroy()
coins = coins + 1
play("coin")
show_score()
if coins == total:
level_over = True
play("done")
new_score = score + coins
if number + 1 < len(LEVELS):
@say("Level " + str(number + 1) + " done!\n\n"
+ "Score: " + str(new_score))
def next_level():
go("level", number + 1, new_score, lap)
else:
# Back to level 1, one lap further on, less time.
@say("You beat all five levels!\n\n"
+ "Score: " + str(new_score) + "\n\n"
+ "Again -- with less time!")
def next_lap():
go("level", 0, new_score, lap + 1)
# -------------------------------------------------- spikes and ghosts
@bean.onCollide("danger")
def ouch(thing):
global time_left
if level_over:
return
time_left = time_left - PENALTY
bean.pos = vec2(start.x, start.y)
shake(10)
play("ouch")
# wave() swings between two numbers for ever. Used as a SPEED, it makes
# a ghost float one way, slow down, and float back again.
@onUpdate("across")
def float_across(ghost):
ghost.move(wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5), 0)
@onUpdate("updown")
def float_updown(ghost):
ghost.move(0, wave(-GHOST_SPEED, GHOST_SPEED, time() * 1.5))
# moveTo() heads towards a point. The gazer has no body(), so walls do
# not stop it.
@onUpdate("chaser")
def chase(gazer):
gazer.moveTo(bean.pos, CHASE_SPEED)
# ------------------------------------------------------------ time's up
def times_up():
global level_over
level_over = True
final = score + coins
best = getData(BEST_KEY, 0)
if final > best:
setData(BEST_KEY, final)
message = "New best score: " + str(final) + "!"
else:
message = "Score: " + str(final) + "\nBest: " + str(best)
shake(8)
play("ouch")
@say("Time's up!\n\n" + message, button="Play again")
def play_again():
go("level", 0, 0, 0)
# The message pauses the game, so the clock does not start until you
# close it.
show_score()
say("Level " + str(number + 1) + "\n\n" + TIPS[number], button="Go!")
# ------------------------------------------------------- 6. start the game
go("level", 0, 0, 0)
Make it yours
Open the whole game in the playground → It goes straight into the editor, ready to change and run.
- Change the numbers.
SPEED,TIME_LIMIT,PENALTY,GHOST_SPEED. A game you find too hard is one number away from being too easy. - Draw level 6. Copy
LEVEL_5, change the letters, and add it toLEVELS. You also need a sixth line inTIPS. Without it, reaching level 6 crashes withIndexError: list index out of range, because there are only five tips to choose from. - Add a new letter. A
+that gives you five more seconds: a tile tagged"bonus"using theheartpicture, and a@bean.onCollide("bonus")that destroys it and adds totime_left. Don't forget toloadSpritethe heart. - Use WASD as well. Copy the arrow-key handlers and change
"left"to"a", and so on. They can set the same variables.