Skip to main content

Scene Management

Organizing Game Worlds with Scenes

Scene management is how we organize game content into manageable chunks - levels, menus, cutscenes, and more. It handles loading, unloading, and transitioning between different parts of your game world seamlessly! 🌍🎬

Understanding Scene Management

🎬 The Movie Production Analogy

Think of scenes like movie production:

graph TD A["Scene Manager"] --> B["Active Scene"] A --> C["Scene Queue"] A --> D["Resource Manager"] A --> E["Transition System"] B --> F["Game Objects"] B --> G["Scene Data"] C --> H["Pending Scenes"] C --> I["Preloading"] D --> J["Textures"] D --> K["Sounds"] D --> L["Scripts"] E --> M["Fade"] E --> N["Slide"] E --> O["Custom"]
Scene graph: a tree of game objects under a scene root, with the Player and its child Weapon highlighted in amber to show that children inherit transforms from their parent
Inside a scene, game objects form a hierarchical tree. Children inherit transforms from their parent — moving the Player also moves its Weapon, Sprite, and any other children. This transform cascade is what distinguishes a scene graph from a flat list of objects.

Interactive Scene Manager Demo

Experience different scene transitions and loading!

Current Scene: Main Menu | Resources: 0 | Memory: 0 MB

Basic Scene Management System

import pygame
import json
import asyncio
from enum import Enum
from typing import Dict, List, Optional

class SceneTransition(Enum):
    """Types of scene transitions"""
    NONE = 0
    FADE = 1
    SLIDE_LEFT = 2
    SLIDE_RIGHT = 3
    DISSOLVE = 4
    ZOOM = 5

class Scene:
    """Base class for game scenes"""
    def __init__(self, scene_manager: 'SceneManager', name: str) -> None:
        self.scene_manager: 'SceneManager' = scene_manager
        self.name: str = name
        self.loaded: bool = False
        self.resources = {}
        self.game_objects = []
        self.camera = None
        
    def load_resources(self) -> None:
        """Load scene-specific resources"""
        pass
    
    def unload_resources(self) -> None:
        """Unload resources to free memory"""
        for resource in self.resources.values():
            if hasattr(resource, 'unload'):
                resource.unload()
        self.resources.clear()
    
    def on_enter(self) -> None:
        """Called when scene becomes active"""
        if not self.loaded:
            self.load_resources()
            self.loaded = True
    
    def on_exit(self) -> None:
        """Called when leaving the scene"""
        pass
    
    def on_pause(self) -> None:
        """Called when scene is paused"""
        pass
    
    def on_resume(self) -> None:
        """Called when scene is resumed"""
        pass
    
    def update(self, dt: float) -> None:
        """Update scene logic"""
        for obj in self.game_objects:
            obj.update(dt)
    
    def draw(self, screen: pygame.Surface) -> None:
        """Draw the scene"""
        for obj in self.game_objects:
            obj.draw(screen)
    
    def handle_event(self, event: pygame.event.Event) -> None:
        """Handle input events"""
        pass

