Source:
engine/logic_thread.py
Role: Central simulation and game-logic scheduler
Simulation rate: 60 Hz
Threading model: Dedicated logic thread with separate Monster AI worker
The Logic Thread is Fio's central runtime simulation thread. It provides a fixed-rate execution loop shared by both editor mode and play mode, handling gameplay state, physics, entity interaction, I/O, movers, doors, triggers, portals, pickups, collision preparation, plugin events, and other time-dependent systems.
Unlike the renderer, which is concerned with producing frames, the Logic Thread is responsible for advancing the simulated world.
The implementation is deliberately independent of the Qt rendering loop. This keeps simulation timing separate from GPU presentation and allows the game state to continue progressing at a controlled rate regardless of rendering workload.
LogicThread runs at:
60 ticks per second
with a tick duration of:
1 / 60 = 0.016666... seconds
The thread uses a high-resolution time.perf_counter() clock and an accumulator rather than simply sleeping for 1/60 second after every update.
Conceptually:
elapsed time
│
▼
accumulator
│
├── >= 1 tick ──► simulation tick
│
├── >= 1 tick ──► simulation tick
│
└── remainder retained
This means that elapsed wall-clock time is converted into discrete simulation steps.
The accumulator can execute multiple ticks when necessary, preventing small timing variations from permanently changing the simulation rate.
Each completed tick also updates the thread's performance counter, allowing Fio to report the actual logic throughput independently of the nominal 60 Hz target.
LogicThread is shared between:
The main loop selects the appropriate update path depending on play_mode.
LogicThread
│
fixed 60 Hz loop
│
┌────────┴────────┐
│ │
Editor Mode Play Mode
│ │
_tick_editor_mode _tick_play_mode
This avoids maintaining separate timing infrastructure for the editor and game runtime.
The editor therefore has access to the same continuously running logic infrastructure used by the game.
When Fio is not playing a level, LogicThread runs _tick_editor_mode().
Editor-mode processing includes the real-time state required by the editor viewport, including camera movement and mouse-look handling.
The editor camera is maintained directly by LogicThread rather than being tied exclusively to Qt's paint/update cycle.
This is important because viewport rendering and editor simulation are separate concerns:
Qt / Renderer
│
│ displays
▼
current editor state
LogicThread
│
│ updates
▼
editor camera / interaction state
The editor can therefore maintain continuous camera movement while the renderer consumes the resulting state.
When play mode is enabled, LogicThread switches to _tick_play_mode().
Play-mode processing is the primary simulation path and coordinates the active game systems.
The Logic Thread owns or coordinates state for:
The result is a single authoritative place through which the majority of time-dependent game behaviour advances.
Entering play mode performs considerably more than simply setting a boolean.
set_play_mode(True) prepares the runtime world before the first simulation tick.
Among other things it:
This means expensive preparation work is performed at the play-mode boundary, rather than repeatedly in the 60 Hz hot path.
One of the important performance characteristics of LogicThread is that it does not repeatedly search the entire level for every operation.
When play begins, _build_entity_caches() constructs O(1) lookup dictionaries for entity names and IDs.
name ─────────► entity
id ─────────► entity
It also creates pre-filtered collections for frequently accessed entity types:
This is particularly important for large maps because the hot path does not need to repeatedly perform:
for every brush
for every thing
determine whether this is a monster/trigger/pickup...
Instead, the relevant collections are prepared once and reused.
The monster collection additionally receives an ID lookup so MonsterAI does not need to repeatedly scan the complete level entity set.
LogicThread is also responsible for preparing runtime collision data.
Normal box brushes can use the fast AABB collision path.
Clipped or otherwise geometrically complex brushes can instead contain runtime geometry information. At play start, solid angled brushes are converted into world-space collision triangles.
Brush geometry
│
▼
build_collision_mesh()
│
▼
world-space triangles
│
▼
mesh collision
This allows ramps, wedges and other convex geometry to provide proper collision rather than being treated as their enclosing box.
Non-solid brushes such as water, fog, triggers and subtractive geometry are excluded.
The generated collision information is runtime data and is removed when the level is no longer using it.
Things with model_path can also receive runtime collision.
Fio supports:
The GLB collision path deliberately uses the CPU-only GLBLoader, avoiding OpenGL calls from the Logic Thread.
This is important because OpenGL context ownership belongs to the rendering side of the engine; collision preparation must remain safe on the logic worker.
LogicThread maintains a cached combined collision collection:
brushes
+
model collision brushes
│
▼
_collision_brushes_cache
Previously, concatenating these collections inside hot paths could create unnecessary allocations.
The cache is rebuilt only when the underlying collision collections actually change.
This is representative of the general LogicThread design: prepare structural information outside the per-tick path and keep the 60 Hz loop focused on changing state.
Movers and doors are maintained as explicit runtime systems.
LogicThread keeps:
This allows animated world geometry to be updated without repeatedly reconstructing the relevant collections.
Parented lights and portals are also tracked so that moving geometry can carry associated entities with it.
LogicThread maintains trigger state for the player.
Runtime state includes:
player_in_triggers
fired_once_triggers
This allows trigger volumes to support both continuously active behaviour and one-shot activation.
Trigger brushes are pre-indexed during runtime cache construction, avoiding a full level scan on every tick.
Fio's entity I/O system is directly connected to LogicThread.
During construction, the Logic Thread creates the IOManager and provides it with access to:
This makes LogicThread the runtime execution environment for entity I/O.
The architecture is therefore approximately:
Entity Output
│
▼
IOManager
│
▼
LogicThread
│
▼
Target entity / input handler
│
▼
World state change
This is particularly important for Radiant-style entities such as logic timers, doors, movers and other entities whose behaviour is expressed through connections rather than hard-coded relationships.
The Logic Thread is also the engine's native plugin runtime boundary.
If plugins are available, LogicThread:
Plugin events pass through _plugin_emit().
This provides a single choke point for the engine's plugin event stream:
LogicThread
│
▼
_plugin_emit()
│
▼
Plugin Manager
│
├── Plugin A
├── Plugin B
├── Plugin C
└── ...
Plugin failures are deliberately contained so that a faulty plugin cannot directly bring down the core simulation loop.
Fio also provides an engine-level kill switch through:
FIO_NO_PLUGINS=1
When enabled, the plugin manager is not attached and the per-tick plugin path becomes effectively inactive.
Monster AI is intentionally not executed as a second full simulation loop inside LogicThread.
LogicThread creates:
MonsterAI
MonsterAIThread
and protects shared monster/player state with locks.
The dedicated Monster AI worker can therefore perform AI processing independently of the main gameplay scheduler.
The architecture is:
LogicThread
│
┌──────────┴──────────┐
│ │
Game simulation shared state
│ │
│ ┌──────┴──────┐
│ │ │
▼ ▼ ▼
Player MonsterAI MonsterAIThread
This prevents potentially expensive AI processing from unnecessarily blocking the central 60 Hz game-logic loop.
The extracted MonsterAI.update() path is explicitly designed to be called from the LogicThread's play-mode processing while the dedicated AI thread handles its own scheduling.
Portal traversal is handled by LogicThread.
Runtime portal state includes:
The previous player position is retained so portal crossing can be evaluated against the movement segment, rather than merely checking the player's final position.
This prevents fast-moving players from passing through a portal aperture between two simulation samples.
A short transit cooldown also prevents immediate oscillation between two closely positioned portals.
LogicThread owns a significant amount of transient gameplay state.
Examples include:
player_health
player_dead
player2_health
player2_dead
collected_pickups
collected_keys
respawn_timers
active_speakers
hurt_trigger_timers
mover_states
door_states
bullet_marks
monster_projectiles
gunfire_events
current_hud_message
active_weapon
muzzle_flash_active
Keeping these values in the simulation layer means they are advanced by the same fixed timestep rather than being driven by rendering frequency.
Water interaction is also processed by the Logic Thread.
Runtime state tracks:
This allows LogicThread to distinguish events such as:
outside water
│
▼
enter water
│
▼
walking in water
│
▼
leave water
The cadence is simulation-time based rather than dependent on how frequently the viewport happens to repaint.
LogicThread is deliberately structured around a hot path / cold path distinction.
Performed when entering play mode or when runtime configuration changes:
Performed at 60 Hz:
The implementation repeatedly uses cached collections and precomputed lookup tables to keep the hot path from performing unnecessary allocations or whole-level scans.
This distinction is particularly important for Fio's large-world capabilities.
LogicThread does not exist to render the game.
The division is approximately:
┌──────────────────────┐
│ LogicThread │
│ │
│ 60 Hz simulation │
│ physics │
│ entities │
│ I/O │
│ gameplay │
└──────────┬───────────┘
│
shared state
│
▼
┌──────────────────────┐
│ Render / Qt side │
│ │
│ camera/view │
│ culling │
│ OpenGL │
│ presentation │
└──────────────────────┘
This separation is especially important because the rendering path may operate at a different frame rate from the simulation.
A display running at 144 Hz does not cause the simulation to run at 144 Hz, and a temporarily slower renderer does not redefine the simulation timestep.
The Logic Thread is the authoritative owner of simulation state, but some systems operate concurrently.
Monster AI has explicit locks for shared state, including:
_monster_lock
_player_damage_lock
The broader engine also uses ThreadedGameState to mediate state shared between the simulation and rendering systems.
This gives Fio a layered concurrency model rather than allowing arbitrary threads to mutate arbitrary game objects.
The main simulation loop is designed to survive individual tick failures.
A failed tick is handled rather than allowing the timing accumulator to become permanently stuck.
This is important for a fixed-timestep loop: if one simulation operation throws an exception, the scheduler must still consume the failed timestep rather than repeatedly attempting the same accumulated tick forever. Fio has regression coverage specifically for this behaviour.
Plugin callbacks are similarly guarded so plugin exceptions do not directly terminate the core logic scheduler.
The Logic Thread effectively forms the simulation spine of Fio.
It sits between the static map/editor representation and the continuously changing runtime world:
Editor / Map Data
│
▼
┌──────────────┐
│ LogicThread │
└──────┬───────┘
│
┌─────────┼──────────┐
│ │ │
Physics Entity I/O
│ Systems │
│ │ │
└─────────┼──────────┘
│
Runtime World State
│
┌───────┴────────┐
▼ ▼
Rendering AI / Plugins
Rather than spreading gameplay timing across the renderer, Qt callbacks, entity classes and individual systems, Fio gives the runtime a common 60 Hz execution authority.
This is what allows movement, physics, doors, triggers, I/O, projectiles, portals and other time-dependent systems to behave as parts of one simulation rather than as unrelated update mechanisms.
The Logic Thread follows several core principles:
| Principle | Implementation |
|---|---|
| Fixed simulation rate | 60 Hz |
| Independent timing | perf_counter() + accumulator |
| Shared editor/runtime scheduler | Editor and play update paths |
| Minimal hot-path work | Cached entity and collision collections |
| Runtime preparation | Expensive collision/entity setup at play start |
| Deterministic progression | Fixed-duration simulation ticks |
| Renderer independence | Simulation does not depend on Qt repaint rate |
| Extensibility | Native plugin event dispatch |
| Entity communication | Integrated I/O manager |
| Concurrent AI | Dedicated Monster AI thread |
| Runtime safety | Locks and guarded subsystem failures |
| Large-level optimisation | O(1) entity lookup and pre-filtered lists |
The result is a conventional but robust game-engine architecture: the Logic Thread advances the world; the renderer observes and presents it.
engine/logic_thread.py — Fio 2.3.0.0709_Latest.