PVG
25. Scene Switching — Program Video Games

25. Scene Switching

Distinct locations in your game - the town square, wilderness paths, dungeon chambers - each needs its own set of entities, geometry, and triggers. When players move between these locations, the game must efficiently swap out one scene and load another without memory leaks or fragmentation.

This brings us to the Scene System - a structured approach to managing distinct game locations and their associated data.

The Fractal Code Cycle Applied to Scenes

Recall our fundamental pattern:

Input → Processing → Output

For our scene system:

  • Input: Scene definition data (from JSON), scene change requests
  • Processing: Allocate scene state, populate entities and geometry, manage transitions
  • Output: Fully initialised scene ready for gameplay

What Are Scenes Good For?

Discrete Locations

  • Town areas with NPCs and shops
  • Wilderness regions with enemies
  • Indoor locations like houses or dungeons
  • Battle arenas

Memory Management

  • Load only what's needed for the current location
  • Unload everything when leaving
  • Prevent memory fragmentation
  • Clean slate for each scene

Data Organisation

  • Each scene contains its own entities, geometry, triggers
  • Scene definitions describe what to load
  • Scene state holds the runtime data
  • Clear separation between definition and state

Scene Definitions vs Scene State

Scene Definition

A scene definition is the template - static data describing what a scene should contain:

Scene_Definition :: struct {
    name:              string,
    objects:           []Object,
    static_geometry:   []OBB3,
    walkable_surfaces: []Walkable_Surface,
    triggers:          []Trigger,
}

Loaded from JSON files and stored in gs.scenes map. These persist for the entire game session.

Scene State

Scene state is the instance - runtime data for the currently active scene:

Scene_State :: struct {
    allocator:             mem.Allocator,
    scene_name:            string,
    player_handle:         Entity_Handle,
    entities:              [dynamic]Entity,
    generations:           [dynamic]int,
    active_entities:       [dynamic]int,
    unused_entity_handles: [dynamic]int,
    objects:               [dynamic]Object,
    static_geometry:       [dynamic]OBB3,
    walkable_surfaces:     [dynamic]Walkable_Surface,
    triggers:              [dynamic]Trigger,
}

Created when entering a scene, destroyed when leaving. This is the "living" version with entities moving, triggers activating, etc.

Memory Management with Arena Allocators

Why Arena Allocators?

Arena allocators are perfect for scene management:

Fast Allocation: Bump pointer allocation, no searching for free blocks Fast Deallocation: Reset the entire arena at once, no individual frees Cache Friendly: Sequential memory layout No Fragmentation: Each scene gets a clean slate

The Scene Arena

The game state now uses a scene arena instead of individual allocations:

Game_State :: struct {
    // ... other fields ...
    scene_arena: mem.Dynamic_Arena,
    scene:       ^Scene_State,
    scenes:      map[string]Scene_Definition,
}
  • scene_arena: Memory pool for scene data
  • scene: Pointer to current scene state
  • scenes: Map of scene definitions (persists)

Scene Creation Process

The scene_create procedure handles the full scene initialisation:

scene_create :: proc(arena: ^mem.Dynamic_Arena, def: Scene_Definition) -> ^Scene_State {
    // Initialise arena if needed
    if arena.current_block == nil {
        mem.dynamic_arena_init(arena, alignment = runtime.MAP_CACHE_LINE_SIZE)
    }
    
    // Reset arena - destroys previous scene
    mem.dynamic_arena_reset(arena)
    allocator := mem.dynamic_arena_allocator(arena)
    
    // Use scene allocator for everything
    context.allocator = allocator
    
    // Allocate scene state
    ss := new(Scene_State)
    
    // Initialise all dynamic arrays
    ss.active_entities = make(type_of(ss.active_entities))
    ss.entities = make(type_of(ss.entities))
    ss.generations = make(type_of(ss.generations))
    // ... more arrays ...
    
    // Create player
    ss.player_handle = player_init(ss)
    
    // Copy scene definition data
    append(&ss.objects, ..def.objects[:])
    append(&ss.static_geometry, ..def.static_geometry[:])
    append(&ss.walkable_surfaces, ..def.walkable_surfaces[:])
    
    // Initialise triggers with their dynamic arrays
    for def_trigger in def.triggers {
        trigger := def_trigger
        trigger.entities_inside = make(type_of(trigger.entities_inside))
        append(&ss.triggers, trigger)
    }
    
    ss.scene_name = strings.clone(def.name)
    
    return ss
}

Key Steps

  1. Reset Arena: Destroys previous scene completely
  2. Allocate State: Create new scene state structure
  3. Initialise Arrays: All dynamic arrays use scene allocator
  4. Copy Data: Transfer definition data to scene state
  5. Special Handling: Triggers need their internal arrays initialised

