Learning Goals
3 minBy the end of this lesson you can:
- Extend a two-state game-state machine (PZ-31) to four states:
"title","play","pause","game_over". - Write a separate draw function for each state so
draw()stays clean and readable. - Handle clean transitions between states, resetting the game when the player restarts.
Warm-Up · State Machines Recap
5 minIn PZ-31 you built a two-state game: "play" and "game_over". Quick check — what does this print?
state = "play" if state == "play": print("Game running") elif state == "game_over": print("Game over screen") else: print("Unknown state")
Show the answer
Output
Game running
Easy. Today you add two more branches: "title" and "pause". The pattern is the same — just more states.
A state machine with four states has four elif branches in draw() and four in update(). Keep each branch short by calling a helper function for each state.
New Concept · Four-State Machine
12 minThink of a state machine like the indicator light on a vending machine: it shows one thing at a time — "insert coin", "choose item", "dispensing", "sold out". It cannot be in two states at once, and pressing buttons only makes sense in certain states.
The four states and when they occur
"title"— game has not started. Show the game name and "Press SPACE to play"."play"— game is active. Run physics, input, scoring."pause"— player pressed P. Freeze everything; show "PAUSED — press P to resume"."game_over"— lives hit zero. Show score and "Press R to restart".
Clean draw() using helpers
def draw(): screen.fill("black") if state == "title": draw_title() elif state == "play": draw_play() elif state == "pause": draw_play() # show frozen game behind draw_pause_overlay() elif state == "game_over": draw_game_over()
The pause state calls draw_play() first, then overlays the pause message. The player can see their frozen game behind the banner — that is the standard pattern.
Clean update() using helpers
def update(): if state == "play": update_play()
update() only runs game logic when in the "play" state. Other states freeze the simulation.
Transitions via on_key_down()
def on_key_down(key): global state if state == "title" and key == keys.SPACE: reset_game() state = "play" elif state == "play" and key == keys.P: state = "pause" elif state == "pause" and key == keys.P: state = "play" elif state == "game_over" and key == keys.R: reset_game() state = "title"
Worked Example · Four-Screen Catcher
12 minThe story
Wei Jie extends his catcher game to have a proper title screen, a pause, and a game-over screen. Save as four_states.py.
# four_states.py — full four-state game loop import pgzrun import random WIDTH = 480 HEIGHT = 320 TITLE = "Durian Dash" state = "title" score = 0 lives = 3 ball_x = 240 ball_y = -30 BALL_SPEED = 4
def reset_game(): global score, lives, ball_x, ball_y score = 0 lives = 3 ball_x = random.randint(30, WIDTH - 30) ball_y = -30
def update_play(): global ball_y, ball_x, lives, state ball_y += BALL_SPEED if ball_y > HEIGHT + 30: lives -= 1 ball_y = -30 ball_x = random.randint(30, WIDTH - 30) if lives <= 0: state = "game_over"
def on_mouse_down(pos, button): global score, ball_y, ball_x if state != "play": return dist = ((pos[0] - ball_x) ** 2 + (pos[1] - ball_y) ** 2) ** 0.5 if dist < 28: score += 1 ball_y = -30 ball_x = random.randint(30, WIDTH - 30)
def on_key_down(key): global state if state == "title" and key == keys.SPACE: reset_game() state = "play" elif state == "play" and key == keys.P: state = "pause" elif state == "pause" and key == keys.P: state = "play" elif state == "game_over" and key == keys.R: state = "title"
def draw_title(): screen.draw.text("DURIAN DASH", center=(240, 120), fontsize=52, color="gold") screen.draw.text("Press SPACE to play", center=(240, 200), fontsize=24, color="white") def draw_play(): screen.draw.filled_circle((ball_x, ball_y), 26, "crimson") screen.draw.text(f"Score: {score} Lives: {lives}", topleft=(10, 10), fontsize=22, color="white") def draw_pause_overlay(): r = Rect((90, 110), (300, 80)) screen.draw.filled_rect(r, (0, 0, 0)) screen.draw.rect(r, (255, 255, 255)) screen.draw.text("PAUSED", center=(240, 135), fontsize=36, color="white") screen.draw.text("Press P to resume", center=(240, 170), fontsize=20, color="grey") def draw_game_over(): screen.draw.text("GAME OVER", center=(240, 110), fontsize=48, color="crimson") screen.draw.text(f"Final score: {score}", center=(240, 175), fontsize=28, color="white") screen.draw.text("Press R to return to title", center=(240, 220), fontsize=20, color="grey")
def update(): if state == "play": update_play() def draw(): screen.fill("black") if state == "title": draw_title() elif state == "play": draw_play() elif state == "pause": draw_play() draw_pause_overlay() elif state == "game_over": draw_game_over() pgzrun.go()
Try It Yourself
13 minTrack a best_score variable. Update it whenever score > best_score and show it on the game-over screen next to the final score.
Hint
best_score = 0 # in the code that triggers game_over: if score > best_score: best_score = score # in draw_game_over(): screen.draw.text(f"Best: {best_score}", center=(240, 200), fontsize=22, color="gold")
Pygame Zero does not support real alpha blending, but you can fake a dim overlay by drawing many thin horizontal lines across the screen in a dark colour. Draw 10 lines spaced 30 px apart in the pause overlay to create a "blind" effect.
Hint
def draw_pause_overlay(): for y in range(0, HEIGHT, 30): screen.draw.line((0, y), (WIDTH, y), (30, 30, 30)) screen.draw.text("PAUSED", center=(WIDTH // 2, HEIGHT // 2), fontsize=40, color="white")
Mini-Challenge · Debug: Iman's Broken States
8 minIman wrote a four-state game but the pause key does nothing and restarting after game over skips the title screen. Find the three bugs.
# iman_states.py — buggy
state = "title"
score = 0
def on_key_down(key):
if state == "title" and key == keys.SPACE:
state = "play" # bug 1
if state == "PLAY" and key == keys.P: # bug 2
state = "pause"
if state == "pause" and key == keys.P:
state = "play"
if state == "game_over" and key == keys.R:
reset_game()
state = "play" # bug 3Show the fixes
# iman_states.py — fixed state = "title" score = 0 def on_key_down(key): global state # fix 1: must declare global to reassign if state == "title" and key == keys.SPACE: state = "play" elif state == "play" and key == keys.P: # fix 2: lowercase "play" state = "pause" elif state == "pause" and key == keys.P: state = "play" elif state == "game_over" and key == keys.R: reset_game() state = "title" # fix 3: return to "title", not "play"
Three bugs: missing global state, "PLAY" capitalised incorrectly, and restarting jumped to "play" instead of "title".
Recap
3 minExtending a state machine from two states to four is straightforward: add new string constants, new elif branches in draw() and update(), and new key bindings in on_key_down(). Always call reset_game() before entering "play" so leftover data from a previous run is cleared. Keep each state's draw logic in its own helper function to stay readable.
Vocabulary Card
- state machine
- A system that can be in exactly one state at a time. Transitions between states are triggered by specific events (key press, timer, collision).
- title state
- The initial screen showing the game name and start instructions before the player begins.
- pause state
- A state where
update()does nothing, freezing the simulation, whiledraw()overlays a "PAUSED" message on top of the frozen game. - reset_game()
- A function that restores all game variables to their starting values, called before transitioning to "play" so each run starts fresh.
Homework
4 minTake any game you built earlier in this module and upgrade it to the four-state machine. You need:
- A title screen with the game name and "SPACE to start".
- A pause state triggered by P.
- A game-over screen showing the final score and "R to restart".
- A working
reset_game()that clears all state.
Save as polished_game.py and bring it to the next class.
Sample · polished_game.py (skeleton)
# polished_game.py — four-state upgrade skeleton import pgzrun WIDTH = 480 HEIGHT = 320 state = "title" score = 0 lives = 3 def reset_game(): global score, lives score = 0 lives = 3 def on_key_down(key): global state if state == "title" and key == keys.SPACE: reset_game() state = "play" elif state == "play" and key == keys.P: state = "pause" elif state == "pause" and key == keys.P: state = "play" elif state == "game_over" and key == keys.R: state = "title" def update(): if state == "play": pass # your game logic here def draw(): screen.fill("black") if state == "title": screen.draw.text("MY GAME", center=(240, 120), fontsize=52, color="gold") screen.draw.text("SPACE to start", center=(240, 190), fontsize=24, color="white") elif state == "play": screen.draw.text(f"Score:{score} Lives:{lives}", topleft=(10, 10), fontsize=22, color="white") elif state == "pause": screen.draw.text("PAUSED", center=(240, 160), fontsize=40, color="white") elif state == "game_over": screen.draw.text("GAME OVER", center=(240, 120), fontsize=48, color="crimson") screen.draw.text(f"Score: {score}", center=(240, 180), fontsize=28, color="white") screen.draw.text("R to restart", center=(240, 220), fontsize=20, color="grey") pgzrun.go()
The skeleton shows the structure. Replace the placeholder logic in update() and the gameplay drawing in draw() with your existing game code.