Learning Goals
3 minBy the end of this lesson you can:
- Read the whole 150-line program and say what each block is for.
- Add a collectable with a score, reusing
overlaps()and nothing else. - Implement stomping, and explain how the game tells a stomp from a bump.
- Add coyote time, and say why generous games feel better than accurate ones.
- Playtest your own game against a checklist rather than a feeling.
Warm-Up · Check Your Listing
5 minEverything from stages 01 to 12, assembled. Open yours beside it. You are not looking for identical text — your level and tuning should differ by now — you are looking for the same order: settings, player, level, monsters, helpers, update, on_key_down, respawn, draw.
The whole program
import pgzrun from pgzhelper import * WIDTH = 800 HEIGHT = 600 # --- tuning --- GRAVITY = 0.6 # pulls the player down every frame MOVE_SPEED = 5 # sideways pixels per frame JUMP_SPEED = -14 # negative = upwards MAX_FALL = 18 # terminal velocity, stops tunnelling through platforms TILE_W = 60 # platform1.png is 60 x 30 TILE_H = 30 MONSTER_SPEED = 2 # skeletons walk slower than the player MAX_HP = 100 # a full health bar TOUCH_DAMAGE = 10 # hp lost each time a skeleton touches you HURT_COOLDOWN = 1.0 # seconds of mercy before the same touch hurts again # --- player --- # player.png is 900x900 but the character only fills a 434x570 area in the # middle. subrect crops to that box so the collision rect matches the drawing. player = Actor('player', subrect=(210, 181, 434, 570)) player.scale = 0.12 player.pos = (100, 200) player.vx = 0 player.vy = 0 player.on_ground = False player.hp = MAX_HP player.hurt_timer = 0 # seconds left before he can be hurt again START_POS = player.pos # --- level: (x, y, how many tiles wide) --- LEVEL = [ (0, 570, 5), # ground, left half (420, 570, 7), # ground, right half (gap in between = a pit) (60, 360, 2), (200, 470, 3), (240, 250, 3), (400, 390, 3), (620, 310, 2), ] platforms = [] for x, y, count in LEVEL: for i in range(count): platforms.append(Actor('platform1', topleft=(x + i * TILE_W, y))) # --- monsters: (x, y) = where the feet stand, y is a platform's top --- MONSTERS = [ (250, 570), (620, 570), (300, 470), (500, 390), ] monsters = [] for x, y in MONSTERS: # skeleton.png is 599x333; the drawing sits in a 240x278 box inside it m = Actor('skeleton', subrect=(211, 27, 240, 278)) m.scale = 0.22 m.midbottom = (x, y) m.home = (x, y) # where respawn() puts it back m.vx = MONSTER_SPEED monsters.append(m) def overlaps(a, b): """True if two actors' rectangles overlap.""" return (a.right > b.left and a.left < b.right and a.bottom > b.top and a.top < b.bottom) def ground_at(x, y): """True if some platform covers the single point (x, y).""" for p in platforms: if p.left <= x < p.right and p.top <= y < p.bottom: return True return False def update_monsters(): for m in monsters: m.x += m.vx # look one pixel past the toes and a little below the feet. toes = m.right + 1 if m.vx > 0 else m.left - 1 turn = not ground_at(toes, m.bottom + 2) for p in platforms: if overlaps(m, p): if m.vx > 0: m.right = p.left else: m.left = p.right turn = True if m.left < 0: m.left = 0 turn = True elif m.right > WIDTH: m.right = WIDTH turn = True if turn: m.vx = -m.vx m.flip_x = m.vx < 0 def update(dt): # ---- horizontal movement ---- player.vx = 0 if keyboard.a: player.vx = -MOVE_SPEED if keyboard.d: player.vx = MOVE_SPEED if player.vx < 0 and not player.flip_x: player.flip_x = True elif player.vx > 0 and player.flip_x: player.flip_x = False player.x += player.vx for p in platforms: if overlaps(player, p): if player.vx > 0: player.right = p.left elif player.vx < 0: player.left = p.right if player.left < 0: player.left = 0 if player.right > WIDTH: player.right = WIDTH # ---- vertical movement ---- player.vy = min(player.vy + GRAVITY, MAX_FALL) player.y += player.vy player.on_ground = False for p in platforms: if overlaps(player, p): if player.vy > 0: player.bottom = p.top player.on_ground = True elif player.vy < 0: player.top = p.bottom player.vy = 0 # ---- monsters ---- update_monsters() if player.hurt_timer > 0: player.hurt_timer -= dt for m in monsters: if overlaps(player, m): if player.hurt_timer <= 0: player.hp -= TOUCH_DAMAGE player.hurt_timer = HURT_COOLDOWN if player.hp <= 0: respawn() break if player.top > HEIGHT: respawn() def on_key_down(key): if key == keys.SPACE and player.on_ground: player.vy = JUMP_SPEED player.on_ground = False elif key == keys.R: respawn() def respawn(): player.pos = START_POS player.vx = 0 player.vy = 0 player.hp = MAX_HP player.hurt_timer = 0 for m in monsters: m.midbottom = m.home m.vx = MONSTER_SPEED m.flip_x = False def draw_health(): bar = Rect(560, 12, 220, 26) screen.draw.filled_rect(bar, (30, 30, 40)) if player.hp > 0: fill = Rect(bar.x, bar.y, bar.width * player.hp // MAX_HP, bar.height) screen.draw.filled_rect(fill, (200, 50, 50)) screen.draw.rect(bar, (255, 255, 255)) screen.draw.text("%d / %d" % (player.hp, MAX_HP), center=bar.center, fontsize=24, color="white") def draw(): screen.fill((92, 148, 200)) for p in platforms: p.draw() for m in monsters: m.draw() # blink five times a second while the mercy window is running if player.hurt_timer <= 0 or int(player.hurt_timer * 10) % 2 == 0: player.draw() screen.draw.text("A / D = move SPACE = jump R = respawn", topleft=(10, 10), fontsize=28, color="white") draw_health() pgzrun.go()
New Concept · Generous Games Feel Better Than Accurate Ones
12 minYour collision code is correct. Walk off a ledge and press jump one frame later and nothing happens — because you were, accurately, in mid-air. Players do not experience it that way. They pressed jump "at the edge" and the game ignored them.
Coyote time is the standard fix, named after the cartoon coyote who only falls once he looks down: keep allowing a jump for a few frames after on_ground stops being true.
COYOTE_FRAMES = 6 # about a tenth of a second # in update(), just after the landing loop: if player.on_ground: player.coyote = COYOTE_FRAMES elif player.coyote > 0: player.coyote -= 1 # and the jump becomes: if key == keys.SPACE and player.coyote > 0: player.vy = JUMP_SPEED player.coyote = 0 # spend it, so you cannot double jump
Nobody notices coyote time when it is there. Everybody notices when it is missing — they just call the game "floaty" or "unresponsive" without knowing why. Almost every platformer you have played has this, along with a jump buffer that remembers a press made just before landing.
Two conditions, both about the frame in which you touched the monster: you were falling (player.vy > 0), and your feet were near the top of it. Any other contact still hurts. That is the whole rule — the same "which way was I moving?" question that decided every collision repair in this module.
Build Log · Upgrades 13–15
14 minThree upgrades, each reusing something you already have. Add them in order, and run after each.
Coins and a score
A second list of Actors, one overlaps check, one number on screen. No new ideas at all.
platformer.py · add after the monsters loop
COINS = [(120, 540), (270, 440), (300, 220), (450, 360), (660, 280)] coins = [] for x, y in COINS: c = Actor('platform1') # swap in a coin image if you have one c.scale = 0.4 c.midbottom = (x, y) coins.append(c) score = 0
platformer.py · add in update(dt), after the monster block
global score for c in list(coins): if overlaps(player, c): coins.remove(c) score += 1
platformer.py · add in draw()
for c in coins: c.draw() screen.draw.text("Coins: %d / %d" % (score, len(COINS)), topleft=(10, 44), fontsize=26, color="white")
list(coins) makes a copy, because removing from a list while looping over it skips the next entry. global score is needed because score is a plain number rather than an attribute — which is exactly why the player's own state lives on player.
Run it — checkpoint 13
- Five coins sit on ledges; walking into one removes it and the counter goes up.
- The counter never goes above the total.
- Decide what a respawn should do to collected coins — and make
respawn()say so.
Stomping
Replace the monster-contact block from PLT-07 with one that checks for a stomp first.
platformer.py · replace the monster collision block
for m in monsters: if overlaps(player, m): falling_onto_it = player.vy > 0 and player.bottom < m.top + 20 if falling_onto_it: monsters.remove(m) player.vy = JUMP_SPEED / 2 # a small bounce elif player.hurt_timer <= 0: player.hp -= TOUCH_DAMAGE player.hurt_timer = HURT_COOLDOWN if player.hp <= 0: respawn() break
The + 20 is a tolerance: the feet must be near the skeleton's head, not merely above its middle. Too small and legitimate stomps are counted as bumps; too large and running into a monster's chest kills it. Tune it by playing.
Stomped monsters are gone from monsters, so a fresh life needs them back. The cleanest fix is to rebuild the list in respawn() from MONSTERS, exactly as you built it at the start — which is a good argument for having kept that data.
Run it — checkpoint 14
- Landing on a skeleton's head removes it and bounces you.
- Walking into its side still costs 10 and makes you blink.
- Jumping up into one from below still hurts —
vyis negative there, so it is not a stomp.
Coyote time
Add player.coyote = 0 with the other player attributes, then the code from §03. Test it honestly: run off the edge of the top ledge and press jump a fraction late. Before, nothing. Now, a jump.
Run it — checkpoint 15
- A jump pressed just after leaving a ledge still works.
- A jump pressed while genuinely falling — say, halfway down the pit — does not.
- You cannot double jump: the coyote count is spent when it is used.
Try It Yourself · Pick Your Own Upgrade
13 minChoose one and build it properly rather than three half-finished ones.
A counter that respawn() decrements, drawn next to the controls. At zero, put everything back and start the game again from full.
Give one platform its own vx and reverse it at two x limits. When the player is standing on it, add that vx to his position too — otherwise it slides out from under his feet.
Hint
In the landing loop you already know which platform he landed on. Remember it — player.riding = p — and after the platform has moved, apply player.x += player.riding.vx. Clear riding whenever on_ground is false.
Add a state variable holding "title", "play" or "won". draw() and update() both branch on it. Collecting every coin sets "won"; pressing space on the title screen starts the game.
Mini-Challenge 🔥 · Playtest Somebody Else's Game
8 minSwap laptops with a partner. Play their game for five minutes without asking any questions, and fill in this report. They do the same with yours.
- What did you think you were meant to do? Write it before they tell you.
- Where did you die first, and did it feel fair?
- One thing that felt good. Be specific — "the bounce after a stomp", not "it's fun".
- One thing you would change first, and the line or number you think it is.
- Any platform you never reached? If so, could you tell whether it was meant to be reachable?
Watching in silence is the hard part and the whole point. Where they hesitate is a bug in your game, not in their reading.
If their game misbehaves, this table covers nearly everything that goes wrong in this project.
| What you see | What it means |
|---|---|
| ModuleNotFoundError: pgzrun | Pygame Zero is not installed for the Python that ran the file. |
| Unable to load image | The image is not in images/, or the name is spelled differently. It is platform1, with the digit. |
| The player floats above the platforms | The subrect is missing or wrong, so the rectangle still includes transparent padding. |
| He falls through the ground | MAX_FALL is larger than the tile height, or the vertical collision loop sits above player.y += player.vy. |
| He sinks slowly into a platform | player.vy = 0 is missing after landing, so gravity keeps adding up. |
| He snags on flat ground | The x and y collision loops were merged. They must stay two separate passes. |
| A skeleton shivers on the spot | Its y in MONSTERS is not exactly a platform top, so ground_at never finds floor. |
| A skeleton walks off into the sky | The ledge probe is looking the wrong way — see PLT-06. |
| The bar empties the instant you touch a skeleton | hurt_timer is never set, so all 60 frames of contact cost 10 each. |
| Health drains about four times too fast | The hurt_timer -= dt line is inside the monster loop. |
| NameError: name 'dt' is not defined | The def line is still def update():. |
| You die over and over after one hit | respawn() is not sending the monsters back to home. |
| Holding Space flies | The jump is being tested in update(). It belongs in on_key_down. |
| The editor underlines screen, keyboard, keys | Not an error. Pygame Zero creates those names when the game starts. |
Recap
3 minEvery upgrade in this lesson reused something already in the file: coins are overlaps plus a list, stomping is vy > 0 plus a tolerance, coyote time is a counter next to on_ground. That is what a well-shaped program buys you. The other lesson is the one you have been practising since stage 01 — write a little, run it, look at it — which is why a 150-line game never once had a bug you could not find.
Vocabulary Card
- coyote time
- Still allowing a jump for a few frames after leaving a ledge. Invisible when present, infuriating when absent.
- stomp
- Landing on an enemy from above. Detected by falling
vyplus feet near the enemy's top. - playtest
- Watching somebody else play in silence. The only reliable way to find out what your game actually communicates.
- game feel
- The sum of tuning, tolerances and feedback — why two games with identical rules can feel completely different.
Homework · Ship It
4 minFinish and hand in your platformer. It must have:
- Your own level, with every platform reachable.
- At least three skeletons placed to make one route tense.
- The health bar, and at least one upgrade from §04 or §05.
- A title on screen — the game's name and your name.
- Both fixes from your partner's playtest report, or a sentence explaining why you disagreed.
Alongside the code, write a short README.txt: what the game is called, how to run it, the controls, and the one design decision you are most pleased with.
Hint: zip the whole folder — platformer.py, pgzhelper.py, images/ and the README. A game that will not run on the marker's laptop is a game that does not exist.
Sample · README.txt and the title line
MELON DASH
by Nur Mia, Year 8
How to run
1. Open the folder in VS Code.
2. Open platformer.py and press Run.
(Pygame Zero must be installed: pip install pgzero)
Controls
A / D move
SPACE jump
R respawn
The decision I am most pleased with
The skeleton on the two-tile ledge below the top platform. My partner
kept dying there, so instead of slowing it down I widened the ledge
below it to three tiles. Now you can stand and watch it turn before
you jump. Same enemy, same speed, but it feels fair - because you get
somewhere to think.platformer.py · the title line in draw()
screen.draw.text("MELON DASH - by Nur Mia", center=(400, 30), fontsize=34, color="white", owidth=1, ocolor=(20, 20, 30))
A marker should be able to run the game without asking a single question. If they have to guess the controls or install something you did not mention, the README is not finished — and owidth/ocolor draw an outline, which keeps white text readable over a bright sky.