PVG
30. Battle Entry and Exit — Program Video Games

30. Battle Entry and Exit

Every RPG needs clean transitions between exploration and combat. Walk into an enemy, start a battle, finish the fight, return to where you were. Simple concept, complex state management problem.

The world scene must persist while the battle runs in isolation. When the battle ends, the world must resume exactly as it was - same position, same state, no memory leaks.

The Fractal Code Cycle Applied to Battle Transitions

Recall our fundamental pattern:

Input → Processing → Output

For battle transitions:

  • Input: Collision with battle-triggering entity, player input to exit

  • Processing: Scene swap, entity spawning, state preservation

  • Output: Active battle scene or restored world scene

What Are Battle Transitions Good For?

State Isolation

  • Battle doesn't corrupt world state

  • World doesn't interfere with battle

  • Each system operates independently

Memory Management

  • Battle allocates temporary memory

  • Exit deallocates completely

  • No gradual memory growth across battles

Event-Driven Flow

  • Systems don't need direct references

  • Battle triggers work from anywhere

  • Easy to extend with new trigger types

The State Management Problem

Consider what needs preserving when entering battle:

  • Player's world position

  • Which entities triggered the fight

  • Which scene to return to

And what needs isolating:

  • Battle entity positions

  • Battle-specific state

  • Combat calculations

Getting this wrong means corrupted saves, memory leaks, or lost progress.

The Battle State Structure

Battle_State :: struct {
    arena:           mem.Dynamic_Arena,
    is_active:       bool,
    battle_scene:    ^Scene_State,
    return_scene:    ^Scene_State,
    return_position: Vec3,
}

Arena Allocation

Battles use a dedicated arena. When the battle ends, one call frees everything:

mem.dynamic_arena_free_all(&gs.battle.arena)

No tracking individual allocations, no leak potential. The arena guarantees clean slate for next battle.

State Preservation

return_scene and return_position capture exactly what's needed to restore the world. Nothing more.

Scene Initialization Split

Previously, scene_load did everything. Now it's split:

scene_init :: proc(ss: ^Scene_State, def: Scene_Definition) {
    ss.active_entities = make(type_of(ss.active_entities))
    ss.entities = make(type_of(ss.entities))
    // ... allocate all dynamic structures
    ss.scene_name = strings.clone(def.name)
}

scene_load :: proc(...) -> ^Scene_State {
    ss := new(Scene_State)
    scene_init(ss, def)
    
    // World-specific setup below
    // Geometry, triggers, navigation, player
}

Why Split It

Battle scenes need entity arrays but not navigation grids, triggers, or world geometry. scene_init handles common setup, scene_load handles world-specific concerns.

Battles call scene_init directly, skipping irrelevant world systems.

Entity Metadata for Encounters

Entities now carry battle information:

Entity :: struct {
    // ... existing fields
    battle_entities: [4]Maybe(int),
}

When the player collides with an entity that has battle_entities set, a battle triggers. The array holds entity definition IDs to spawn as enemies.

Self-Referential Data Problem

Entity definitions may reference themselves (an enemy that spawns copies of itself). JSON loading order is unpredictable. Solution: two-pass loading.

First pass - load all definitions, assign IDs:

entity_def.id = len(gs.entity_definitions)
append(&gs.entity_definitions, entity_def)

Second pass - resolve name references to IDs:

for k, v in battle_entities {
    def := must_entity_def_ptr_by_name(gs.entity_definitions, k)
    idx := 0
    for name in v {
        def2 := must_entity_def_ptr_by_name(gs.entity_definitions, name)
        def.battle_entities[idx] = def2.id
        idx += 1
    }
}

Now entities reference each other by stable array indices.

Battle Entry Flow

Player collision detection in player_update:

for idx in ss.active_entities {
    entity := ss.entities[idx]
    if entity.battle_entities != {} {
        dist := rl.Vector3Length(player.position - entity.position)
        if dist < entity.collider_radius + player.collider_radius {
            payload: Event_Payload_Battle_Start
            payload.trigger_entity = entity.handle
            payload.enemy_entity_def_ids[0] = entity.def_id
            events_enqueue(es, Event{type = .Battle_Start, payload = payload})
        }
    }
}

Event subscriber handles transition:

events_subscribe(&gs.events, .Battle_Start, proc(event: Event) {
    payload := event.payload.(Event_Payload_Battle_Start)
    gs := cast(^Game_State)context.user_ptr
    
    player := entity_get(gs.scene, gs.scene.player_handle)
    gs.battle.is_active = true
    gs.battle.return_position = player.position
    
    entity_destroy(gs.scene, payload.trigger_entity)
    camera_reset(&gs.rendering)
    
    if scene_def, ok := gs.scenes[gs.battle_scene_key]; ok {
        gs.scene = battle_start(...)
    }
})

