The guide
Thirteen lessons, from an empty file to an enemy with a state machine. Every one is a game you can run, and each adds one idea to the one before it.
You need to know what a variable and a function are. Everything else — game loops, collision, animation, scenes — is explained here as it comes up.
Reading straight through takes about an hour. Typing it in takes a few evenings, and is the point. Every lesson ends with something to change, and the changing is where the learning is.
Getting started
Every game in this guide is an ordinary Python file that begins:
from kaypy import *
That one line brings in every name you will use. Everything else follows from it.
There are two ways to run what you write, and the lessons are identical in both — same code, same results. Use whichever suits you.
In the playground, with nothing installed
Open the playground. It runs Python in your browser, the sprites are already there, and it starts with the import line above.
Press Run to play, and click the picture once so the keys reach the game. Press Stop when you want the keyboard back for typing.
Nothing is saved on a server, because there is no server — so when you have
something you want to keep, use Download. You get a zip holding your .py
file and a single self-contained .html you can double-click to play, or send
to somebody, or upload to itch.io.
On your own machine
Install once, then make yourself a game to work in:
pip install kaypy
kaypy new mygame
cd mygame
That folder has a small working game in game.py, and all the sprites and
sounds these lessons use. Run it:
python game.py
A window opens and the game is already running. Close it to stop.
To turn the same file into a web page instead:
kaypy web game.py
That gives you one HTML file. Double-click it and the game plays — no server, nothing to install.
There is no run() call anywhere in this guide, either way. The loop starts
on its own once your file has finished being read, so the first thing you write
is the part you came for.
Where the pictures and sounds live
Paths look the same wherever you run:
loadSprite("bean", "images/bean.png") # the cartoon pack
loadSprite("elf_m", "dungeon/elf_m.png") # the dungeon pack — pixel art
loadSound("ding", "sounds/ding.wav") # sounds
In the playground, the Sprites button opens every sprite and sound there is; click one and it inserts the lines you need, so you never type a path by hand. The search box covers both packs at once, and a ▶ in the corner of a cell means that sprite animates.
On your own machine, kaypy new put those same three folders next to your
game.py, so the paths line up already.
Either way, the two sprite packs are separate folders, and that matters: a
dungeon sprite is not in images/. There is also dungeon.png, the dungeon
artwork as one uncut image — that is a sprite atlas, and Lesson 12 is about
cutting one up yourself.
If you want to look something up
The API reference lists every name there is, each with a small example you can run. This guide teaches; that page answers.
Reading JavaScript examples
Skip this section if you have never seen JavaScript — nothing later depends on it.
KayPy's function names follow KAPLAY, a JavaScript game engine. You never have to write or read any JavaScript. It matters for one reason only: when you search the web for how to do something, the examples you find are usually theirs, and they translate almost line for line.
Here is the whole translation.
| JavaScript | Python |
|---|---|
kaplay({ width: 800 }) |
kaypy(width=800) |
body({ jumpForce: 800 }) |
body(jumpForce=800) |
const SPEED = 300; |
SPEED = 300 |
function jump() { ... } |
def jump(): ... |
if (a && !b) { ... } |
if a and not b: ... |
true / false / null |
True / False / None |
// a comment |
# a comment |
{ ... } blocks |
indentation |
; at the end of a line |
nothing |
Two rules worth stating plainly:
Options in curly braces become keyword arguments. Anywhere the JavaScript
shows { speed: 10, loop: true }, you write speed=10, loop=True.
Callbacks can ignore arguments they don't want. An event handler that takes no arguments works even when the engine has something to hand it. If you do want it, give your function a parameter and you will get it.
Event handlers
This is the one place where the Python is shaped differently, and it is an improvement. JavaScript writes an event like this:
onKeyPress("space", () => {
if (player.isGrounded()) player.jump(800)
})
In Python you put the event above the function it should run:
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(800)
The @ line reads as a label: when space is pressed, run this. The function
underneath is an ordinary function — as many lines as you like, if statements,
variables, anything.
Every event in this guide works that way. You can also hand an event a function directly:
onKeyPress("space", jump)
Both do exactly the same thing. Use whichever is clearer. Where a JavaScript
example uses a one-line arrow function, Python's equivalent is a lambda:
onKeyDown("left", () => player.move(-300, 0)) // JavaScript
onKeyDown("left", lambda: player.move(-300, 0)) # Python, same thing
You never have to write one. This guide uses the @ form throughout, because a
lambda can only hold a single expression — the moment a handler needs an if,
or to change a variable, a lambda can't do it and you need a real function
anyway.
1 — Adding a game object
In Kaplay everything you put on the screen — players, bullets, rocks, clouds, text — is a game object. You build one out of components, and each component gives it one ability.
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
loadSprite("bean", "images/bean.png")
bean = add([
sprite("bean"),
pos(80, 40),
area(),
color(0, 0, 255),
])
Run it — Run in the playground, or python game.py on your machine. Bean is on
the screen.
What each part does.
kaypy(...) starts the engine. It has to come first, before anything else.
loadSprite("bean", "images/bean.png") makes a picture available under the name
bean. Loading and using are two separate steps, and forgetting the load is the
commonest reason a sprite doesn't appear.
add([...]) builds a game object out of a list of components:
sprite("bean")— draw it using the picture calledbeanpos(80, 40)— put it at x=80, y=40area()— give it a collision box, so it can bump into thingscolor(0, 0, 255)— tint it blue
There are many more components. You will meet most of them in the lessons below.
Challenge
- Use a different sprite. Click Sprites to see them all, or look in
examples/images/.- Play with the position, color and scale. Try adding
scale(1.5), and trypos(width() / 2, height() / 2)—width()andheight()give you the size of the game window, so that puts the object dead centre.
2 — Player movement
Input handling and moving things about.
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
loadSprite("bean", "images/bean.png")
SPEED = 320 # pixels per second
player = add([
sprite("bean"),
pos(center()),
area(),
])
@onKeyDown("left")
def move_left():
player.move(-SPEED, 0)
@onKeyDown("right")
def move_right():
player.move(SPEED, 0)
@onKeyDown("up")
def move_up():
player.move(0, -SPEED)
@onKeyDown("down")
def move_down():
player.move(0, SPEED)
@onClick
def teleport():
player.moveTo(mousePos())
add([
text("Arrow keys to move, click to teleport", size=20),
pos(12, 12),
])
What each part does.
center() gives you the middle of the screen — it is shorthand for
vec2(width() / 2, height() / 2).
@onKeyDown(key) above a function registers an event that runs every frame
while the key is held down. This pattern — name an event, give it a function
to run — is the shape of nearly everything in Kaplay. Learn it once here.
@onClick has no argument to give it, so it needs no brackets. Everything else
in this guide does.
.move() comes from the pos() component. A component gives an object
abilities, and the object only has the abilities its components gave it. No
pos(), no .move(). This is worth stopping on, because it explains a whole
category of confusing error later.
.move() is measured in pixels per second, not pixels per frame. Kaplay
multiplies by the frame time for you, so your game runs at the same speed on a
fast computer and a slow one.
.moveTo() also comes from pos(), and puts the object somewhere rather than
nudging it.
text() is a component just like sprite(), but it draws words instead of a
picture.
3 — Collision handling
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
loadSprite("bean", "images/bean.png")
loadSprite("ghosty", "images/ghosty.png")
loadSprite("steel", "images/steel.png")
SPEED = 320
player = add([
sprite("bean"),
pos(center()),
area(),
body(),
"player",
])
# Three enemies, made with an ordinary Python for loop.
for i in range(3):
add([
sprite("ghosty"),
pos(rand(0, width()), rand(0, height())),
area(),
"enemy",
])
# A wall that nothing can push. isStatic means it never moves.
add([
sprite("steel"),
pos(600, 300),
area(),
body(isStatic=True),
])
# Heavy, but not immovable — you can shove it, slowly.
add([
sprite("steel"),
pos(200, 400),
area(),
body(mass=100),
])
@onKeyDown("left")
def move_left():
player.move(-SPEED, 0)
@onKeyDown("right")
def move_right():
player.move(SPEED, 0)
@onKeyDown("up")
def move_up():
player.move(0, -SPEED)
@onKeyDown("down")
def move_down():
player.move(0, SPEED)
@player.onCollide("enemy")
def hit_enemy(enemy):
enemy.destroy()
What each part does.
area() is what makes collision possible. Both objects need it — an object
without area() is invisible to collision, and this is the single most common
reason a collision "doesn't work".
body() makes an object respond to physics. body(isStatic=True) makes it a
wall: solid, and never moved by anything. body(mass=100) makes it heavy but
still pushable.
"player" and "enemy" in the component list are tags. A tag is just a
label you can look objects up by later.
@obj.onCollide(tag) comes from area(). It runs when this object touches an
object carrying that tag, and the thing it touched is handed to your function —
that's the enemy parameter above. There are two relatives:
@obj.onCollideUpdate(tag)runs every frame while they are touching@obj.onCollideEnd(tag)runs once when they stop touching
Also from area(): @obj.onClick runs when the object itself is clicked, and
.isHovering() returns True while the mouse is over it.
A debugging tool worth knowing now. Add this line and every collision box is drawn on screen:
debug.inspect = True
You can also press F1 while the game is running. When a collision isn't firing, look at the boxes before you look at the code — usually they simply aren't touching.
About dt()
dt() is the time since the last frame. You use it as a multiplier when you
move something by hand:
@onUpdate
def drift():
rock.move(0, 50 * dt())
.move() already does this for you, which is why the movement code above has no
dt() in it. Reach for dt() when you are changing a number yourself — a
timer, a fade, a score that climbs over time.
4 — Review assignment
No new material. Build this from memory, checking back only when stuck.
- Initialize Kaplay.
- Put a sprite on the screen that responds to collisions. (Which component makes collision possible?)
- Put a rectangle on the screen. Think — you want a
rect()component. What arguments would it take?
Hint for 3: a rectangle needs a width and a height, and like everything else it
needs a pos() to say where it goes.
5 — Gravity
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
loadSprite("bean", "images/bean.png")
setGravity(1600) # pixels per second, per second
player = add([
sprite("bean"),
pos(center()),
area(),
body(),
])
# A platform to land on.
add([
rect(width(), 48),
pos(0, height() - 48),
outline(4),
area(),
body(isStatic=True),
color(127, 200, 255),
])
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(800)
@player.onGround
def landed():
print("landed")
add([
text("Press space to jump", size=24, width=320),
pos(12, 12),
color(255, 255, 255),
])
What each part does.
setGravity(1600) switches gravity on for the whole game. Nothing falls until
you call it.
body() is what makes an object respond to gravity. And body() is what gives
you these:
.isGrounded()—Truewhen standing on something.jump(force)— launch upward@obj.onGround— an event that runs each time it lands
Again: no body(), none of these exist. If you get an error saying an object has
no jump, you forgot body().
The if player.isGrounded() check is what stops infinite mid-air jumping. Take
it out and see.
onKeyPress runs once per press; onKeyDown runs every frame while held.
Jumping wants onKeyPress.
The platform is a rect() with body(isStatic=True) — solid and immovable.
outline(4) draws a border round it.
text() takes options like everything else: size=24 sets the height,
width=320 wraps the words at that many pixels.
Exercise
Add left and right movement from Lesson 2, so you can jump and run.
6 — Sprite animation
An animation is several pictures shown in turn. The Kaplay pack has a nine-frame
dino walk cycle — dino_0 through dino_8 — so you can build one from the
frames you already have:
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
# One sprite made out of nine pictures. The frames are numbered 0 to 8 in the
# order you list them.
loadSprite("dino", [
"images/dino_0.png", "images/dino_1.png", "images/dino_2.png",
"images/dino_3.png", "images/dino_4.png", "images/dino_5.png",
"images/dino_6.png", "images/dino_7.png", "images/dino_8.png",
], anims={
"idle": {"from": 0, "to": 0, "loop": True},
"run": {"from": 0, "to": 8, "speed": 12, "loop": True},
})
SPEED = 300
setGravity(1600)
player = add([
sprite("dino"),
pos(center()),
anchor("center"),
area(),
body(),
scale(3),
"player",
])
player.play("idle")
add([
rect(width(), 48),
pos(0, height() - 48),
area(),
body(isStatic=True),
color(90, 74, 58),
])
@onKeyDown("left")
def run_left():
player.move(-SPEED, 0)
player.flipX = True
if player.isGrounded() and player.curAnim() != "run":
player.play("run")
@onKeyDown("right")
def run_right():
player.move(SPEED, 0)
player.flipX = False
if player.isGrounded() and player.curAnim() != "run":
player.play("run")
@onKeyRelease("left")
def stop_left():
player.play("idle")
@onKeyRelease("right")
def stop_right():
player.play("idle")
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(700)
What each part does.
anims= names ranges of frames. "run": {"from": 0, "to": 8, "speed": 12}
means "frames 0 through 8, twelve frames per second, forever". speed is frames
per second; loop decides whether it repeats.
.play(name) comes from sprite() and starts one of those animations.
.play() restarts the animation from its first frame. So calling it every
frame while a key is held gives you a character stuck on frame 0, twitching.
That is what player.curAnim() != "run" is for — only start the run animation if
it isn't already running. This catches everyone once.
Notice how much the @ form is earning here. run_left changes a variable,
checks two conditions and calls a method — none of which fits in a lambda.
anchor("center") says which point of the picture pos() refers to. Without it,
pos() means the top-left corner.
It takes a name, not a position. anchor(center()) looks reasonable — the
line above it is pos(center()) — but it is the one mistake in this guide that
costs a whole lesson. center() is a place on the screen; an anchor is an offset
between −1 and 1. The number is accepted either way and your sprite is drawn
thousands of pixels off screen, with no error and nothing on the canvas.
anchor("center") # right — a name
anchor(center()) # wrong — a screen position
anchor(vec2(0, 1)) # also right — bottom edge, an offset in -1..1
player.flipX = True mirrors the sprite so it faces the other way. Notice this
is a plain assignment, not a method call.
scale(3) draws it three times the size — the dino frames are only 16×26 pixels.
The shorter way: a spritesheet. A spritesheet is many frames in one image file, and Kaplay slices it for you. The dungeon pack is built this way — every one of its characters is a single strip holding all of their animations end to end:
loadSprite("elf_m", "dungeon/elf_m.png",
sliceX=8, anims={
"idle": {"from": 0, "to": 3, "speed": 8, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True},
})
player = add([sprite("elf_m", anim="idle"), pos(200, 200), scale(3)])
sliceX is how many frames across, sliceY how many down. anims names the
runs of frames: elf_m is eight frames, the first four an idle and the next four
a run.
You don't have to type any of that. In the playground, click the elf in the
Sprites panel and it appears, filled in — then change it. Turn speed up and the elf
runs faster. Set "loop": False on the run and it stops after one lap.
anim="idle" starts an animation the moment the object is added, and
.play("run") switches it later — exactly as in the dino version above.
Exercise
The player walks off the edge of the screen. Add an
@onUpdatethat keeps them on it. Hint: compareplayer.pos.xagainst0andwidth().
7 — Scenes
A scene is one part of your game: a menu, the game itself, a game-over screen.
Each one is a function, and go() switches between them.
from kaypy import *
kaypy(width=800, height=600)
setBackground(0, 0, 0)
loadSprite("bean", "images/bean.png")
loadSprite("ghosty", "images/ghosty.png")
loadSprite("coin", "images/coin.png")
loadSprite("portal", "images/portal.png")
loadSound("ding", "sounds/ding.wav")
loadSound("danger", "sounds/screech.wav")
SPEED = 320
@scene("game")
def game():
score = 0
coin_mult = 1.0
player = add([sprite("bean"), pos(center()), area(), "player"])
@onKeyDown("left")
def move_left():
player.move(-SPEED, 0)
@onKeyDown("right")
def move_right():
player.move(SPEED, 0)
@onKeyDown("up")
def move_up():
player.move(0, -SPEED)
@onKeyDown("down")
def move_down():
player.move(0, SPEED)
for i in range(20):
add([
sprite("coin"),
pos(rand(20, width() - 20), rand(20, height() - 20)),
area(),
"coin",
])
score_label = add([text("0", size=28), pos(12, 12)])
@player.onCollide("coin")
def got_coin(c):
nonlocal score, coin_mult
c.destroy()
score += 1
coin_mult += 0.05
score_label.text = str(score)
play("ding")
for i in range(5):
add([
sprite("ghosty"),
pos(rand(0, width()), rand(0, height())),
area(),
"enemy",
])
# Every frame, every ghost drifts toward the player — and speeds up as the
# player collects coins.
@onUpdate("enemy")
def chase(ghost):
ghost.moveTo(player.pos, 70 * coin_mult)
@player.onCollide("enemy")
def caught(e):
play("danger")
go("lose", score)
add([
sprite("portal"),
pos(rand(20, width() - 20), rand(20, height() - 20)),
area(),
"portal",
])
@player.onCollide("portal")
def escaped(p):
go("win", score)
@scene("lose")
def lose(score):
add([text("You lost", size=48), pos(center()), anchor("center")])
add([text("Score: " + str(score), size=28),
pos(center().x, center().y + 60), anchor("center")])
@onKeyPress("space")
def restart():
go("game")
@scene("win")
def win(score):
add([text("You win!", size=48), pos(center()), anchor("center")])
add([text("Score: " + str(score), size=28),
pos(center().x, center().y + 60), anchor("center")])
@onKeyPress("space")
def restart():
go("game")
go("game") # nothing happens until you start a scene
What each part does.
@scene("name") above a function registers a scene. go("name") switches to it,
and everything from the previous scene is thrown away — objects, events, all of
it. That is the point: you never have to clean up by hand.
go("lose", score) passes a value through, and the scene function receives it as
a parameter: def lose(score):.
Nothing runs until go() is called. That last line is the one everybody
forgets.
Notice that the events live inside the scene functions. They have to: they
refer to player, which doesn't exist until the scene runs. A decorator runs at
the moment Python reaches it, so an @onKeyDown indented inside game() is
registered when game() is called, not before.
setBackground(0, 0, 0) sets the background outside the kaypy() call — handy
when you want to change it later.
@onUpdate("enemy") runs the function every frame for every object tagged
enemy. The object is handed to your function.
.moveTo(target, speed) moves toward a point at a given speed, rather than
jumping straight there.
About nonlocal. score belongs to the game function, and got_coin is a
function inside it. Python needs nonlocal score to say "change the outer one,
don't make a new one". Leave it out and the score stays at zero forever,
silently. This is the one Python-specific gotcha in the whole guide.
8 — Audio and buttons
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
loadSound("bell", "sounds/ding.wav")
loadSound("bgMusic", "sounds/background.wav")
# Start the music paused, so it doesn't play until we say so.
music = play("bgMusic", loop=True, paused=True, volume=0.5)
bell_button = add([
rect(200, 60, radius=8),
pos(100, 100),
area(),
color(80, 120, 220),
])
# Added to the BUTTON, not to the screen — so (16, 18) is measured from the
# button's own corner, and the text travels with it if the button moves.
bell_button.add([
text("Ring the bell", size=20),
pos(16, 18),
])
@bell_button.onClick
def ring():
play("bell")
music_button = add([
rect(200, 60, radius=8),
pos(100, 200),
area(),
color(220, 120, 80),
])
music_button.add([text("Play music", size=20), pos(30, 18)])
@music_button.onClick
def toggle_music():
music.paused = not music.paused
What each part does.
loadSound(name, path) works just like loadSprite.
play(name) plays a sound once. play(name, loop=True, paused=True) hands you
back a handle you can control — music.paused = False starts it,
music.volume = 0.2 turns it down.
The bell needs no handle, because we never want to control it — we just want it to go off.
Child objects. bell_button.add([...]) adds the text to the button rather
than to the screen. Positions of a child are relative to its parent, and a child
moves when its parent moves.
Think about where else that's useful: a player carrying a sword, a health bar floating above an enemy's head, a label on a moving platform. Make it a child and you never have to keep the two positions in step by hand.
@obj.onClick on an object needs area() on that object — the click has to land
somewhere.
9 — Timer and loop
Kaplay has its own timers. Use them instead of Python's, because Kaplay's are tied to the frame loop and stay in step with the game.
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
loadSprite("bean", "images/bean.png")
# Every half second, for as long as the game runs.
@loop(0.5)
def spawn():
b = add([
sprite("bean"),
pos(rand(0, width()), rand(0, height())),
area(),
])
# ...and three seconds after it appears, it goes away again.
@wait(3)
def remove_it():
b.destroy()
What each part does.
@loop(seconds) runs the function over and over, that many seconds apart.
@wait(seconds) runs the function once, after a delay.
Both are measured in seconds and both are managed by Kaplay, so they pause and resume with the game and never drift out of step with what's on screen.
Notice the @wait(3) nested inside spawn. Each bean that appears schedules its
own removal, and b inside remove_it means that bean — the one this run of
spawn just made.
You will use wait() constantly: a delay before a respawn, a pause before the
game-over screen appears, a gap between waves of enemies.
10 — Levels
Drawing a map by hand gets old fast. addLevel() lets you draw it as a picture
made of characters.
from kaypy import *
kaypy(width=800, height=600, background=[141, 183, 255])
loadSprite("bean", "images/bean.png")
loadSprite("grass", "images/grass.png")
loadSprite("steel", "images/steel.png")
loadSprite("coin", "images/coin.png")
loadSprite("spike", "images/spike.png")
SPEED = 320
setGravity(2400)
layout = [
" ",
" ",
" ",
" $$ ",
" ===== $$ ",
" ===== ",
" @ ^^ $ ",
"==========================",
]
level = addLevel(layout, {
"tileWidth": 64,
"tileHeight": 64,
"pos": vec2(0, 0),
"tiles": {
"=": lambda: [sprite("grass"), area(), body(isStatic=True)],
"$": lambda: [sprite("coin"), area(), "coin"],
"^": lambda: [sprite("spike"), area(), "danger"],
"@": lambda: [sprite("bean"), area(), body(), anchor("bot"), "player"],
},
})
player = level.get("player")[0]
@onKeyDown("left")
def move_left():
player.move(-SPEED, 0)
@onKeyDown("right")
def move_right():
player.move(SPEED, 0)
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(1000)
# Touch a spike and you go back to the start.
@player.onCollide("danger")
def hit_spike(spike):
player.pos = level.tile2Pos(1, 6)
@player.onCollide("coin")
def collect(coin):
coin.destroy()
What each part does.
The layout is a list of strings. Each character becomes one tile, and the
tiles dictionary says what each character means. A space means nothing at all.
Each entry in tiles is a function returning a component list — that is why
every one of them is lambda: [...]. It has to be a function because Kaplay
calls it once per tile; a plain list would give every tile the same single
object. (lambda: [...] is just a one-line function that returns the list. It is
not an event handler, which is why it isn't written with @.)
tileWidth and tileHeight are the size of one tile in pixels. Match them to
your sprites — the bundled grass and steel are 64×64.
level.get("player")[0] finds the object you tagged player inside the level,
so you can control it. get returns a list, hence the [0].
level.tile2Pos(col, row) converts a tile position into real pixels. The @
above is at column 1, row 6.
Notice player.pos = ... in hit_spike. That is an ordinary assignment, and it
is only possible because the handler is a real function — you cannot assign
inside a lambda.
Design your map on paper first, or with an ASCII map editor. Keep all the rows the same length.
Exercises
- Send the player to a game-over scene when they fall off the bottom. Hint:
@onUpdateand checkplayer.pos.y > height().- Add a score that goes up when a coin is collected.
11 — Camera
When the level is bigger than the window, the camera follows the player.
from kaypy import *
kaypy(width=800, height=600, background=[141, 183, 255])
loadSprite("bean", "images/bean.png")
loadSprite("grass", "images/grass.png")
loadSprite("coin", "images/coin.png")
SPEED = 320
setGravity(2400)
layout = [
" ",
" ",
" $ $ $ $ ",
" ==== ===== ===== ====== ",
" ",
" @ $ ",
"=============================================",
]
level = addLevel(layout, {
"tileWidth": 64,
"tileHeight": 64,
"tiles": {
"=": lambda: [sprite("grass"), area(), body(isStatic=True)],
"$": lambda: [sprite("coin"), area(), "coin"],
"@": lambda: [sprite("bean"), area(), body(), anchor("bot"), "player"],
},
})
player = level.get("player")[0]
score = 0
# A score that does NOT scroll with the world.
score_label = add([
text("0", size=28),
pos(12, 12),
fixed(),
])
@onUpdate
def keep_camera_on_player():
setCamPos(player.pos)
@player.onCollide("coin")
def got_coin(c):
global score
c.destroy()
score += 1
score_label.text = str(score)
setCamScale(1 + score * 0.02) # zoom in a little with every coin
@onKeyDown("left")
def move_left():
player.move(-SPEED, 0)
@onKeyDown("right")
def move_right():
player.move(SPEED, 0)
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(1000)
# Where did I actually click, in the world?
@onClick
def report_click():
print("clicked world position:", toWorld(mousePos()))
What each part does.
setCamPos(position) points the camera somewhere. Calling it from @onUpdate
every frame is what makes it follow.
setCamScale(n) zooms. Bigger than 1 zooms in.
fixed() is the component that makes an object ignore the camera. Score labels,
health bars, buttons — anything that should stay stuck to the screen rather than
to the world — needs fixed(). Leave it off the score and it slides away as you
walk.
toWorld(mousePos()) converts a position on the screen into a position in the
world. Once the camera has moved, those are different things, and mixing them up
is a genuinely confusing bug. mousePos() is where the mouse is on screen;
toWorld(...) is what it's pointing at in the game.
global score here does the same job nonlocal did in Lesson 7 — score lives
at the top level of the file, so global is the right word for it.
12 — Sprite atlas
An atlas is one image holding many different sprites, each at a known position.
You load it once and name the pieces. dungeon.png is one — it is in the
Sprites panel under Sprite atlas, and next to your game.py on disk.
Clicking it in the playground inserts all of this:
loadSpriteAtlas("dungeon.png", {
"wall": {"x": 16, "y": 16, "width": 16, "height": 16},
"floor": {"x": 16, "y": 64, "width": 48, "height": 48, "sliceX": 3, "sliceY": 3},
"hero": {
"x": 128, "y": 196, "width": 144, "height": 28, "sliceX": 9,
"anims": {
"idle": {"from": 0, "to": 3, "speed": 3, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True},
"hit": 8,
},
},
"ogre": {
"x": 16, "y": 336, "width": 256, "height": 32, "sliceX": 8,
"anims": {
"idle": {"from": 0, "to": 3, "speed": 3, "loop": True},
"run": {"from": 4, "to": 7, "speed": 10, "loop": True},
},
},
"chest": {
"x": 304, "y": 400, "width": 48, "height": 16, "sliceX": 3,
"anims": {
"open": {"from": 0, "to": 2, "speed": 20, "loop": False},
"close": {"from": 2, "to": 0, "speed": 20, "loop": False},
},
},
})
The published coordinates for this image are wrong, and nothing will tell you. The version you find online has
ogreaty: 320andchestaty: 304. Both are wrong for this image:y: 320is 16 pixels above the ogres, so you get the top half of an ogre and a strip of floor, andy: 304is empty space, so the chest loads with nothing in it. Neither one is an error — Kaplay will cut out a rectangle of nothing without complaint. Which is the whole lesson: the numbers are not checked for you, so when a sprite comes out wrong or missing, suspect the rectangle first.
What each part does.
Each entry says where its sprite sits in the big image: x and y are how far
in from the left and down from the top, width and height how big the whole
strip is.
sliceX and sliceY cut that strip into frames — "sliceX": 9 means nine
frames side by side.
anims names ranges of those frames, exactly as in Lesson 6. An animation can
also be a single number ("hit": 8 means "frame 8").
Animations are optional. floor and wall above have none — they are just
pictures.
Atlas or ready-made? The dungeon pack — the Sprites panel, or
examples/dungeon/ on disk — is this same artwork, already cut up and named.
Reaching for one of those is faster and you cannot get a rectangle wrong. Use the
atlas when the artwork you want is in one image somebody else laid out, which is
most artwork you will find online.
Building a level from an atlas is the same addLevel() as Lesson 10, run twice:
once for the floor, then once for the walls and objects on top, so things sit
above the floor rather than under it.
# The floor, drawn first. A space means floor here, so we don't have to type
# a character for every square.
addLevel(floor_layout, {
"tileWidth": 16,
"tileHeight": 16,
"tiles": {
" ": lambda: [sprite("floor", frame=randi(0, 8))],
},
})
# Then everything that stands on it.
addLevel(map_layout, {
"tileWidth": 16,
"tileHeight": 16,
"tiles": {
"#": lambda: [sprite("wall"), area(), body(isStatic=True), tile(isObstacle=True)],
"$": lambda: [sprite("chest"), area(), tile()],
},
})
frame=randi(0, 8) picks a random floor tile, so the floor is different every
time the game loads.
tile() marks an object as occupying a square on a grid.
Exercise
Make the chests play their open animation when space is pressed.
13 — Using state to handle AI
A state machine gives an object a mode — idle, attack, move — and different behaviour in each. It is how nearly all simple game AI is written.
from kaypy import *
kaypy(width=800, height=600, background=[0, 0, 0])
loadSprite("bean", "images/bean.png")
loadSprite("ghosty", "images/ghosty.png")
loadSprite("coin", "images/coin.png")
SPEED = 320
player = add([
sprite("bean"),
pos(100, 300),
area(),
anchor("center"),
"player",
])
enemy = add([
sprite("ghosty"),
pos(600, 300),
area(),
anchor("center"),
# Start in "move", and these are the only three modes it can be in.
state("move", ["idle", "attack", "move"]),
])
# --- what each state means -------------------------------------------------
# Idle: stand still for half a second, then attack.
@enemy.onStateEnter("idle")
def start_idle():
@wait(0.5)
def then_attack():
enemy.enterState("attack")
@enemy.onStateEnter("attack")
def start_attack():
if player.exists():
direction = player.pos.sub(enemy.pos).unit()
add([
sprite("coin"),
pos(enemy.pos),
anchor("center"),
area(),
move(direction, 400),
offscreen(destroy=True),
"bullet",
])
@wait(1)
def then_move():
enemy.enterState("move")
# Move: chase for two seconds, then go idle.
@enemy.onStateEnter("move")
def start_move():
@wait(2)
def then_idle():
enemy.enterState("idle")
# This runs every frame, but ONLY while the state is "move".
@enemy.onStateUpdate("move")
def chase():
enemy.moveTo(player.pos, 120)
# --- getting hit -----------------------------------------------------------
@player.onCollide("bullet")
def shot(bullet):
bullet.destroy()
addKaboom(player.pos)
player.destroy()
@onKeyDown("left")
def move_left():
player.move(-SPEED, 0)
@onKeyDown("right")
def move_right():
player.move(SPEED, 0)
@onKeyDown("up")
def move_up():
player.move(0, -SPEED)
@onKeyDown("down")
def move_down():
player.move(0, SPEED)
What each part does.
state(starting, [all_possible]) gives the object a mode. It starts in the first
one, and can only ever be in one of the listed modes.
@obj.onStateEnter(name) runs once, the moment the object enters that state.
@obj.onStateUpdate(name) runs every frame while it is in that state — think of
it as an onUpdate that only applies to one mode.
.enterState(name) switches modes. Notice that every state ends by scheduling a
switch to another one with @wait(...), which is what makes the cycle turn:
move → idle → attack → move.
Compare shot above with what it would have to be as a single expression. Three
separate things happen — destroy the bullet, draw the explosion, destroy the
player — and a lambda can only hold one.
player.exists() checks the player hasn't already been destroyed. Without it,
the enemy would try to aim at something that isn't there.
player.pos.sub(enemy.pos).unit() is the direction from the enemy to the player:
subtract the positions to get the difference, then .unit() shrinks it to length
1 so it is a pure direction.
move(direction, speed) is a component that makes an object drift in a straight
line forever. offscreen(destroy=True) cleans it up once it leaves the screen —
without that, every bullet ever fired is still in memory.
addKaboom(position) plays Kaplay's explosion animation.
If you have seen the JavaScript version of this lesson
It used
asyncandawaitinsideonStateEnter, which needed explaining and apologising for. Python has no equivalent here, so instead each state schedules the next one with@wait(seconds). Same behaviour, one less concept.
Quick reference
Everything used in this guide.
Starting up
kaypy(width=, height=, background=) |
start the engine, always first |
loadSprite(name, path) |
make a picture available |
loadSprite(name, [paths], anims={}) |
build frames from several pictures |
loadSprite(name, path, sliceX=, anims={}) |
cut a spritesheet into frames |
loadSpriteAtlas(path, {...}) |
name many sprites inside one image |
loadSound(name, path) |
make a sound available |
setGravity(n) / setBackground(r, g, b) |
world settings |
Making things
add([components]) |
build a game object |
obj.add([components]) |
build a child of that object |
obj.destroy() |
remove it |
get("tag") |
every object with that tag |
addLevel(layout, config) |
build a map out of characters |
addKaboom(pos) |
the explosion animation |
Components
sprite(name) |
draw a picture — gives .play(), .flipX, .curAnim() |
text(str, size=, width=) |
draw words — gives .text |
rect(w, h, radius=) / circle(r) |
draw a shape |
pos(x, y) |
position — gives .move(), .moveTo(), .pos |
area() |
collision box — gives .onCollide(), .onClick(), .isHovering() |
body(isStatic=, mass=, jumpForce=) |
physics — gives .jump(), .isGrounded(), .onGround() |
state(start, [all]) |
modes — gives .onStateEnter(), .onStateUpdate(), .enterState() |
anchor("center") |
which point pos() refers to |
scale(n), color(r,g,b), opacity(n), outline(n), z(n) |
appearance |
fixed() |
ignore the camera |
move(dir, speed) |
drift in a straight line |
offscreen(destroy=True) |
clean up when it leaves the screen |
tile(isObstacle=) |
occupies a square on a grid |
health(hp) |
hit points — gives .hp, .hurt(), .heal(), .onDeath() |
lifespan(secs, fade=) |
destroys itself after a while — for bullets and explosions |
"a string" |
a tag |
Events
Each one goes above a function, as @onKeyDown("left"). The ones with nothing to
configure — @onUpdate, @onClick, @obj.onGround — take no brackets.
@onUpdate |
every frame |
@onUpdate("tag") |
every frame, for each tagged object |
@onKeyDown / @onKeyPress / @onKeyRelease (key) |
held / pressed once / let go |
@onClick |
left mouse button clicked anywhere |
@onMousePress("left") / @onMouseRelease / @onMouseDown |
a button went down / came up / is held |
@onMouseMove |
the mouse moved |
@onDraw |
draw straight to the screen, after everything else |
@obj.onClick |
that object clicked (needs area()) |
@obj.onCollide("tag") |
touched something tagged |
@obj.onCollideUpdate / @obj.onCollideEnd |
while touching / when it stops |
@obj.onGround |
landed on something (needs body()) |
@wait(seconds) |
once, after a delay |
@loop(seconds) |
over and over |
Every one of these also takes a function directly — onKeyDown("left", walk) —
which is what Kaplay's JavaScript examples translate to.
Scenes
@scene("name") |
define one |
go("name", value) |
switch to it, optionally passing something |
Useful values
width(), height(), center() |
the size and middle of the window |
dt() |
seconds since the last frame |
vec2(x, y) |
a position — has .x, .y, .sub(), .unit() |
rand(a, b), randi(a, b), choose(list) |
randomness |
mousePos(), toWorld(pos) |
on screen / in the world |
isMouseDown("left"), isMousePressed(), isMouseReleased() |
ask about a button rather than being told |
isMouseMoved(), mouseDeltaPos() |
did it move this frame, and how far |
drawRect, drawCircle, drawLine, drawText, drawSprite |
inside @onDraw only — fixed=True for a HUD |
setData(key, value), getData(key, default) |
remember a high score between runs |
setCamPos(pos), setCamScale(n), shake(n) |
the camera |
play(name, loop=, paused=, volume=) |
play a sound |
debug.inspect = True |
show every collision box (or press F1) |
When something doesn't work
"has no attribute 'jump'" — the object is missing the component that provides
it. .jump() and .isGrounded() come from body(); .move() from pos();
.onCollide() from area().
A collision never fires — one of the two objects has no area(). Turn on
debug.inspect = True and look at the boxes.
Nothing appears at all — you called scene() but never go(), or you loaded
a sprite but never added it.
The sprite is invisible but the game runs — check the path. It should look
like "images/bean.png" or "dungeon/elf_m.png". In the playground it must be a file the
Sprites panel lists; on your own machine you need to be running from the folder
those live in. Either way the two packs are separate folders, and a dungeon
sprite is not in images/.
Every frame is half one pose and half the next — sliceX doesn't match the
spritesheet.
The animation is stuck on one frame — you are calling .play() every frame.
Guard it with if obj.curAnim() != "run".
The score never changes — a function inside another function needs
nonlocal score, or global score if the score lives at the top level of the
file.
An event does nothing, and you wrote it with @ — check whether it needed
brackets. @onKeyDown("space") has something to configure, so it takes them;
@onUpdate has nothing, so it doesn't. Writing @onUpdate() or @onKeyDown
without its key won't do what you meant.
The keys do nothing — click the picture once. In the playground, and in a web build, the game only gets the keyboard once the canvas has focus. (A desktop window takes focus by itself.)