24. Trigger Volumes
Every hidden area you've discovered, every trap you've accidentally triggered, every cutscene that played when you entered a room - all rely on triggers.
Triggers are invisible volumes in the game world that fire events when entities move through them.
The Fractal Code Cycle Applied to Triggers
Recall our fundamental pattern:
Input → Processing → Output
For our trigger system:
- Input: Entity positions, trigger volumes, layer masks
- Processing: Check if entities enter or exit trigger bounds
- Output: Events fired for game systems to respond to
What Are Triggers Good For?
- Triggering cutscenes or dialogue
- Invisible boundaries that load new scenes
- Checkpoint systems
- Tutorial prompts at specific locations
Trigger System in the System Stack
Triggers sit in the World Layer alongside collisions:
- Base Layer: Entities, Input, Time, Assets, Save/Load
- Presentation: Camera, Shaders, Animation, UI, Particles
- Interaction: Player, Collisions, AI, Scenes, Triggers
- Game Systems: Stats, Combat, Inventory, Abilities
- Content: Dialogue, Quests, NPC Schedules
Triggers depend on:
- Entity system (for positions and handles)
- Event system (for firing notifications)
- Physics system (for collision checks)
The Trigger Structure
Trigger :: struct {
pos, size: Vec3,
use_count: int,
layers: Layers,
entities_inside: [dynamic]Entity_Handle,
flags: Trigger_Flags,
on_enter: Maybe(Event),
on_exit: Maybe(Event),
}
Layer System
Triggers use layers to filter which entities they respond to:
Layer :: enum {
Player,
NPC,
Item,
}
Layers :: bit_set[Layer]
This allows triggers to respond only to specific entity types. A dialogue trigger might only care about the player, whereas traps might be triggered by either NPCs or the Player.
Entity Tracking
The entities_inside slice tracks which entities are currently within the trigger volume. This enables:
- Firing exit events when entities leave
- Preventing duplicate enter events
- Supporting multi-entity triggers
Optional Events
Both on_enter and on_exit are Maybe(Event) types, meaning they're optional. A checkpoint trigger might only need on_enter, whilst a zone that tracks player presence might use both.
Trigger Flags
Trigger_Flag :: enum {
Single_Use,
}
Trigger_Flags :: bit_set[Trigger_Flag]
Flags modify trigger behaviour. Single_Use would make a trigger fire once then disable itself - useful for one-time events like cutscenes.
The Update Algorithm
triggers_update :: proc(ws: ^World_State, es: ^Events_State) {
for entity, i in ws.entities {
for &trigger in ws.triggers {
if entity.layer not_in trigger.layers {
continue
}
entity_handle := Entity_Handle{i, ws.generations[i]}
aabb := aabb_from_pos_size(trigger.pos, trigger.size)
is_inside := check_point_vs_aabb(entity.position, aabb)
index, was_inside := slice.linear_search(trigger.entities_inside[:], entity_handle)
if is_inside && !was_inside {
if on_enter, on_enter_ok := trigger.on_enter.?; on_enter_ok {
events_enqueue(es, on_enter)
}
append(&trigger.entities_inside, entity_handle)
}
if !is_inside && was_inside {
if on_exit, on_exit_ok := trigger.on_exit.?; on_exit_ok {
events_enqueue(es, on_exit)
}
unordered_remove(&trigger.entities_inside, index)
}
}
}
}
The Algorithm Steps
- Layer Filtering: Skip entities not on the trigger's layer mask
- State Detection: Check if entity is inside trigger volume
- Change Detection: Compare current state with previous state
- Event Firing:
- Fire
on_enterif entity just entered - Fire
on_exitif entity just left
- Fire
- Tracking Update: Add or remove entity from tracking list
Integration with Entity Layers
Entities now have a layer field:
Entity :: struct {
// ... existing fields
layer: Layer,
}
This must be set when creating entities:
player.layer = .Player
npc.layer = .NPC
Collision Detection Helpers
Two new physics procedures support trigger detection:
check_sphere_vs_aabb :: proc(sphere_center: Vec3, sphere_radius: f32, box: AABB) -> bool
check_point_vs_aabb :: proc(position: Vec3, box: AABB) -> bool
The point check uses a tiny sphere radius (0.01) to treat entity positions as points for trigger detection.
Creating Triggers
trigger_spawn(&gs.world, Trigger{
pos = Vec3{2, 0.5, 2},
size = Vec3{1, 2, 1},
layers = {.Player},
on_enter = Event{
type = .Game_Start,
debug_level = .Info,
debug_message = "Entered Player trigger!",
},
on_exit = Event{
type = .Game_Start,
debug_level = .Info,
debug_message = "Exited Player trigger!",
},
})
Triggers are spawned into the world state and persist across frames.
Debug Visualisation
Triggers render as yellow bounding boxes:
for t in gs.world.triggers {
aabb := aabb_from_pos_size(t.pos, t.size)
rl.DrawBoundingBox(aabb, rl.YELLOW)
}
This makes it easy to see trigger placement and size during development.
Design Considerations
Performance
With dozens of entities and triggers, the double loop in triggers_update is acceptable. For hundreds of entities, consider:
- Spatial partitioning (only check nearby triggers)
- Separate active/inactive trigger lists
Single Use Triggers
The Single_Use flag isn't implemented yet but would work like this:
if .Single_Use in trigger.flags && trigger.use_count > 0 {
continue // Skip this trigger
}
// After firing on_enter
trigger.use_count += 1
Multi-Entity Triggers
The current system supports multiple entities in one trigger. You could extend this to require a specific count:
required_entity_count: int,
Then only fire events when len(entities_inside) == required_entity_count.
The Big Idea
Triggers allow us to fire events based on spatial positions of entities.
By providing a set of trigger events, levels can be created and modified without touching the compiled code.
The layer system ensures triggers respond to the correct entities.