Code of the Day
IntermediateGame Architecture

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.

Lab · optionalGame DevIntermediate35 min
By the end of this lesson you will be able to:
  • 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 = scene

Checkpoint 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.

Finished reading? Mark it complete to track your progress.

On this page