class SceneManager:
    """Manages scenes and transitions between them"""
    def __init__(self, screen: pygame.Surface) -> None:
        self.screen: pygame.Surface = screen
        self.scenes: Dict[str, Scene] = {}
        self.current_scene: Optional[Scene] = None
        self.next_scene: Optional[Scene] = None
        self.scene_stack: List[Scene] = []
        
        # Transition system
        self.transition_active: bool = False
        self.transition_type: SceneTransition = SceneTransition.FADE
        self.transition_duration: float = 0.5
        self.transition_progress: float = 0.0
        self.transition_surface: Optional[pygame.Surface] = None
        
        # Resource management
        self.resource_cache: dict = {}
        self.loading_thread = None
        
    def register_scene(self, name: str, scene: Scene) -> None:
        """Register a new scene"""
        self.scenes[name] = scene
    
    def change_scene(self, scene_name: str, transition: SceneTransition = SceneTransition.FADE) -> None:
        """Change to a different scene"""
        if scene_name not in self.scenes:
            print(f"Scene '{scene_name}' not found!")
            return
        
        self.next_scene = self.scenes[scene_name]
        self.transition_type = transition
        self.start_transition()
    
    def push_scene(self, scene_name: str) -> None:
        """Push a scene onto the stack (for overlays)"""
        if self.current_scene:
            self.current_scene.on_pause()
            self.scene_stack.append(self.current_scene)
        
        if scene_name in self.scenes:
            self.current_scene = self.scenes[scene_name]
            self.current_scene.on_enter()
    
    def pop_scene(self) -> None:
        """Pop the top scene from the stack"""
        if self.scene_stack:
            if self.current_scene:
                self.current_scene.on_exit()
            
            self.current_scene = self.scene_stack.pop()
            self.current_scene.on_resume()
    
    def start_transition(self) -> None:
        """Start a scene transition"""
        self.transition_active = True
        self.transition_progress = 0.0
        
        # Create transition surface
        self.transition_surface = pygame.Surface(self.screen.get_size())
        self.transition_surface.set_alpha(0)
    
    def update_transition(self, dt: float) -> None:
        """Update the transition effect"""
        if not self.transition_active:
            return
        
        self.transition_progress += dt / self.transition_duration
        
        if self.transition_progress >= 0.5 and self.next_scene:
            # Switch scenes at halfway point
            if self.current_scene:
                self.current_scene.on_exit()
            
            self.current_scene = self.next_scene
            self.current_scene.on_enter()
            self.next_scene = None
        
        if self.transition_progress >= 1.0:
            self.transition_active = False
            self.transition_progress = 1.0
    
    def draw_transition(self) -> None:
        """Draw the transition effect"""
        if not self.transition_active:
            return
        
        if self.transition_type == SceneTransition.FADE:
            self.draw_fade_transition()
        elif self.transition_type == SceneTransition.SLIDE_LEFT:
            self.draw_slide_transition(-1)
        elif self.transition_type == SceneTransition.SLIDE_RIGHT:
            self.draw_slide_transition(1)
        elif self.transition_type == SceneTransition.DISSOLVE:
            self.draw_dissolve_transition()
        elif self.transition_type == SceneTransition.ZOOM:
            self.draw_zoom_transition()
    
    def draw_fade_transition(self) -> None:
        """Draw fade transition"""
        # Calculate alpha based on progress
        if self.transition_progress < 0.5:
            alpha = int(255 * (self.transition_progress * 2))
        else:
            alpha = int(255 * (2 - self.transition_progress * 2))
        
        self.transition_surface.fill((0, 0, 0))
        self.transition_surface.set_alpha(alpha)
        self.screen.blit(self.transition_surface, (0, 0))
    
    def draw_slide_transition(self, direction: int) -> None:
        """Draw slide transition"""
        width = self.screen.get_width()
        offset = int(width * self.transition_progress * direction)
        
        if self.current_scene:
            # Draw current scene with offset
            temp_surface = pygame.Surface(self.screen.get_size())
            self.current_scene.draw(temp_surface)
            self.screen.blit(temp_surface, (offset, 0))
    
    def update(self, dt: float) -> None:
        """Update current scene and transitions"""
        self.update_transition(dt)
        
        if self.current_scene and not self.transition_active:
            self.current_scene.update(dt)
    
    def draw(self) -> None:
        """Draw current scene and transitions"""
        if self.current_scene:
            self.current_scene.draw(self.screen)
        
        self.draw_transition()
    
    def handle_event(self, event: pygame.event.Event) -> None:
        """Pass events to current scene"""
        if self.current_scene and not self.transition_active:
            self.current_scene.handle_event(event)

Resource Management

