Code of the Day
IntermediateGame Architecture

Scene management

Implement a Scene base class and a SceneManager that swaps between title, game, and game-over screens cleanly.

Game DevIntermediate10 min read
Recommended first
By the end of this lesson you will be able to:
  • Implement a Scene base class with update() and draw() methods
  • Build a SceneManager that holds the current scene and swaps on request
  • Wire title → game → game-over transitions using set_scene()

The previous lesson described the state machine as a design principle. This lesson turns it into code. The pattern is straightforward: a Scene base class defines the interface every state must honour, and a SceneManager holds the currently active scene and exposes a set_scene() method for transitions. The main loop knows nothing about which scene is active — it just calls manager.current.update(events) and manager.current.draw(screen).

Pyodide (the in-browser Python runner) does not support pygame's display system. Read through this code carefully in the browser, then run it locally: pip install pygame followed by python scenes.py.

The Scene base class and SceneManager

import pygame
import sys

# ── Scene base class ─────────────────────────────────────────────────────────

class Scene:
    """All game states inherit from this."""

    def __init__(self, manager):
        self.manager = manager          # back-reference so scenes can switch

    def update(self, events):
        """Called once per frame with the current event list."""
        pass

    def draw(self, screen):
        """Called once per frame to render to the display surface."""
        pass


# ── SceneManager ──────────────────────────────────────────────────────────────

class SceneManager:
    def __init__(self, initial_scene):
        self.current = initial_scene

    def set_scene(self, scene):
        """Swap to a new scene immediately."""
        self.current = scene


# ── Concrete scenes ───────────────────────────────────────────────────────────

class TitleScene(Scene):
    def __init__(self, manager):
        super().__init__(manager)
        self.font = pygame.font.SysFont(None, 64)

    def update(self, events):
        for event in events:
            if event.type == pygame.KEYDOWN:
                # Any key starts the game
                self.manager.set_scene(GameScene(self.manager))

    def draw(self, screen):
        screen.fill((20, 20, 40))
        title = self.font.render("MY GAME", True, (255, 255, 255))
        sub   = pygame.font.SysFont(None, 32).render(
            "Press any key to start", True, (180, 180, 180))
        screen.blit(title, title.get_rect(center=(320, 200)))
        screen.blit(sub,   sub.get_rect(center=(320, 270)))


class GameScene(Scene):
    def __init__(self, manager):
        super().__init__(manager)
        self.font   = pygame.font.SysFont(None, 32)
        self.px     = 300
        self.py     = 220
        self.health = 3
        self.score  = 0

    def update(self, events):
        for event in events:
            if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
                # Simulated death for demonstration
                self.manager.set_scene(
                    GameOverScene(self.manager, self.score))

        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:  self.px -= 4
        if keys[pygame.K_RIGHT]: self.px += 4
        if keys[pygame.K_UP]:    self.py -= 4
        if keys[pygame.K_DOWN]:  self.py += 4

    def draw(self, screen):
        screen.fill((30, 30, 50))
        pygame.draw.rect(screen, (100, 210, 120),
                         pygame.Rect(self.px, self.py, 40, 40))
        hud = self.font.render(
            f"Score: {self.score}   ESC = game over", True, (255, 255, 255))
        screen.blit(hud, (10, 10))


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 event in events:
            if event.type == pygame.KEYDOWN and event.key == pygame.K_r:
                # R restarts from the title
                self.manager.set_scene(TitleScene(self.manager))

    def draw(self, screen):
        screen.fill((50, 10, 10))
        msg   = self.font.render("GAME OVER", True, (255, 80, 80))
        score = pygame.font.SysFont(None, 36).render(
            f"Score: {self.score}   R to restart", True, (200, 200, 200))
        screen.blit(msg,   msg.get_rect(center=(320, 200)))
        screen.blit(score, score.get_rect(center=(320, 270)))


# ── Main loop ─────────────────────────────────────────────────────────────────

def main():
    pygame.init()
    screen  = pygame.display.set_mode((640, 480))
    pygame.display.set_caption("Scene management demo")
    clock   = pygame.time.Clock()

    # Build manager and wire the first scene
    manager = SceneManager(None)
    manager.set_scene(TitleScene(manager))

    running = True
    while running:
        events = pygame.event.get()
        for event in events:
            if event.type == pygame.QUIT:
                running = False

        manager.current.update(events)
        manager.current.draw(screen)
        pygame.display.flip()
        clock.tick(60)

    pygame.quit()
    sys.exit()


if __name__ == "__main__":
    main()

How the pieces connect

Scene.__init__(manager) stores a reference to the SceneManager. This is how a scene triggers a transition: instead of setting a global variable, it calls self.manager.set_scene(SomeOtherScene(self.manager)). The new scene is constructed with the same manager, so it can trigger further transitions later.

update(events) receives the already-collected event list from the main loop rather than calling pygame.event.get() itself. This avoids draining the queue twice and makes scenes easier to test in isolation.

SceneManager.set_scene() swaps immediately. The current frame's draw() call will use the new scene, but the new scene's update() runs next frame. That one-frame delay is imperceptible.

Per-scene state lives on the scene instance. GameScene owns px, py, health, and score. When GameOverScene is constructed with self.score, it captures the final value. When the player restarts and a fresh TitleSceneGameScene chain is constructed, all state is reset automatically — no manual clearing required.

Notice the main loop never changes. To add a settings screen, a shop screen, or a cutscene, you write a new Scene subclass and call set_scene() from wherever the transition should happen. The loop stays untouched.

Where to go next

Next: entity design — thinking about how to structure the objects that live inside a scene as your games grow.

Finished reading? Mark it complete to track your progress.

On this page