Menu ▾ ▴

Monster AI Thread

Rufus Pearce

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.


1. Architecture

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.


2. Why a Separate Thread?

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.


3. Fixed-Rate AI Scheduling

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.


4. MonsterAI Is the Behaviour Layer

MonsterAIThread does not contain the actual enemy behaviour.

That responsibility belongs to MonsterAI.

The AI implementation handles:

  • waking and sleeping
  • player detection
  • enemy-team detection
  • hearing
  • sound investigation
  • target selection
  • target overrides
  • line-of-sight testing
  • pursuit
  • attack behaviour
  • monster infighting
  • ranged projectiles
  • melee attacks
  • patrols
  • PathNode navigation
  • obstacle avoidance
  • detours
  • gravity
  • death behaviour
  • AI debug visualisation

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

5. AI Update

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

6. Per-Monster State

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.


7. Perception

Monster perception combines multiple mechanisms.

Sight

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.

Enemy Detection

Monsters assigned to teams can detect hostile monsters.

This permits team-based combat and monster-versus-monster behaviour.

Hearing

Monsters can optionally have can_hear enabled.

The AI receives recent player noise events from LogicThread, including events such as:

  • gunfire
  • water splashes

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.


8. Sound Investigation

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:

  1. Finds recent noise events.
  2. Applies the event's loudness-scaled hearing range.
  3. Selects the closest audible event.
  4. Checks its age.
  5. Moves toward the source.
  6. Stops investigating after reaching the location or when the sound expires.

This produces behaviour closer to:

                    player
                      │
                   gunshot
                      │
                      ▼
                sound event
                      │
              ┌───────┴───────┐
              │               │
        sleeping AI       awake AI
              │               │
            wake          investigate
                              │
                              ▼
                       sound location

The investigation state is stored per monster.


9. Target Selection

Once awake, a monster determines what it should pursue.

Fio supports several targeting mechanisms.

Explicit Target

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.

Aggro 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 Targeting

Team-aware monsters search for hostile monsters before falling back to the player.

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.


10. Line of Sight

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.


11. Movement

Monsters move toward their current target when outside their configured stopping distance.

Movement differs according to monster type.

Ground Monsters

Ground monsters move primarily across the X/Z plane while gravity controls vertical position.

Flying Monsters

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

12. SpatialGrid Integration

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.


13. Gravity and Death

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.


14. Patrol System

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:

  • loop
  • ping_pong
  • once

PathNodes 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.


15. Patrol Obstacle Recovery

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.


16. Combat and Infighting

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:

  • health is reduced
  • OnDamaged can be fired through I/O
  • lethal damage sets dead
  • OnDeath can be fired
  • surviving victims can acquire the attacker as an aggro target

This creates Doom-style monster infighting without requiring a separate combat system.


17. Flying Monster Projectiles

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.


18. Notarget

Fio's notarget cheat changes monster targeting behaviour without freezing the AI entirely.

When active:

  • monsters do not target the player
  • shooting is disabled
  • animation state is reset
  • monsters can still fall under gravity
  • monsters can still patrol
  • hearing-enabled monsters can still investigate sounds

This is an important distinction: notarget suppresses player aggression rather than disabling the Monster AI system altogether.


19. Synchronisation

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.


20. Thread Lifecycle

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.


21. Debug Visualisation

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:

  • waking
  • acquiring an enemy
  • beginning an engagement
  • reaching a sound source
  • patrol movement
  • patrol blockage
  • detour selection
  • monster damage
  • monster death
  • projectile creation

This makes the AI system considerably easier to diagnose from Fio's debug console.


22. Performance Model

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.


23. Relationship to LogicThread

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.


24. Design Summary

The Monster AI Thread exists to keep Fio's enemy simulation independent, bounded and scalable.

It provides:

  • a dedicated worker thread
  • a fixed 30 Hz AI update rate
  • explicit synchronisation with the main engine
  • a clean separation between scheduling and AI behaviour
  • SpatialGrid-backed world queries
  • support for perception, navigation, combat and infighting
  • integration with entity I/O
  • runtime debug instrumentation

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.