# Resource management for scenes
class ResourceManager:
    """Manages loading and caching of game resources"""
    def __init__(self) -> None:
        self.textures: Dict[str, pygame.Surface] = {}
        self.sounds: Dict[str, pygame.mixer.Sound] = {}
        self.fonts: Dict[str, pygame.font.Font] = {}
        self.data: Dict[str, dict] = {}
        self.loading_queue: list = []
        self.cache_size_limit: int = 100 * 1024 * 1024  # 100MB limit
        self.current_cache_size: int = 0
        
    def load_texture(self, path: str, name: str = None) -> pygame.Surface:
        """Load and cache a texture"""
        if name is None:
            name = path
        
        if name in self.textures:
            return self.textures[name]
        
        try:
            texture = pygame.image.load(path).convert_alpha()
            self.textures[name] = texture
            self.current_cache_size += self.get_surface_size(texture)
            return texture
        except pygame.error as e:
            print(f"Failed to load texture '{path}': {e}")
            return None
    
    def load_sound(self, path: str, name: str = None) -> pygame.mixer.Sound:
        """Load and cache a sound"""
        if name is None:
            name = path
        
        if name in self.sounds:
            return self.sounds[name]
        
        try:
            sound = pygame.mixer.Sound(path)
            self.sounds[name] = sound
            return sound
        except pygame.error as e:
            print(f"Failed to load sound '{path}': {e}")
            return None
    
    def load_font(self, path: str, size: int, name: str = None) -> pygame.font.Font:
        """Load and cache a font"""
        if name is None:
            name = f"{path}_{size}"
        
        if name in self.fonts:
            return self.fonts[name]
        
        try:
            font = pygame.font.Font(path, size)
            self.fonts[name] = font
            return font
        except pygame.error as e:
            print(f"Failed to load font '{path}': {e}")
            return None
    
    def load_json(self, path: str, name: str = None) -> dict:
        """Load and cache JSON data"""
        if name is None:
            name = path
        
        if name in self.data:
            return self.data[name]
        
        try:
            with open(path, 'r') as f:
                data = json.load(f)
            self.data[name] = data
            return data
        except (IOError, json.JSONDecodeError) as e:
            print(f"Failed to load JSON '{path}': {e}")
            return None
    
    def unload_texture(self, name: str) -> None:
        """Unload a texture from cache"""
        if name in self.textures:
            texture = self.textures[name]
            self.current_cache_size -= self.get_surface_size(texture)
            del self.textures[name]
    
    def clear_cache(self) -> None:
        """Clear all cached resources"""
        self.textures.clear()
        self.sounds.clear()
        self.fonts.clear()
        self.data.clear()
        self.current_cache_size = 0
    
    def get_surface_size(self, surface: pygame.Surface) -> int:
        """Calculate memory size of a surface"""
        width, height = surface.get_size()
        # Assuming 4 bytes per pixel (RGBA)
        return width * height * 4
    
    def check_cache_limit(self) -> None:
        """Check if cache exceeds limit and clean if necessary"""
        if self.current_cache_size > self.cache_size_limit:
            # Simple LRU-style cleanup (would need usage tracking in production)
            self.clear_cache()

# Async scene loading
class AsyncSceneLoader:
    """Handles asynchronous scene loading"""
    def __init__(self, resource_manager: ResourceManager) -> None:
        self.resource_manager: ResourceManager = resource_manager
        self.loading_progress: float = 0.0
        self.loading_message: str = ""
        self.is_loading: bool = False
        
    async def load_scene_resources(self, resource_list: List[dict]) -> None:
        """Load scene resources asynchronously"""
        self.is_loading = True
        total_resources = len(resource_list)
        
        for i, resource in enumerate(resource_list):
            self.loading_progress = i / total_resources
            self.loading_message = f"Loading {resource['name']}..."
            
            # Load based on type
            if resource['type'] == 'texture':
                await self.load_texture_async(resource['path'], resource['name'])
            elif resource['type'] == 'sound':
                await self.load_sound_async(resource['path'], resource['name'])
            elif resource['type'] == 'data':
                await self.load_data_async(resource['path'], resource['name'])
            
            # Small delay to prevent blocking
            await asyncio.sleep(0.01)
        
        self.loading_progress = 1.0
        self.is_loading = False
    
    async def load_texture_async(self, path: str, name: str) -> None:
        """Load texture asynchronously"""
        # In practice, you'd use threading for file I/O
        self.resource_manager.load_texture(path, name)
    
    async def load_sound_async(self, path: str, name: str) -> None:
        """Load sound asynchronously"""
        self.resource_manager.load_sound(path, name)
    
    async def load_data_async(self, path: str, name: str) -> None:
        """Load data asynchronously"""
        self.resource_manager.load_json(path, name)

Best Practices

⚡ Scene Management Tips

Key Takeaways

🏋️‍♂️ Practice Exercise

🏋️‍♂️ Exercise 1: Halfway-Point Scene Swap + Stack Overlay + Lazy-Load Guard in One Pygame Window

Objective: Build a single-process pygame demo (~85 lines) that exercises three pillar production-scale scene-management patterns in one runnable program: (1) the halfway-point scene swap during a fade transition (the lesson’s if self.transition_progress >= 0.5 and self.next_scene: block — swap is hidden behind the maximum-opacity moment of the fade), (2) scene stack push/pop with on_pause()/on_resume() preserving the underlying scene’s state vs change_scene’s full on_exit()/on_enter() teardown, and (3) the lazy-loading guard if not self.loaded inside on_enter() preventing double-allocation when scenes are re-entered through stack patterns or rapid change_scene cycles.

