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:
- Scenes: Individual sets or locations (Level 1, Boss Arena, Shop)
- Transitions: Moving between sets (Fade, Slide, Dissolve)
- Props/Assets: Objects needed for each scene
- Loading: Setting up the next scene while current plays
- Memory: Striking sets no longer needed
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
- Lazy Loading: Load resources only when needed
- Preloading: Load next scene while current plays
- Resource Sharing: Share common resources between scenes
- Memory Management: Unload unused resources
- Scene Pooling: Reuse scene instances when possible
- Async Loading: Load resources without blocking
- Transition Masking: Hide loading with smooth transitions
- Error Handling: Gracefully handle missing resources
Key Takeaways
- 🎬 Scenes organize game content into manageable chunks
- 📦 Resource management prevents memory issues
- 🔄 Smooth transitions hide loading times
- 📚 Scene stacks enable complex navigation
- 🎯 Cameras provide viewport into large worlds
- ⚡ Async loading keeps games responsive
- 🔧 Proper architecture makes games scalable
🏋️♂️ 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:
- Define three Scene subclasses (or one Scene class with name+color+resource_count fields): MENU, LEVEL, PAUSE_OVERLAY. Each has a
loadedbool 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 withif not self.loaded: self.loaded = True; LOG.append(...loading...)— otherwiseLOG.append(...SKIP (already loaded)...)so the lazy-load guard is visible. on_exit clears self.loaded. - 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.
- 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).
- 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.
- 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!