Source:
engine/monster_ai.py
Owner:LogicThread
AI update rate: 30 Hz
Purpose: Run monster behaviour independently of the main 60 Hz simulation loop
The Monster AI Thread is Fio's dedicated worker for updating monster behaviour.
It separates potentially expensive AI processing from the main LogicThread, allowing the game simulation to maintain its fixed 60 Hz timestep while monster decision-making runs at a lower 30 Hz rate.
The actual behaviour is implemented by MonsterAI; MonsterAIThread is the scheduling and synchronisation layer that repeatedly calls it.
The relationship between the two classes is deliberately simple:
LogicThread
│
│ owns
▼
MonsterAI
│
│ executed by
▼
MonsterAIThread
│
│ 30 Hz
▼
MonsterAI.update()
MonsterAI is constructed by LogicThread and retained as self.monster_ai.
The worker thread is then created with the LogicThread, MonsterAI instance and monster lock:
MonsterAIThread(
self,
self.monster_ai,
self._monster_lock,
tick_rate=30
)
and started by LogicThread.
Fio's main LogicThread operates at 60 Hz because player movement, physics, collision and other gameplay systems benefit from a relatively high-frequency fixed timestep.
Monster AI does not necessarily need to make decisions 60 times per second.
Running the AI at 30 Hz gives:
LogicThread 60 Hz
MonsterAIThread 30 Hz
This halves the frequency of the potentially expensive monster update workload while retaining a sufficiently responsive AI update rate.
The separation also means that AI processing does not have to occupy the main simulation thread for every one of its ticks.
MonsterAIThread maintains its own timing loop rather than simply depending on the render loop.
The implementation uses elapsed time and an accumulator to determine when an AI update is due.
Conceptually:
elapsed time
│
▼
AI accumulator
│
└── >= 1/30 sec ──► MonsterAI.update()
│
▼
AI state changes
This gives the AI its own fixed update cadence while allowing the worker to sleep when there is no immediate work to perform.
The timing loop is therefore independent of monitor refresh rate and Qt repaint frequency.
MonsterAIThread does not contain the actual enemy behaviour.
That responsibility belongs to MonsterAI.
The AI implementation handles:
MonsterAI.update(delta) is the main entry point called by the worker.
This separation keeps the class responsibilities clear:
| Component | Responsibility |
|---|---|
LogicThread |
Main 60 Hz game simulation and lifecycle |
MonsterAIThread |
AI scheduling and threading |
MonsterAI |
Monster behaviour |
MonsterThing |
Monster entity/data representation |
SpatialGrid |
Spatial acceleration for collision/raycast queries |
Each AI update begins by checking whether there is an active player and whether the player is alive.
If there is no player, or the player is dead, the AI update returns immediately.
The thread then allows MonsterAI to process the precomputed monster collection maintained by LogicThread.
This avoids repeatedly scanning the complete level for monster entities.
The normal flow is:
MonsterAIThread
│
▼
MonsterAI.update()
│
├── player valid?
│
├── iterate monsters
│
├── update state
│
├── perception
│
├── target selection
│
├── movement
│
└── combat
MonsterAI maintains a separate runtime state dictionary for each monster.
The state is keyed using the monster object's identity and contains values such as:
shoot_timer
anim_timer
in_sight
vel_y
investigating_sound
This allows transient AI state to exist independently of the persistent entity properties.
For example, a monster can remember that it is currently investigating a sound without that temporary state having to become part of the map definition.
Monster perception combines multiple mechanisms.
Monsters can detect the player within the configured sight range.
Sight checks use squared distances where only threshold comparisons are required, avoiding unnecessary square-root operations.
Monsters assigned to teams can detect hostile monsters.
This permits team-based combat and monster-versus-monster behaviour.
Monsters can optionally have can_hear enabled.
The AI receives recent player noise events from LogicThread, including events such as:
Noise has a loudness multiplier, so different sounds can have different effective ranges.
gunshot
│
└──► larger hearing radius
water splash
│
└──► reduced hearing radius
A sleeping monster can therefore wake because of either sight or an audible event.
Hearing a noise is not equivalent to immediately knowing where the player is.
Once awake, a monster capable of hearing can investigate the location of the recent sound.
The AI:
This produces behaviour closer to:
player
│
gunshot
│
▼
sound event
│
┌───────┴───────┐
│ │
sleeping AI awake AI
│ │
wake investigate
│
▼
sound location
The investigation state is stored per monster.
Once awake, a monster determines what it should pursue.
Fio supports several targeting mechanisms.
An I/O-driven target_name can override normal target selection.
If the named entity exists and has a position, the monster uses that entity as its target.
Monster infighting can assign an _aggro_target.
This allows a monster that has been attacked by another monster to retaliate against its attacker.
Team-aware monsters search for hostile monsters before falling back to the player.
If there is no explicit or hostile-monster target, the player becomes the normal target.
This gives target selection a layered structure rather than a single hard-coded "attack player" rule.
After selecting a target, MonsterAI performs a line-of-sight test between the monster and target.
The monster's eye position and the target's eye position are used for the ray.
The result determines whether the target is actually visible rather than merely being within the sight radius.
When monster debugging is active, the ray is stored for visualisation and represented as visible debug geometry.
This makes the F7-style AI debugging useful for diagnosing why a monster can or cannot see its target.
Monsters move toward their current target when outside their configured stopping distance.
Movement differs according to monster type.
Ground monsters move primarily across the X/Z plane while gravity controls vertical position.
Flying monsters can move through three-dimensional space toward their target.
Movement is collision-tested before being committed.
If the direct movement vector is blocked, the AI attempts movement along individual axes to slide around the obstacle.
direct movement
│
▼
blocked
/ \
▼ ▼
slide X slide Z
│ │
└──┬──┘
▼
alternative movement
Monster collision and raycast queries use the engine's SpatialGrid when available.
Instead of testing a monster against every brush in the level:
Monster
│
▼
SpatialGrid
│
▼
nearby brushes
This reduces the amount of geometry examined for collision and visibility operations.
The MonsterAI instance receives its spatial grid from LogicThread through set_spatial_grid().
The source explicitly identifies this as a performance optimisation, reducing per-monster collision/raycast work from the entire brush collection to nearby geometry.
Ground monsters receive gravity processing as part of their AI update.
Their vertical velocity is maintained in per-monster state.
When a monster dies, normal AI behaviour stops and its sprite is allowed to fall toward the ground.
The dead-monster path therefore remains active after death, but only for the physical falling behaviour.
Monsters can patrol using PathNode entities.
A patrol chain is constructed by following each node's next_node relationship.
PathNode A
│
▼
PathNode B
│
▼
PathNode C
│
▼
PathNode D
The AI supports patrol modes including:
loopping_pongoncePathNodes can also define patrol speed and monster-type compatibility.
The AI fires I/O outputs such as OnMonsterLeft when appropriate, allowing patrol movement to participate in Fio's entity I/O system.
Patrol movement does not assume that a PathNode can always be reached directly.
If the monster repeatedly encounters an obstacle, the AI tracks how many consecutive ticks it has been blocked.
After reaching the configured stuck threshold, it searches for a nearby compatible PathNode that can act as a detour.
Current PathNode
│
▼
blocked
│
│ repeated failures
▼
detour search
│
▼
Nearby PathNode
│
▼
resume patrol
This provides lightweight obstacle recovery without requiring a heavyweight global navigation-mesh system.
MonsterAI supports both player combat and monster-versus-monster combat.
When a monster fires, the AI can detect another monster intersecting the firing ray.
If an enemy monster is caught in the line of fire, it can receive the damage instead of the player.
Same-team monsters are explicitly excluded from crossfire.
When a monster damages another monster:
OnDamaged can be fired through I/OdeadOnDeath can be firedThis creates Doom-style monster infighting without requiring a separate combat system.
Flying monsters can use projectile attacks.
A projectile contains runtime information including:
position
velocity
owner
sprite
lifetime
damage
size
distance travelled
The projectile is inserted into LogicThread's shared monster-projectile collection.
This means the AI thread can initiate the attack while the projectile becomes part of the engine's broader runtime projectile state.
Projectiles have finite travel distance and can therefore be dodged rather than behaving as instantaneous hits.
Fio's notarget cheat changes monster targeting behaviour without freezing the AI entirely.
When active:
This is an important distinction: notarget suppresses player aggression rather than disabling the Monster AI system altogether.
Because MonsterAIThread operates concurrently with LogicThread, shared state requires explicit synchronisation.
LogicThread creates a re-entrant monster lock:
_monster_lock
and passes it into MonsterAIThread.
There is also a separate player-damage lock used where player health can be modified concurrently.
The result is:
LogicThread ──────┐
│
shared state
│
MonsterAIThread ──┘
│
└── protected by monster synchronisation
This prevents concurrent monster updates and other engine operations from freely modifying shared AI state at the same time.
The Monster AI worker is managed by LogicThread.
When the AI needs to be started, LogicThread first stops any existing worker and creates a fresh MonsterAIThread with a 30 Hz tick rate.
The thread is then started explicitly.
When the runtime is stopped, LogicThread calls _stop_monster_ai(), which signals the worker to stop.
This keeps thread ownership inside the main runtime controller rather than allowing individual monsters to create their own workers.
MonsterAI maintains a collection of debug rays for visual inspection.
When monster debugging is active, the AI records perception rays containing:
start
end
colour
A visible ray can therefore show whether a monster's target is currently considered visible.
Debug logging also reports significant AI events such as:
This makes the AI system considerably easier to diagnose from Fio's debug console.
The Monster AI architecture is built around several deliberate optimisations:
| Optimisation | Purpose |
|---|---|
| 30 Hz worker | Reduces AI update frequency |
| Precomputed monster list | Avoids repeated entity-type scans |
| SpatialGrid | Limits collision/raycast queries to nearby geometry |
| Squared-distance tests | Avoids unnecessary square roots |
| Cached PathNode lookup | Uses LogicThread's entity-name cache |
| Dedicated worker | Keeps AI work separate from the 60 Hz logic scheduler |
| Shared runtime state | Avoids duplicating the game world for AI |
The AI therefore remains relatively heavyweight in capability while avoiding a naive "every monster scans the entire level every frame" architecture.
The Monster AI Thread is not a replacement for LogicThread.
It is a specialised worker underneath it.
LogicThread
│
┌──────────┴──────────┐
│ │
Main simulation Monster AI
60 Hz 30 Hz
│ │
│ MonsterAIThread
│ │
└──────────┬──────────┘
│
shared world
│
┌──────────┴──────────┐
▼ ▼
Render state Game entities
LogicThread remains responsible for the overall game lifecycle and 60 Hz simulation. MonsterAIThread specialises in the computationally independent problem of continuously evaluating monster behaviour.
This gives Fio a useful two-rate simulation model:
60 Hz for the core game simulation, 30 Hz for autonomous monster reasoning.
The Monster AI Thread exists to keep Fio's enemy simulation independent, bounded and scalable.
It provides:
The underlying design is intentionally lightweight. Fio does not create a thread per monster; instead, one MonsterAIThread services the complete monster population, with each monster retaining its own runtime state inside MonsterAI.
This makes the system substantially more appropriate for a game engine than giving every autonomous entity its own update thread.