Learning Goals
3 minBy the end of this lesson you can:
- Explain why a naive "touching costs 10" empties a 100-point bar in a sixth of a second.
- Ask Pygame Zero for
dtand say why it beats counting frames. - Implement a cooldown with a timer that counts down in real seconds.
- Draw a health bar from three rectangles, and blink the player while the mercy window runs.
Warm-Up · How Often Is "Touching"?
5 minYou are about to write the rule "touching a skeleton costs 10 hit points". The player has 100. Work through this on paper before writing any code:
update()runs about 60 times a second. If you stand inside a skeleton, how many times per second isoverlaps(player, m)true?- At 10 points each time, how long does a 100-point bar last?
- So what does the player actually experience when they touch one enemy?
The answer is about a sixth of a second — indistinguishable from instant death, which is what lesson 6 already had. What you want is 10 now, and 10 more if he is still there a second later. That gap is the whole design, and every game you have played has one; it is why you flash after taking a hit.
New Concept · dt, Cooldowns and Showing State
12 minAsk for the clock. To count a second you need to know how long a frame took. Pygame Zero will tell you, but only if you ask — add a parameter to update and it passes the time since the last frame, in seconds:
def update(dt):
Nothing else in update changes. Pygame Zero looks at how many parameters your function has and calls it whichever way suits — which is why the first six lessons worked with a bare def update():.
You could count 60 frames instead, and at a steady 60fps you would get the same answer. But that number is a hope, not a promise: on a slow machine, or while the window is being dragged, frames take longer and your "second" quietly stretches. dt is measured, so a second stays a second. It is also the honest way to write the rule down — the design says one second, so the code should say 1.0, not 60.
A cooldown is two facts about one number. Touching sets hurt_timer to 1.0; every frame subtracts the time that has passed. While it is above zero the skeleton is scenery. The moment it reaches zero, the next frame of contact costs another 10 and winds it back up.
if player.hurt_timer > 0: player.hurt_timer -= dt # once per frame, outside the monster loop
Put it inside for m in monsters: and four skeletons subtract dt four times each frame, so the mercy window runs out four times too fast. Worse, the bug gets worse every time you add a monster — which is the sort that survives testing for a long while, because it works fine on the day you write it.
A number the player cannot see is not a game mechanic. Rect is a Pygame Zero builtin, like Actor, and screen.draw can fill one or outline it. The bar is three layers: a dark trough, a red fill as wide as the fraction of health left, a white outline over both.
Build Log · Stage 12
14 minGive the player health and a clock
platformer.py · add to the tuning block
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
platformer.py · add after player.on_ground
player.hp = MAX_HP player.hurt_timer = 0 # seconds left before he can be hurt again
platformer.py · change the def line of update()
def update(dt):
Run it — checkpoint 12a
The game behaves exactly as it did last lesson — touching a skeleton still sends you straight back. Nothing visible has changed, and that is the point: you added the parameter without breaking anything. If you get NameError: name 'dt' is not defined later, this is the line you forgot.
Spend the health
platformer.py · replace the monster collision block in update()
# the mercy window ticks down in real seconds, not frames 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
platformer.py · update 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
Everything that starts a fresh life goes through respawn() — running out of health, falling in the pit, pressing R — so refilling the bar in one place covers all three. Clearing hurt_timer matters for the same reason vy = 0 did in lesson 5: leave it set and the new life starts already invulnerable, blinking, for no reason the player can see.
It does not matter which skeleton you touched, which is why the break from last lesson can stay. The timer belongs to the player, not to the monster, so being sandwiched between two of them costs the same 10 as brushing one.
Run it — checkpoint 12b
- Walking into a skeleton no longer sends you home.
- Stand inside one and count: it takes ten touches, about ten seconds, to get sent back.
- You cannot see any of this yet — which is exactly the problem stage 12c fixes.
Show it
platformer.py · add above draw()
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")
bar.width * player.hp // MAX_HP uses // so the width comes out as a whole number of pixels — at 40 hp that is 220 × 40 // 100, or 88.
platformer.py · update 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()
The blink is the same trick every arcade game uses, and it is not decoration: without it, a player standing on a skeleton and taking no damage assumes the game is broken. Multiplying the timer by 10 chops the second into ten slices and % 2 shows him during the even ones, so the flash and the invulnerability start and stop together — the animation is the state, rather than a guess at it.
Run it — checkpoint 12
- The bar reads 100 / 100 and sits in the top-right corner.
- Walking into a skeleton costs exactly 10, once, and the player starts blinking.
- Stand still inside one: 10 more every second, on the second — nine touches to empty the bar and a tenth to send you home at full health.
- Step off and touch it again within the second and it is free; wait longer and it costs.
- The pit and R both refill the bar, and neither leaves you blinking.
Try It Yourself
13 minTry HURT_COOLDOWN = 0.2 and then 3.0, and describe how each changes the way you play around a skeleton. Which one makes you braver? Which one feels unfair, and why?
Then change TOUCH_DAMAGE to 34 and count how many touches you now survive. Put both back before the next exercise.
Colour the fill by how much health is left: green above 60, amber above 30, red below. One if chain and one variable inside draw_health().
Hint
if player.hp > 60: colour = (60, 180, 75) elif player.hp > 30: colour = (240, 170, 40) else: colour = (200, 50, 50) screen.draw.filled_rect(fill, colour)
Percentages are safer than raw numbers here: write player.hp > MAX_HP * 0.6 and the colours still work if you change MAX_HP later.
Mini-Challenge 🔥 · Debug: The Bar That Empties Four Times Too Fast
8 minFirdaus has four skeletons and a strange complaint: touching one costs 10, as designed, but if he stands still inside it the bar drains far faster than once a second. With only one skeleton on the platform, everything is fine.
for m in monsters:
if player.hurt_timer > 0:
player.hurt_timer -= dt
if overlaps(player, m):
if player.hurt_timer <= 0:
player.hp -= TOUCH_DAMAGE
player.hurt_timer = HURT_COOLDOWN
if player.hp <= 0:
respawn()
breakExplain why the number of skeletons changes the drain rate — and why the break does not save him.
Answer
The countdown is inside the loop, so it runs once per monster per frame. With four skeletons, hurt_timer loses 4 × dt each frame and the one-second window empties in a quarter of a second.
The break does not help because it only fires when an overlap is found — the countdown lines run for every monster before that point. And with one skeleton the timer loses exactly dt per frame, which is correct, so his test case hides the bug entirely.
Fix: move the countdown above for m in monsters:. Time passes once per frame, not once per enemy — a rule worth remembering for anything else you make time-based.
Recap
3 mindef update(dt) asks Pygame Zero for the time since the last frame, so a second stays a second on any machine. A cooldown is one number: set it on a hit, subtract dt once per frame, and ignore contact while it is above zero. That countdown belongs outside the monster loop, because time passes once per frame, not once per enemy. The bar is three Rects, and the blink makes the invulnerability visible — otherwise the player thinks the game is broken.
Vocabulary Card
- dt (delta time)
- Seconds since the previous frame. Ask for it by adding a parameter to
update. - cooldown
- A timer that blocks an action until it reaches zero. Here it is the mercy window after a hit.
- invulnerability frames
- The short spell after taking damage when you cannot be hurt again. Nearly every action game has them.
- Rect(x, y, w, h)
- A plain rectangle.
screen.draw.filled_rectfills one,screen.draw.rectoutlines it.
Homework
4 minAdd a health pickup. Put two or three small Actors on ledges; when the player overlaps one, remove it from the list and heal him — capped so the bar cannot overfill.
Use any small PNG in images/ (a coin, a heart, even a spare platform tile). Bring the code and a screenshot of a half-empty bar being topped up.
Hint: min(player.hp + 25, MAX_HP) is the cap. Remove pickups with pickups.remove(item), and iterate over a copy — for item in list(pickups): — because changing a list while looping over it skips entries.
Sample · health pickups
HEAL_AMOUNT = 25 # --- pickups: put them on ledges, midbottom on a platform top --- PICKUPS = [(300, 470), (500, 390), (680, 310)] pickups = [] for x, y in PICKUPS: item = Actor('platform1') # any small image will do item.scale = 0.5 item.midbottom = (x, y) item.home = (x, y) pickups.append(item) # in update(dt), after the monster block: for item in list(pickups): if overlaps(player, item): player.hp = min(player.hp + HEAL_AMOUNT, MAX_HP) pickups.remove(item) # in draw(), before the player: for item in pickups: item.draw() # in respawn(), so a new life gets them back: for x, y in PICKUPS: pass # simplest: rebuild the list, or keep a spare copy
Two things to notice. list(pickups) makes a copy so removing while looping is safe. And that last comment is a real design decision: should pickups return after a respawn? If they do the level is forgiving; if they do not, dying near the end is punishing. Either is fine — but decide it on purpose, and make respawn() say so.