Instructions:

  1. Define three Scene subclasses (or one Scene class with name+color+resource_count fields): MENU, LEVEL, PAUSE_OVERLAY. Each has a loaded bool and overrides on_enter/on_exit/on_pause/on_resume to append a line to a shared lifecycle log (e.g. 'on_enter MENU: loading 4 resources'). on_enter starts with if not self.loaded: self.loaded = True; LOG.append(...loading...) — otherwise LOG.append(...SKIP (already loaded)...) so the lazy-load guard is visible. on_exit clears self.loaded.
  2. SceneManager keeps current_scene, next_scene, scene_stack (list), transition_progress (float, 1.0 = idle), and transition_duration. update_transition advances progress by dt/duration each frame and fires the SWAP exactly when progress crosses 0.5: current.on_exit(), self.current_scene = self.next_scene, current.on_enter(), self.next_scene = None.
  3. change_scene(name) sets self.next = scenes[name] and resets self.tp = 0.0; this drives the fade. push_scene(name) calls current.on_pause(), stack.append(current), then current = scenes[name] and current.on_enter() (instant, no fade). pop_scene() calls current.on_exit(), current = stack.pop(), current.on_resume() (instant, no fade).
  4. Render: clear, current_scene.draw(); if transition active, overlay a black rect with alpha = 255 * (progress*2 if progress<0.5 else 2 - progress*2) — peaks at 255 at progress=0.5. HUD: current scene name, stack depth (0 or 1), transition progress bar 0–1 with a vertical red tick at 0.5 (the swap moment), last-5 lifecycle-log lines, per-scene loaded boolean.
  5. Keys: 1 → change_scene('MENU'); 2 → change_scene('LEVEL'); P → push_scene('PAUSE_OVERLAY'); ESC → pop_scene(); R → reset. The visual proves all three patterns simultaneously: pressing 1↔2 fires the fade-blackout-swap-fade visible on the progress bar (on_exit/on_enter pair fires at the red-tick moment); pressing 2 then P then ESC shows on_pause/on_resume firing without unload (LEVEL stays loaded throughout the overlay); pressing 1 then 1 again triggers the lazy-load guard (second on_enter sees self.loaded == True and emits the SKIP line, no duplicate loading).
💡 Hint

The halfway-swap timing is the key insight: at progress=0.5 the fade overlay’s alpha is exactly 255 (fully opaque), so the on_exit→reassign→on_enter sequence happens behind a black screen — the user can’t see the moment the scene changes. Swapping at progress=1.0 (transition complete) would expose a frame of the new scene during the fade-out half (overlay alpha < 255 already), producing a visible single-frame pop. The fade is the visual contract; the swap is the runtime work; doing them at temporally-separated moments (full span vs midpoint) gives correct visuals AND correct lifecycle ordering simultaneously — the same design-time-vs-runtime separation pattern from chat-54 M2 architecture_state_machines’s two-layer transition gate, applied here at scene-lifecycle scope.

✅ Example Solution
import pygame, sys

pygame.init()
SCREEN_W, SCREEN_H = 800, 600
screen = pygame.display.set_mode((SCREEN_W, SCREEN_H))
clock = pygame.time.Clock()
font = pygame.font.Font(None, 22)
LOG = []  # rolling lifecycle log

class Scene:
    def __init__(self, name, color, resource_count):
        self.name = name
        self.color = color
        self.resource_count = resource_count
        self.loaded = False

    def on_enter(self):
        if not self.loaded:  # lazy-load guard
            self.loaded = True
            LOG.append(f'on_enter {self.name}: loading {self.resource_count} resources')
        else:
            LOG.append(f'on_enter {self.name}: SKIP (already loaded)')

    def on_exit(self):
        self.loaded = False
        LOG.append(f'on_exit {self.name}: unloading {self.resource_count} resources')

    def on_pause(self):
        LOG.append(f'on_pause {self.name}: keeping state, hiding music')

    def on_resume(self):
        LOG.append(f'on_resume {self.name}: restoring music')

    def draw(self, surf):
        surf.fill(self.color)
        label = font.render(f'{self.name} (loaded={self.loaded})', True, (255, 255, 255))
        surf.blit(label, (40, 40))

