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
How would you implement formation positioning instead of hardcoded coordinates?
What data belongs in
Battle_StateversusScene_State?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.