Scene Parsing and Definitions

Scene definitions are loaded from JSON and parsed into structures:

scene_parse :: proc(data: []u8, allocator := context.allocator) -> (def: Scene_Definition) {
    // Parse into temporary structure
    temp_def: Scene_Definition
    json.unmarshal(data, &temp_def, .SJSON, context.temp_allocator)
    
    // Use passed allocator for permanent storage
    context.allocator = allocator
    
    def.name = strings.clone(temp_def.name)
    
    // Allocate and copy arrays
    def.objects = make(type_of(def.objects), len(temp_def.objects))
    def.static_geometry = make(type_of(def.static_geometry), len(temp_def.static_geometry))
    def.walkable_surfaces = make(type_of(def.walkable_surfaces), len(temp_def.walkable_surfaces))
    
    copy(def.objects, temp_def.objects)
    copy(def.static_geometry, temp_def.static_geometry)
    copy(def.walkable_surfaces, temp_def.walkable_surfaces)
    
    // Manual parsing for complex types (triggers)
    // ...
    
    return
}

This two-stage parsing ensures:

  • Temporary parsing uses temp allocator
  • Final definition uses specified allocator (usually main allocator)
  • Scene definitions persist for game duration

Scene Transitions

Changing scenes is now a single operation:

// Before (old approach)
scene_change(gs, gs.scenes["Town"])

// After (new approach)
gs.scene = scene_create(&gs.scene_arena, gs.scenes["Town"])

The arena reset in scene_create automatically destroys the previous scene - no need for manual cleanup.

Integration with Entity System

All entity functions now take Scene_State instead of World_State:

entity_create :: proc(ws: ^Scene_State) -> Entity_Handle
entity_get :: proc(ws: ^Scene_State, handle: Entity_Handle) -> ^Entity
entity_destroy :: proc(ws: ^Scene_State, handle: Entity_Handle)
entities_update :: proc(ws: ^Scene_State, dt: f32)

This makes it clear that entities belong to a scene and are destroyed with it.

Scene System in the System Stack

The Scene System sits in the World Layer:

  • Dependencies: Assets (for models), Time (for updates)
  • Provides: Environment for Player, Collisions, AI
  • Manages: Entities, geometry, triggers, transitions

It acts as the container for all interactive elements in a location.

Debug Memory Tracking

In debug builds, we now track all allocations:

when ODIN_DEBUG {
    permanent_arena: mem.Dynamic_Arena
    mem.dynamic_arena_init(&permanent_arena, alignment = runtime.MAP_CACHE_LINE_SIZE)
    context.allocator = mem.dynamic_arena_allocator(&permanent_arena)
    
    track: mem.Tracking_Allocator
    mem.tracking_allocator_init(&track, context.allocator)
    context.allocator = mem.tracking_allocator(&track)
    
    defer {
        if len(track.allocation_map) > 0 {
            fmt.eprintf("=== %v allocations not freed: ===
", len(track.allocation_map))
            for _, v in track.allocation_map {
                fmt.eprintf("- %v bytes @ %v
", v.size, v.location)
            }
        }
    }
}

This catches memory leaks during development.

Design Considerations

Why Arena Allocators for Scenes?

Advantages:

  • Extremely fast allocation and deallocation
  • No fragmentation between scenes
  • Simple mental model
  • Perfect for data with the same lifetime

Trade-offs:

  • Can't free individual objects
  • Everything in scene has same lifetime
  • May use more memory than necessary

For our RPG with distinct scenes, these trade-offs are worth it.

Why Pointer to Scene State?

scene: ^Scene_State,  // Pointer

Using a pointer instead of a value has benefits:

  • Arena reset doesn't need to update Game_State
  • Clear that scene can be nil
  • Easier to pass around
  • Smaller copy when passing Game_State

Scene Definition Storage

Scene definitions use the main allocator:

if data, ok := os.read_entire_file(path, context.temp_allocator); ok {
    def := scene_parse(data)  // Uses main allocator
    gs.scenes[def.name] = def
}

They persist for the entire game session so other systems can reference them.

The Big Idea

Scene systems separate what should exist (definitions) from what currently exists (state).

Arena allocators make scene transitions instant - one reset operation destroys everything and provides a clean slate for the next scene.

This approach eliminates entire categories of bugs:

  • No dangling entity pointers between scenes
  • No forgotten cleanup code
  • No memory fragmentation
  • No gradual memory leaks

The scene system transforms complex memory management into a simple, predictable operation.