Learning Goals
3 minBy the end of this lesson you can:
- Define a
statevariable with three possible values:"start","play", and"over". - Branch
draw()andupdate()onstateto show the correct screen. - Advance through all three states with SPACE and reset back to the start screen.
Warm-Up · Branching on a String
5 minPython can branch on a string just like a number. Predict the output:
state = "play" if state == "start": print("Show title screen") elif state == "play": print("Run the game") elif state == "over": print("Show game over") else: print("Unknown state")
Show the answer
Output
Run the game
The string "play" matches the second branch. This exact pattern is the whole lesson — we just move it inside draw() and update().
New Concept · The State Machine Pattern
12 minThink of a game like a traffic light. It can be red, amber, or green — but only one at a time. A state machine is exactly that: one variable holding the current mode, and code that behaves differently in each mode.
The state variable
state = "start" # can be "start", "play", or "over"
Using plain strings instead of numbers (0, 1, 2) makes the code easy to read. You can see at a glance what each branch handles.
Branching draw() on state
def draw(): screen.fill("black") if state == "start": screen.draw.text("Press SPACE to play", center=(WIDTH // 2, HEIGHT // 2), fontsize=30, color="white") elif state == "play": # draw the game world here pass elif state == "over": screen.draw.text("Game Over — SPACE to restart", center=(WIDTH // 2, HEIGHT // 2), fontsize=30, color="red")
Advancing the state
def on_key_down(key): global state if key == keys.SPACE: if state == "start": state = "play" elif state == "over": state = "start" # restart the loop
The game moves from "start" to "play" on the first SPACE. When the game ends, code inside update() sets state = "over". A second SPACE press loops back to "start".
Worked Example · Three-Screen Game Skeleton
12 minAiman builds the full three-screen skeleton with a falling counter game as the play state. Save as game_states.py:
# game_states.py — start / play / over skeleton import pgzrun import random WIDTH = 480 HEIGHT = 360 TITLE = "State Machine Demo" state = "start" score = 0 timer = 10.0 # seconds of play time ball_x = 240 ball_y = 0 ball_speed = 3
def reset(): global score, timer, ball_x, ball_y score = 0 timer = 10.0 ball_x = random.randint(20, WIDTH - 20) ball_y = 0 def update(dt): global state, score, timer, ball_x, ball_y if state != "play": return timer -= dt if timer <= 0: state = "over" return ball_y += ball_speed if ball_y > HEIGHT: ball_y = 0 ball_x = random.randint(20, WIDTH - 20) score += 10 def on_key_down(key): global state if key == keys.SPACE: if state == "start": reset() state = "play" elif state == "over": state = "start"
def draw(): screen.fill("black") if state == "start": screen.draw.text("FALLING BALL", center=(WIDTH // 2, 140), fontsize=48, color="gold") screen.draw.text("Press SPACE to start", center=(WIDTH // 2, 210), fontsize=26, color="white") elif state == "play": screen.draw.filled_circle((ball_x, int(ball_y)), 14, "dodgerblue") screen.draw.text(f"Score: {score}", topleft=(8, 8), fontsize=22, color="white") screen.draw.text(f"Time: {timer:.1f}", topleft=(8, 34), fontsize=22, color="white") elif state == "over": screen.draw.text("Time's Up!", center=(WIDTH // 2, 140), fontsize=48, color="red") screen.draw.text(f"Final Score: {score}", center=(WIDTH // 2, 200), fontsize=30, color="white") screen.draw.text("SPACE to play again", center=(WIDTH // 2, 250), fontsize=22, color="grey") pgzrun.go()
Try It Yourself
13 minAdd a fourth state "paused". When the player presses P during play, freeze the game and show "Paused — P to resume". Pressing P again returns to "play". Only update() needs to be frozen — draw() can show the pause text on top of the frozen play frame.
Hint
def on_key_down(key): global state if key == keys.P: if state == "play": state = "paused" elif state == "paused": state = "play" # ... existing SPACE logic ...
Add a high_score variable. Each time the game-over state is entered, compare score to high_score and update it if needed. Display both on the game-over screen.
Hint
high_score = 0 # inside update(), when timer reaches 0: if score > high_score: high_score = score state = "over"
Mini-Challenge · Debug Imran's State Loop
8 minImran's game flips from "start" straight to "over" the moment SPACE is pressed, and it never resets the score. Find the two bugs:
# imran_states.py — buggy
import pgzrun
WIDTH = 400
HEIGHT = 300
state = "start"
score = 0
def on_key_down(key):
global state, score
if key == keys.SPACE:
if state == "start":
state = "over" # Bug 1: should be "play"
elif state == "over":
state = "start" # Bug 2: should also reset score
def draw():
screen.fill("black")
if state == "start":
screen.draw.text("Press SPACE", center=(200, 150),
fontsize=30, color="white")
elif state == "play":
screen.draw.text(f"Score: {score}", center=(200, 150),
fontsize=30, color="white")
elif state == "over":
screen.draw.text("Over", center=(200, 150),
fontsize=30, color="red")
pgzrun.go()Show the fixes
# imran_states.py — fixed import pgzrun WIDTH = 400 HEIGHT = 300 state = "start" score = 0 def on_key_down(key): global state, score if key == keys.SPACE: if state == "start": state = "play" # Fix 1: advance to play, not over elif state == "over": score = 0 # Fix 2: reset score before going back state = "start" def draw(): screen.fill("black") if state == "start": screen.draw.text("Press SPACE", center=(200, 150), fontsize=30, color="white") elif state == "play": screen.draw.text(f"Score: {score}", center=(200, 150), fontsize=30, color="white") elif state == "over": screen.draw.text("Over", center=(200, 150), fontsize=30, color="red") pgzrun.go()
Bug 1 skips the play state entirely — the first SPACE should only go to "play". Bug 2 means the score from the last game carries over — always reset counters when restarting.
It works if…
SPACE on the title goes to the play screen; SPACE on game-over resets and returns to the title
Recap
3 minA state machine is one string variable (state) and branching code.draw() and update() both check state and behave differently in each mode. Pressing SPACE (or meeting a condition in update()) changes the state. Always reset counters and positions when moving back to "start".
Vocabulary Card
- state machine
- A pattern where one variable holds the current mode and all code branches on it. Only one state is active at a time.
- state variable
- A string (or integer) that names the current game screen — e.g.
"start","play","over". - reset()
- A helper function that sets score, timer, positions back to their starting values. Call it when transitioning from
"over"to"start". - update(dt)
- The version of
updatethat receives elapsed seconds asdt. Usetimer -= dtfor real-time countdowns.
Homework
4 minTake the falling-stars game from PZ-28 and add the three-state structure from today. The title screen should show the game name and "Press SPACE to play". The play state runs the existing game. When lives reach 0, transition to game-over and show the final score. Pressing SPACE from game-over resets everything and returns to the title screen. Save as stars_with_states.py and bring a screenshot to the next class.
Sample · stars_with_states.py
# stars_with_states.py — PZ-28 + state machine import pgzrun import random WIDTH = 480 HEIGHT = 400 TITLE = "Star Catcher" BASKET_SPEED = 5 STAR_SPEED = 3 state = "start" score = 0 lives = 3 basket = Rect((200, 360), (80, 18)) star = Rect((0, 0), (20, 20)) def respawn(): star.x = random.randint(20, WIDTH - 20) star.y = -20 def reset_game(): global score, lives score = 0 lives = 3 basket.x = 200 respawn() def on_key_down(key): global state if key == keys.SPACE: if state == "start": reset_game() state = "play" elif state == "over": state = "start" def update(): global score, lives, state if state != "play": return if keyboard.left and basket.left > 0: basket.x -= BASKET_SPEED if keyboard.right and basket.right < WIDTH: basket.x += BASKET_SPEED star.y += STAR_SPEED if star.y > HEIGHT + 20: lives -= 1 sounds.thud.play() if lives <= 0: state = "over" else: respawn() elif basket.colliderect(star): score += 10 sounds.chime.play() respawn() def draw(): screen.fill("black") if state == "start": screen.draw.text("STAR CATCHER", center=(WIDTH // 2, 140), fontsize=48, color="gold") screen.draw.text("Press SPACE to play", center=(WIDTH // 2, 210), fontsize=26, color="white") elif state == "play": screen.draw.filled_rect(basket, "royalblue") screen.draw.filled_circle(star.center, 10, "gold") screen.draw.text(f"Score: {score} Lives: {lives}", topleft=(8, 8), fontsize=22, color="white") elif state == "over": screen.draw.text("Game Over!", center=(WIDTH // 2, 150), fontsize=48, color="red") screen.draw.text(f"Score: {score}", center=(WIDTH // 2, 210), fontsize=30, color="white") screen.draw.text("SPACE to play again", center=(WIDTH // 2, 260), fontsize=22, color="grey") respawn() pgzrun.go()
The key change: update() returns immediately when state != "play", so the star freezes on the title and game-over screens. Reset everything in reset_game(), not scattered through the code.