Tutorials
Whole games, short enough to read in one sitting.
The guide teaches one idea at a time and builds up. This page is the other half of that: finished programs you can copy into the playground, press Run, and then take apart. Nothing here is a fragment — every listing is a complete file that runs as it stands.
How to use one. Copy it, run it, then break something on purpose. Change a number and see what moves. That tells you more about what a line does than reading it does.
Whole games
A door that asks a question — what ask(), say() and pause() are for
python3 quiz_door.py
Arrows to move, space to jump, escape to pause. Walk into the door and the game stops and asks you something; get it right and the door opens.
Not one of the thirteen lessons: it is the smallest complete example of the one thing a teacher asks for that a game engine usually cannot do. Three names here appear nowhere in the lessons:
ask(question, choices, answer=n) stop and ask, then run a callback
say(text) stop and show a line of text
pause() / resume() / isPaused() freeze the game where it stands
The question is a callback rather than a return value because a browser
cannot block: nothing can wait for an answer without stopping the frame that
would draw the question. So ask puts the panel up and hands you the answer
when there is one, and the game is paused in between.
from kaypy import *
kaypy(width=800, height=600, background=[141, 183, 255])
loadSprite("bean", "images/bean.png")
setGravity(2400)
add([rect(width(), 48), pos(0, height() - 48), area(), body(isStatic=True),
color(90, 150, 70)])
player = add([sprite("bean"), pos(80, 100), area(), body(), anchor("bot")])
door = add([rect(40, 90), pos(650, height() - 138), area(), color(140, 90, 40),
"door"])
score = 0
label = add([text("Score: 0", size=26), pos(12, 12), fixed()])
@onKeyDown("left")
def left():
player.move(-320, 0)
@onKeyDown("right")
def right():
player.move(320, 0)
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(1000)
@player.onCollide("door")
def at_the_door(d):
@ask("Which keyword starts a loop in Python?", ["if", "for", "def"], answer=1)
def checked(correct):
global score
if correct:
score += 1
label.text = "Score: %d" % score
d.destroy()
say("The door swings open.")
else:
say("Not that one. Have another go.")
@onKeyPress("escape")
def toggle_pause():
resume() if isPaused() else pause()
examples/quiz_door.py · 71 lines
A pet that trails you and a health bar that never does — what follow() is for
python3 pet_and_healthbar.py
Arrows to move, space to jump, F to hurt the enemy. The ghost chases you and falls behind when you run; the red bar over the enemy is welded to it and never falls behind at all.
Both of those are one component. The difference between them is the word
speed:
follow(target) be exactly where it is
follow(target, speed=180) move toward it at 180 pixels a second
The bar is the interesting half. It is pinned to an object that gravity moves, that collision shoves out of the floor, and that a keypress teleports — and it stays put through all three, because follow() runs after the engine has finished deciding where everything actually ended up, rather than before like every other component. Without that it would sit correctly while the enemy stands still and slide off it the moment the enemy moved, which looks like a drawing bug and is not one.
from kaypy import *
kaypy(width=800, height=600, background=[141, 183, 255])
loadSprite("bean", "images/bean.png")
loadSprite("ghosty", "images/ghosty.png")
setGravity(1600)
add([rect(width(), 48), pos(0, height() - 48), area(),
body(isStatic=True), color(90, 150, 70)])
player = add([sprite("bean"), pos(120, 300), area(), body()])
# Locked on: no speed, so it is simply where the player is, plus an offset.
add([text("you", size=18), pos(0, 0), color(30, 40, 60),
follow(player, offset=vec2(4, -28))])
# Chasing: with a speed, it heads for the player and arrives when it arrives.
add([sprite("ghosty"), pos(600, 200), follow(player, speed=180)])
enemy = add([sprite("ghosty"), pos(560, 400), area(), body(), health(5)])
# A health bar over something gravity is pulling down and the floor is
# pushing back up. This is the case that shows whether follow() runs early
# or late.
BAR = 48
backing = add([rect(BAR, 6), pos(0, 0), color(40, 20, 20),
follow(enemy, offset=vec2(0, -14))])
bar = add([rect(BAR, 6), pos(0, 0), color(220, 60, 60),
follow(enemy, offset=vec2(0, -14))])
@onKeyDown("left")
def go_left():
player.move(-320, 0)
@onKeyDown("right")
def go_right():
player.move(320, 0)
@onKeyPress("space")
def jump():
if player.isGrounded():
player.jump(760)
@onKeyPress("f")
def hurt():
if enemy.exists():
enemy.hurt(1)
bar.width = BAR * max(0, enemy.hp) / 5
@enemy.onDeath
def gone():
# A follower whose target is destroyed stops where it stands and goes on
# existing — which is right for a pet and wrong for a health bar, so the
# bars are destroyed here, next to the thing they belonged to.
enemy.destroy()
backing.destroy()
bar.destroy()
add([text("arrows move · space jumps · F hurts the ghost", size=20),
pos(12, 12), color(20, 30, 50), fixed()])
examples/pet_and_healthbar.py · 89 lines
A sword that goes where the knight goes — what child objects are for
python3 knight_and_camera.py
Arrows to move, space to swing, 1/2/3 to change the camera. The sword, the shield and the name tag are all children of the knight: nothing in the movement code mentions any of them, and they follow anyway.
knight = add([sprite("bean"), pos(200, 300), area()])
sword = knight.add([rect(8, 34), pos(26, -4), color(220, 220, 235)])
A child's pos() is measured from its parent, not from the screen. The
sword sits at +26, -4 from the knight and stays there through walking,
turning and being teleported, because there is nothing to keep in step —
it is one position, expressed once. Destroy the knight and the sword goes
with it.
That is the difference between this and follow(): a follower is a separate object being kept in agreement every frame, which is what you want when the two things meet later in the game. A child is part of the thing.
THE CAMERA KEYS
1 setCamScale(1) normal
2 setCamScale(2) zoomed in, both ways
3 setCamScale(vec2(2, 0.6)) wide and squashed
The third is the one worth pressing. The scale is a vec2, so the two axes can differ — a letterboxed cutscene, a squash as something lands, or a deliberately wrong aspect ratio for a dream sequence. Everything obeys it together: sprites, the children, the drawn cone of the swing, and the debug boxes under F1.
import math
from kaypy import *
kaypy(width=800, height=600, background=[26, 30, 42])
loadSprite("bean", "images/bean.png")
add([rect(2000, 40), pos(-600, 470), area(), body(isStatic=True),
color(60, 70, 90)])
setGravity(1800)
knight = add([sprite("bean"), pos(200, 300), area(), body(), "knight"])
# Everything below hangs off the knight. None of it is mentioned again in
# the movement code.
sword = knight.add([rect(8, 34), pos(26, -4), color(220, 220, 235),
anchor("bot"), rotate(0)])
knight.add([rect(6, 24), pos(-10, 4), color(150, 110, 60)]) # shield
knight.add([text("Sir Bean", size=14), pos(-4, -22), color(200, 210, 230)])
add([text("arrows move · space swings · 1 2 3 camera", size=18),
pos(12, 12), color(140, 150, 170), fixed()])
swing = [0.0]
@onKeyDown("left")
def go_left():
knight.move(-240, 0)
@onKeyDown("right")
def go_right():
knight.move(240, 0)
@onKeyPress("space")
def jump_or_swing():
swing[0] = 0.35
if knight.isGrounded():
knight.jump(700)
@onUpdate
def animate_sword():
if swing[0] > 0:
swing[0] = max(0.0, swing[0] - dt())
# A swing is just the child's own angle. The knight knows nothing.
sword.angle = -110 * math.sin((0.35 - swing[0]) / 0.35 * math.pi)
else:
sword.angle = 0
@onUpdate
def chase():
setCamPos(vec2(knight.pos.x, 300))
@onKeyPress("1")
def normal():
setCamScale(1)
@onKeyPress("2")
def close():
setCamScale(2)
@onKeyPress("3")
def letterbox():
setCamScale(vec2(2, 0.6))
examples/knight_and_camera.py · 105 lines
Sneak past a guard who cannot see through walls — what sentry() is for
python3 stealth_guard.py
Arrows to move. The guard sweeps a torch left and right; get into the cone and it spots you. Hide behind a crate and it does not, because the cone is light and the crate is solid.
The whole of the seeing is one component:
sentry("player", fieldOfView=70, range=300, lineOfSight=True)
fieldOfView is the width of the cone in degrees. range is how far the
torch reaches — not KAPLAY's, added because "how close can I get" is the
question a stealth game is made of. lineOfSight casts a ray at you and
lets the crates stop it.
There is no direction here on purpose: without one, the sentry looks
wherever rotate() has the guard turned, so sweeping the torch is a matter
of changing guard.angle and the cone follows.
The cone you can see is drawn by hand in onDraw, out of the same three numbers the component was given. Drawing it is worth the twenty lines: a vision cone you cannot see is a rule the player has to infer by dying.
import math
from kaypy import *
kaypy(width=800, height=600, background=[18, 20, 30])
loadSprite("bean", "images/bean.png")
loadSprite("ghosty", "images/ghosty.png")
FOV = 70
RANGE = 300
player = add([sprite("bean"), pos(80, 500), area(), "player"])
# Crates. Solid, so they stop the guard's ray — and tagged, so the ray can
# be told about them.
for x, y in [(300, 180), (300, 260), (300, 340), (560, 420), (560, 500)]:
add([rect(56, 56), pos(x, y), area(), color(90, 74, 58), outline(2),
"crate"])
guard = add([
sprite("ghosty"), pos(400, 120), area(), rotate(90),
sentry("player", fieldOfView=FOV, range=RANGE, lineOfSight=True),
"guard",
])
caught = add([text("", size=28), pos(12, 12), color(255, 120, 120), fixed()])
add([text("arrows to move · hide behind the crates", size=18),
pos(12, 560), color(150, 160, 180), fixed()])
@onKeyDown("left")
def go_left():
player.move(-220, 0)
@onKeyDown("right")
def go_right():
player.move(220, 0)
@onKeyDown("up")
def go_up():
player.move(0, -220)
@onKeyDown("down")
def go_down():
player.move(0, 220)
# The torch sweeps between 50 and 130 degrees — down and to either side.
@onUpdate
def sweep():
guard.angle = 90 + 40 * math.sin(time() * 0.8)
@guard.onObjectsSpotted
def spotted(objects):
caught.text = "Spotted!"
shake(8)
@onUpdate
def forget():
# .spotted is the current answer, for the frames between edges.
if not guard.spotted and caught.text:
caught.text = ""
@onDraw
def draw_cone():
"""The cone, drawn from the same numbers the sentry was given.
Its far edge is cut short wherever a crate is in the way, which is the
same question the component asks — so what you see and what the guard
sees cannot disagree.
"""
seeing = bool(guard.spotted)
edge = (255, 210, 120) if not seeing else (255, 120, 120)
points = [guard.pos]
steps = 24
for i in range(steps + 1):
angle = guard.angle - FOV / 2 + FOV * i / steps
along = Vec2.fromAngle(angle)
hit = raycast(guard.pos, along, exclude=["player"], ignore=[guard],
max_distance=RANGE)
reach = hit.distance if hit else RANGE
points.append(guard.pos + along * reach)
drawLines(points=points, width=2, color=edge, opacity=0.55, close=True)
examples/stealth_guard.py · 117 lines
Asteroids — what rotate() is for
python3 asteroids.py
Left and right turn the ship, up thrusts in the direction it is facing, space fires. Rocks drift, split when hit, and wrap around the screen.
Not one of the thirteen lessons: it is the game the lessons build towards, and the one that cannot be written at all without a ship that turns. Three things here appear nowhere in the lessons and are the whole point of it:
rotate(angle) the ship turns
Vec2.fromAngle(angle) which way "forward" is, once it has turned
a velocity of its own momentum, so letting go of thrust coasts
from kaypy import *
kaypy(width=800, height=600, background=[8, 8, 20])
TURN = 200 # degrees per second
THRUST = 320 # pixels per second per second
MAX_SPEED = 420
BULLET_SPEED = 560
score = 0
ship = add([
rect(26, 18),
pos(center()),
anchor("center"),
color(200, 230, 255),
rotate(0),
area(),
"ship",
])
ship.vel = vec2(0, 0)
label = add([text("0", size=22), pos(12, 10), fixed()])
def wrap(obj):
"""Off one edge, on at the other — the rule that makes it Asteroids."""
p = obj.pos
if p.x < 0:
obj.pos = vec2(width(), p.y)
elif p.x > width():
obj.pos = vec2(0, p.y)
p = obj.pos
if p.y < 0:
obj.pos = vec2(p.x, height())
elif p.y > height():
obj.pos = vec2(p.x, 0)
def spawn_rock(at=None, chunks=3):
r = add([
circle(chunks * 11),
pos(at or vec2(rand(0, width()), rand(0, 60))),
anchor("center"),
color(150, 140, 130),
outline(2, (90, 85, 80)),
area(),
rotate(rand(0, 360)),
"rock",
])
r.vel = Vec2.fromAngle(rand(0, 360)) * rand(40, 110)
r.spin = rand(-90, 90)
r.chunks = chunks # 3, 2, 1 — splits down to nothing
return r
for _ in range(4):
spawn_rock()
@onKeyDown("left")
def turn_left():
ship.rotateBy(-TURN * dt())
@onKeyDown("right")
def turn_right():
ship.rotateBy(TURN * dt())
@onKeyDown("up")
def thrust():
ship.vel = ship.vel + Vec2.fromAngle(ship.angle) * THRUST * dt()
if ship.vel.len() > MAX_SPEED:
ship.vel = ship.vel.unit() * MAX_SPEED
@onKeyPress("space")
def fire():
b = add([
circle(3),
pos(ship.pos),
anchor("center"),
color(255, 240, 160),
area(),
"bullet",
])
b.vel = Vec2.fromAngle(ship.angle) * BULLET_SPEED
wait(1.2, lambda: b.destroy() if b.exists() else None)
@onUpdate
def fly():
ship.pos = ship.pos + ship.vel * dt()
wrap(ship)
for r in get("rock"):
r.pos = r.pos + r.vel * dt()
r.rotateBy(r.spin * dt())
wrap(r)
for b in get("bullet"):
b.pos = b.pos + b.vel * dt()
wrap(b)
@onUpdate
def shooting():
global score
for b in get("bullet"):
for r in get("rock"):
if not (b.exists() and r.exists()):
continue
if b.pos.dist(r.pos) < r.chunks * 11:
b.destroy()
r.destroy()
score += 10 * r.chunks
label.text = str(score)
shake(6)
if r.chunks > 1:
for _ in range(2):
spawn_rock(r.pos, r.chunks - 1)
break
examples/asteroids.py · 136 lines
The lesson programs
Each of these is the finished program from one lesson of the guide, which explains it a piece at a time. Read the lesson; keep the file to run.
| What it shows | Lines | ||
|---|---|---|---|
| 1 | Adding a game object | 12 | source |
| 2 | Player movement | 25 | source |
| 3 | Collision handling | 48 | source |
| 5 | Gravity | 39 | source |
| 6 | Sprite animation | 67 | source |
| 7 | Scenes | 91 | source |
| 8 | Audio and buttons | 38 | source |
| 9 | Timer and loop | 17 | source |
| 10 | Levels | 43 | source |
| 11 | Camera | 58 | source |
| 12 | Sprite atlas | 100 | source |
| 13 | Using state to handle AI | 61 | source |
Coming next
Coin Collector, a thirteen-step platform game built from nothing — the one to work through once the guide's lessons make sense. It exists as a classroom walkthrough written for a different engine, and is being rewritten for KayPy rather than translated, because the two think about a game in genuinely different ways and a line-by-line translation would teach the seams instead of the game.
If you are teaching with this and want it sooner, or want something else first, say so on the Discord.