Lab: State machine refactor
Refactor the polished beginner game into a three-scene state machine with an event bus for score updates and a camera offset for a scrolling level.
- Refactor a single-loop game into TitleScene, GameScene, and GameOverScene
- Wire transitions on win and lose conditions using set_scene()
- Add an event bus so the HUD updates score without coupling to the player
- Add a camera offset so the level can scroll horizontally
This lab takes the polished game from the beginner track and restructures it using the patterns from this module. The gameplay stays the same — move the player, reach goals, avoid obstacles — but the code will be split into three scenes, decoupled via an event bus, and extended with a scrolling camera.
Pyodide (the in-browser Python runner) does not support pygame's display
system. Work through this lab locally: pip install pygame then run
python state_game.py after each checkpoint.
Starting scaffold
Save this as state_game.py. It is the polished beginner game stripped down to
its core, ready to be restructured:
import pygame
import sys
import random
from collections import defaultdict
pygame.init()
SCREEN_W, SCREEN_H = 640, 480
WORLD_W = 1280 # world is wider than the window
screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
pygame.display.set_caption("State machine game")
clock = pygame.time.Clock()
# ── Event bus ────────────────────────────────────────────────────────────────
class EventBus:
def __init__(self):
self._handlers = defaultdict(list)
def subscribe(self, event_type, cb):
self._handlers[event_type].append(cb)
def publish(self, event_type, data=None):
for cb in self._handlers[event_type]:
cb(data)
SCORE_CHANGED = "score_changed"
GOAL_REACHED = "goal_reached"
PLAYER_RESET = "player_reset"
# ── Scene base ────────────────────────────────────────────────────────────────
class Scene:
def __init__(self, manager):
self.manager = manager
def update(self, events): pass
def draw(self, screen): pass
class SceneManager:
def __init__(self):
self.current = None
def set_scene(self, scene):
self.current = sceneCheckpoint 1 — Title and Game-Over scenes
Add TitleScene and GameOverScene below the scaffold. Both are simple: title
waits for a key, game-over shows the score and waits for R.
class TitleScene(Scene):
def __init__(self, manager):
super().__init__(manager)
self.font = pygame.font.SysFont(None, 60)
def update(self, events):
for e in events:
if e.type == pygame.KEYDOWN:
self.manager.set_scene(GameScene(self.manager))
def draw(self, screen):
screen.fill((15, 15, 35))
t = self.font.render("STATE MACHINE GAME", True, (255, 255, 255))
s = pygame.font.SysFont(None, 30).render(
"Press any key", True, (160, 160, 160))
screen.blit(t, t.get_rect(center=(SCREEN_W // 2, 200)))
screen.blit(s, s.get_rect(center=(SCREEN_W // 2, 270)))
class GameOverScene(Scene):
def __init__(self, manager, score):
super().__init__(manager)
self.score = score
self.font = pygame.font.SysFont(None, 56)
def update(self, events):
for e in events:
if e.type == pygame.KEYDOWN and e.key == pygame.K_r:
self.manager.set_scene(TitleScene(self.manager))
def draw(self, screen):
screen.fill((40, 10, 10))
msg = self.font.render("GAME OVER", True, (230, 60, 60))
sc = pygame.font.SysFont(None, 34).render(
f"Score: {self.score} R to restart", True, (200, 200, 200))
screen.blit(msg, msg.get_rect(center=(SCREEN_W // 2, 200)))
screen.blit(sc, sc.get_rect(center=(SCREEN_W // 2, 270)))Run the skeleton with a temporary GameScene stub to confirm title appears and
the key transition works before moving on.
Checkpoint 2 — GameScene with event bus
GameScene owns all per-session state. It creates a bus, a player, obstacles,
and a HUD, and connects them through events. Score updates arrive via the bus
rather than direct calls.
class GameScene(Scene):
SIZE = 40
SPEED = 4
def __init__(self, manager):
super().__init__(manager)
self.bus = EventBus()
self.font = pygame.font.SysFont(None, 30)
self.score = 0
self.camera_x = 0
# Player starts in world coords
self.px, self.py = WORLD_W // 2, SCREEN_H // 2
self.obstacles = [
pygame.Rect(300, 80, 20, 320),
pygame.Rect(600, 100, 20, 280),
pygame.Rect(900, 80, 20, 320),
]
self.gx, self.gy = self._new_goal()
# HUD subscribes to score changes
self.bus.subscribe(SCORE_CHANGED, self._on_score)
def _on_score(self, data):
self.score += data.get("delta", 1)
def _new_goal(self):
while True:
gx = random.randint(0, (WORLD_W - self.SIZE) // self.SIZE) * self.SIZE
gy = random.randint(0, (SCREEN_H - self.SIZE) // self.SIZE) * self.SIZE
cand = pygame.Rect(gx, gy, self.SIZE, self.SIZE)
if not any(cand.colliderect(o) for o in self.obstacles):
return gx, gy
def _update_camera(self):
target_x = self.px - SCREEN_W // 2
self.camera_x = max(0, min(target_x, WORLD_W - SCREEN_W))
def update(self, events):
for e in events:
if e.type == pygame.KEYDOWN and e.key == pygame.K_ESCAPE:
self.manager.set_scene(GameOverScene(self.manager, self.score))
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]: self.px -= self.SPEED
if keys[pygame.K_RIGHT]: self.px += self.SPEED
if keys[pygame.K_UP]: self.py -= self.SPEED
if keys[pygame.K_DOWN]: self.py += self.SPEED
self.px = max(0, min(WORLD_W - self.SIZE, self.px))
self.py = max(0, min(SCREEN_H - self.SIZE, self.py))
player_rect = pygame.Rect(self.px, self.py, self.SIZE, self.SIZE)
for obs in self.obstacles:
if player_rect.colliderect(obs):
self.px, self.py = WORLD_W // 2, SCREEN_H // 2
break
goal_rect = pygame.Rect(self.gx, self.gy, self.SIZE, self.SIZE)
if player_rect.colliderect(goal_rect):
self.bus.publish(SCORE_CHANGED, {"delta": 1})
self.gx, self.gy = self._new_goal()
self._update_camera()
def _world_to_screen(self, rect):
return rect.move(-self.camera_x, 0)
def draw(self, screen):
screen.fill((20, 20, 35))
for obs in self.obstacles:
pygame.draw.rect(screen, (180, 80, 80),
self._world_to_screen(obs))
pygame.draw.rect(screen, (255, 180, 50),
self._world_to_screen(
pygame.Rect(self.gx, self.gy,
self.SIZE, self.SIZE)))
pygame.draw.rect(screen, (100, 210, 120),
self._world_to_screen(
pygame.Rect(self.px, self.py,
self.SIZE, self.SIZE)))
hud = self.font.render(
f"Score: {self.score} ESC = game over world x: {self.px}",
True, (255, 255, 255))
screen.blit(hud, (10, 10))Checkpoint 3 — Wire the main loop
The main loop is now four lines of real logic:
manager = SceneManager()
manager.set_scene(TitleScene(manager))
running = True
while running:
events = pygame.event.get()
for e in events:
if e.type == pygame.QUIT:
running = False
manager.current.update(events)
manager.current.draw(screen)
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()Run the game. Walk the player right — the camera should follow. Red walls reset position. Reaching the gold square increments the score. ESC triggers game-over, R on that screen returns to title.
What you have now
Compare this to the flat beginner game:
- Adding a new scene means writing a new class. The loop never changes.
- The HUD updates score without knowing about the player — they communicate only through the bus.
- The world is 1280 pixels wide; the camera follows the player and clamps at the edges.
- Resetting the game state is just constructing a new
GameScene— no manual clearing of individual variables.
These properties compound. When the game has ten scene types and twenty event types, the same structural rules keep each piece independent and testable.
Camera and viewport
Understand world coordinates versus screen coordinates and how a camera offset lets you scroll through a level larger than the window.
The Sprite class
pygame.sprite.Sprite enforces a contract — image plus rect — that lets Groups manage update, draw, and collision for every member at once.