Learning Goals
3 minBy the end of this lesson you can:
- Derive speed, spawn interval and max-enemies from a single
levelinteger using simple formulas. - Advance the level when the player reaches a score threshold and reschedule spawners.
- Run a game that visibly becomes harder as the level climbs.
Warm-Up · One Number, Many Values
5 minWe can derive several game settings from one number. Predict the output:
for lvl in range(1, 5): speed = 3 + lvl spawn_secs = max(0.5, 2.0 - lvl * 0.3) max_enemies = lvl * 2 print(f"L{lvl}: speed={speed}, spawn={spawn_secs:.1f}s, max={max_enemies}")
Show the answer
Output
L1: speed=4, spawn=1.7s, max=2 L2: speed=5, spawn=1.4s, max=4 L3: speed=6, spawn=1.1s, max=6 L4: speed=7, spawn=0.8s, max=8
Speed rises linearly, spawn interval shrinks (capped at 0.5 s so it never reaches zero), and enemy count doubles. Three different dials — one lever.
New Concept · Difficulty as a Formula
12 minThink of a thermostat. One dial controls the whole heating system. Your level variable is that thermostat — turn it up by one and everything calibrates automatically.
The level-to-settings function
def get_settings(lvl): speed = 3 + lvl # pixels per frame spawn_secs = max(0.5, 2.0 - lvl * 0.3) # seconds between spawns max_enemies = lvl * 2 # cap on simultaneous enemies return speed, spawn_secs, max_enemies
Advancing to the next level
Check the score inside update(). When it crosses the threshold, increment level and reschedule the spawner:
def advance_level(): global level, speed, max_enemies level += 1 speed, spawn_secs, max_enemies = get_settings(level) clock.unschedule(spawn_enemy) clock.schedule_interval(spawn_enemy, spawn_secs)
Score threshold per level
LEVEL_THRESHOLD = 300 # score points needed to advance def update(): global score score += 1 if score >= level * LEVEL_THRESHOLD: advance_level()
Worked Example · Runner with Rising Difficulty
12 minImran builds on the endless runner. Save as levels.py. Only the new additions are shown — keep the PZ-38/39 foundation.
Part A — level state
# levels.py — difficulty-curve demo import pgzrun import random WIDTH = 600 HEIGHT = 300 GROUND_Y = 240 GRAVITY = 0.6 JUMP_SPEED = 12 LEVEL_THRESHOLD = 300 level = 1 speed = 4 spawn_secs = 1.7 max_enemies = 2 score = 0 game_over = False player_x = 80 player_y = GROUND_Y vy = 0.0 ground_x = 0.0 rocks = []
Part B — settings helper + spawner
def get_settings(lvl): spd = 3 + lvl secs = max(0.5, 2.0 - lvl * 0.3) maxen = lvl * 2 return spd, secs, maxen def spawn_rock(): if len(rocks) < max_enemies: rocks.append({"x": WIDTH + 10, "h": random.randint(20, 40)}) clock.schedule_interval(spawn_rock, spawn_secs)
Part C — update with level advance
def update(): global player_y, vy, ground_x, score, game_over global level, speed, spawn_secs, max_enemies if game_over: return vy += GRAVITY player_y += vy if player_y >= GROUND_Y: player_y = GROUND_Y vy = 0.0 ground_x -= speed if ground_x <= -WIDTH: ground_x = 0.0 for rock in rocks: rock["x"] -= speed rocks[:] = [r for r in rocks if r["x"] > -30] p_rect = Rect((player_x - 12, player_y - 28), (24, 28)) for rock in rocks: if p_rect.colliderect(Rect((rock["x"] + 2, GROUND_Y - rock["h"]), (16, rock["h"]))): game_over = True score += 1 if score >= level * LEVEL_THRESHOLD: level += 1 speed, spawn_secs, max_enemies = get_settings(level) clock.unschedule(spawn_rock) clock.schedule_interval(spawn_rock, spawn_secs)
Part D — draw with level badge
def draw(): if game_over: screen.fill("black") screen.draw.text("GAME OVER", center=(WIDTH // 2, HEIGHT // 2 - 20), fontsize=56, color="red") screen.draw.text(f"Score: {score} Level: {level}", center=(WIDTH // 2, HEIGHT // 2 + 30), fontsize=28, color="white") return screen.fill("lightyellow") gx = int(ground_x) screen.draw.filled_rect(Rect((gx, GROUND_Y), (WIDTH, 60)), "saddlebrown") screen.draw.filled_rect(Rect((gx + WIDTH, GROUND_Y), (WIDTH, 60)), "saddlebrown") screen.draw.filled_rect(Rect((player_x - 15, player_y - 30), (30, 30)), "royalblue") for rock in rocks: screen.draw.filled_rect(Rect((rock["x"], GROUND_Y - rock["h"]), (20, rock["h"])), "dimgray") screen.draw.text(f"Score: {score}", topleft=(10, 10), fontsize=22, color="black") screen.draw.text(f"Level: {level}", topleft=(10, 34), fontsize=22, color="purple") def on_key_down(key): global vy if key == keys.SPACE and player_y >= GROUND_Y: vy = -JUMP_SPEED pgzrun.go()
Try It Yourself
13 minWhen level advances, flash the level number in the centre of the screen for 1 second. Use a level_flash timer variable that counts down in update().
level_flash = 0 # frames left to show the flash # in update(), after level advances: globals()["level_flash"] = 60 # 1 second at 60 fps # in draw(), before return at top: if level_flash > 0: screen.draw.text(f"LEVEL {level}!", center=(WIDTH // 2, 60), fontsize=42, color="gold") globals()["level_flash"] -= 1
Replace the linear speed formula with an exponential one: speed = 3 + level ** 1.3 (use int() to convert). Print the speed for levels 1 to 8 in the terminal first to check the curve feels right before putting it in the game.
for lvl in range(1, 9): print(f"Level {lvl}: speed = {int(3 + lvl ** 1.3)}")
Mini-Challenge · Hafiz's Level That Never Advances
8 minHafiz's game shows the score increasing but the level never changes. Spot the two bugs.
# hafiz_levels.py — buggy
level = 1
score = 0
THRESHOLD = 200
def get_settings(lvl):
return 3 + lvl, max(0.5, 2.0 - lvl * 0.3)
def update():
score += 1 # bug 1
if score > THRESHOLD: # bug 2
level += 1
sp, ss = get_settings(level) # result discardedIt works if…
level increments once when score reaches 200, then again at 400, 600, ...
Show the fix
# hafiz_levels.py — fixed level = 1 score = 0 THRESHOLD = 200 speed = 4 spawn_secs = 1.7 def get_settings(lvl): return 3 + lvl, max(0.5, 2.0 - lvl * 0.3) def update(): global score, level, speed, spawn_secs # fix 1: declare all globals score += 1 if score >= level * THRESHOLD: # fix 2: threshold per level, not flat level += 1 speed, spawn_secs = get_settings(level) # fix 3: store the results
Bug 1: score += 1 inside a function creates a local variable without global score. Bug 2: score > THRESHOLD only triggers once (at score 201) — using level * THRESHOLD sets a new bar each time.
Recap
3 minA single level integer is the master dial for difficulty. Helper functions convert it into speed, spawn interval, and enemy cap using simple formulas. When the score passes level * THRESHOLD, increment level and re-derive all settings. Always clamp with max() or min() so extreme levels stay playable.
Vocabulary Card
- difficulty curve
- How a game's challenge increases over time. A good curve ramps gradually so players improve alongside the game.
- level threshold
- The score (or time) a player must reach before the next level begins.
- clock.unschedule
- Cancels a previously scheduled repeating function so it can be rescheduled with a new interval.
- clamping
- Using
max()ormin()to keep a derived value inside safe bounds (e.g. spawn interval ≥ 0.5 s).
Homework
4 minAdd a lives = 3 mechanic. Hitting a rock costs one life (and removes the rock) instead of immediately ending the game. When lives reaches zero, trigger game over. Draw small red hearts near the top-right corner — one per remaining life. Bring a screenshot showing at least two lives remaining.
Sample · lives system snippet
lives = 3 # in update(), replace the game_over collision block: hit_rocks = [] p_rect = Rect((player_x - 12, player_y - 28), (24, 28)) for rock in rocks: r_rect = Rect((rock["x"] + 2, GROUND_Y - rock["h"]), (16, rock["h"])) if p_rect.colliderect(r_rect): lives -= 1 hit_rocks.append(rock) if lives <= 0: game_over = True for rock in hit_rocks: rocks.remove(rock) # in draw(), after score/level text: for i in range(lives): screen.draw.filled_circle((WIDTH - 20 - i * 22, 20), 8, "red")
Remove the colliding rock immediately so the player is not hit twice. Drawing circles for hearts is a quick stand-in — replace with a heart image later.