Critical Decision: Destroy Trigger Entity

For simplicity, we destroy the entity that triggered the battle. Alternative approaches (disable, mark defeated, respawn logic) add complexity we don't need yet.

Battle Scene Setup

battle_start spawns combatants:

battle_start :: proc(
    bs: ^Battle_State,
    entity_defs: [dynamic]Entity_Definition,
    scene_def: Scene_Definition,
    return_scene: ^Scene_State,
    enemy_entity_def_ids: [4]Maybe(int),
) -> ^Scene_State {
    // Initialize arena
    if bs.arena.current_block == nil {
        mem.dynamic_arena_init(&bs.arena, ...)
    }
    mem.dynamic_arena_reset(&bs.arena)
    allocator := mem.dynamic_arena_allocator(&bs.arena)
    context.allocator = allocator
    
    bs.is_active = true
    bs.return_scene = return_scene
    
    bs.battle_scene = new(Scene_State)
    scene_init(bs.battle_scene, scene_def)
    
    // Spawn enemies at fixed positions
    for maybe_def_id in enemy_entity_def_ids {
        // ... create enemy entity
        e.position = {8, 0, 2}
        e.movement_speed = 0
    }
    
    // Spawn party
    // ... create warrior entity
    e.position = {-8, 0, 2}
    
    return bs.battle_scene
}

Hardcoded Positions

Enemies at {8, 0, 2}, party at {-8, 0, 2}. Obviously temporary. Formation systems come later. Right now we need visible proof the transition works.

Movement Speed Zero

Field entities may have movement AI. Battles don't want that, so we explicitly disable it.

Battle Update Loop

Minimal implementation:

battle_update :: proc(bs: ^Battle_State) {
    if rl.IsKeyPressed(.SPACE) {
        battle_exit(bs)
    }
}

Space exits battle. No combat logic yet - just the infrastructure to get in and out cleanly.

Battle Exit Flow

battle_exit :: proc(bs: ^Battle_State) {
    emit({type = .Battle_Exit})
}

Event subscriber handles restoration:

events_subscribe(&gs.events, .Battle_Exit, proc(event: Event) {
    gs := cast(^Game_State)context.user_ptr
    
    gs.battle.is_active = false
    gs.scene = gs.battle.return_scene
    
    player := entity_get(gs.scene, gs.scene.player_handle)
    player.position = gs.battle.return_position
    
    mem.dynamic_arena_free_all(&gs.battle.arena)
})

Scene pointer swap, position restore, arena deallocation. Three lines, complete cleanup.

Update Loop Branching

Main update splits on battle state:

if gs.battle.is_active {
    battle_update(&gs.battle)
} else {
    triggers_update(gs.scene, &gs.events)
    player_update(...)
    physics_update(gs.scene)
    rendering_camera_update(...)
}

World systems don't run during battle. Battle systems don't run during exploration. Clean separation.

Event System Ergonomics

Previously, passing Events_State pointer everywhere defeated the purpose of a cross-cutting system. Now:

@(private = "file")
events_state: ^Events_State

events_init :: proc(es: ^Events_State) {
    events_state = es
}

emit :: proc(event: Event) {
    events_enqueue(events_state, event)
}

File-local state pointer, one-line emission. Battle code calls emit({type = .Battle_Exit}) without plumbing.

Design Considerations

Why Arena Allocation

Battles are transient. Everything allocated during battle becomes garbage after. Arena lets us bulk-free instead of tracking lifetimes.

Why Scene Pointer Swap

gs.scene always points to the active scene. Rendering and update code don't need battle awareness. They operate on gs.scene regardless of mode.

Why Event-Driven Transitions

Battle triggers come from various sources (collision, scripted events, debug commands). Events decouple trigger location from transition logic.

Try It Yourself

  1. How would you implement formation positioning instead of hardcoded coordinates?

  2. What data belongs in Battle_State versus Scene_State?

  3. How would you handle multiple consecutive battles without returning to world?

The Big Idea

Transient state systems need explicit lifecycle management. Arena allocation provides bulk deallocation. Scene initialization split allows selective system activation. Event-driven transitions decouple trigger points from state management logic.

The battle system now has complete entry/exit flow with zero memory leaks, preserved world state, and no system coupling. Combat mechanics can build on this foundation without touching the transition infrastructure.