class SceneManager:
    def __init__(self):
        self.scenes = {
            'MENU': Scene('MENU', (40, 60, 100), 4),
            'LEVEL': Scene('LEVEL', (60, 100, 60), 6),
            'PAUSE_OVERLAY': Scene('PAUSE_OVERLAY', (40, 40, 40), 1),
        }
        self.current = self.scenes['MENU']
        self.current.on_enter()
        self.next = None
        self.stack = []
        self.tp = 1.0  # transition progress: 1.0 = idle
        self.tdur = 0.6

    def change_scene(self, name):
        if self.tp < 1.0:
            return  # already transitioning
        self.next = self.scenes[name]
        self.tp = 0.0

    def push_scene(self, name):
        self.current.on_pause()
        self.stack.append(self.current)
        self.current = self.scenes[name]
        self.current.on_enter()

    def pop_scene(self):
        if not self.stack:
            return
        self.current.on_exit()
        self.current = self.stack.pop()
        self.current.on_resume()

    def update(self, dt):
        if self.tp < 1.0:
            self.tp += dt / self.tdur
            if self.tp >= 0.5 and self.next:
                # halfway-point swap: hidden behind max-opacity fade
                self.current.on_exit()
                self.current = self.next
                self.current.on_enter()
                self.next = None
            if self.tp >= 1.0:
                self.tp = 1.0

    def draw(self, surf):
        self.current.draw(surf)
        if self.tp < 1.0:
            alpha = int(255 * (self.tp * 2 if self.tp < 0.5 else 2 - self.tp * 2))
            ov = pygame.Surface((SCREEN_W, SCREEN_H))
            ov.fill((0, 0, 0))
            ov.set_alpha(alpha)
            surf.blit(ov, (0, 0))
        # HUD
        hud_y = SCREEN_H - 180
        pygame.draw.rect(surf, (0, 0, 0), (0, hud_y, SCREEN_W, 180))
        bar_w = int(self.tp * 400)
        pygame.draw.rect(surf, (80, 80, 80), (40, hud_y + 10, 400, 14))
        pygame.draw.rect(surf, (200, 220, 0), (40, hud_y + 10, bar_w, 14))
        # red tick at progress=0.5 (the swap moment)
        pygame.draw.line(surf, (255, 0, 0), (240, hud_y + 8), (240, hud_y + 26), 2)
        info = f'stack={len(self.stack)}  progress={self.tp:.2f}  scene={self.current.name}'
        surf.blit(font.render(info, True, (255, 255, 255)), (40, hud_y + 32))
        for i, line in enumerate(LOG[-5:]):
            surf.blit(font.render(line, True, (180, 220, 255)), (40, hud_y + 60 + i * 22))

mgr = SceneManager()
running = True
while running:
    dt = clock.tick(60) / 1000.0
    for ev in pygame.event.get():
        if ev.type == pygame.QUIT:
            running = False
        if ev.type == pygame.KEYDOWN:
            if ev.key == pygame.K_1:
                mgr.change_scene('MENU')
            elif ev.key == pygame.K_2:
                mgr.change_scene('LEVEL')
            elif ev.key == pygame.K_p:
                mgr.push_scene('PAUSE_OVERLAY')
            elif ev.key == pygame.K_ESCAPE:
                mgr.pop_scene()
    mgr.update(dt)
    mgr.draw(screen)
    pygame.display.flip()
pygame.quit()
sys.exit()

🎯 Quick Quiz

Question 1: The lesson’s update_transition() method performs the actual scene swap (current.on_exit(), reassign self.current_scene = next_scene, next.on_enter()) at transition_progress >= 0.5 rather than waiting until progress >= 1.0 when the transition fully completes. Why is the midpoint the correct moment for the swap?

Question 2: A pause menu opens during gameplay and the player should return to the EXACT mid-game state (player position, score, enemy positions intact) when they close it. Which scene-management API is correct, and which two lifecycle hooks fire on the underlying gameplay scene during the pause?

Question 3: The lesson’s Scene.on_enter() begins with if not self.loaded: self.load_resources(); self.loaded = True. What invariant does the loaded-flag guard protect against, and which prior lessons share the same strict-ordering-with-state-flag shape?

What's Next?

Now that you understand scene management, next we'll explore component systems - a powerful way to build flexible, reusable game objects!