Skip to main content

Game Loop Architecture

helios distinguishes between Commands (world-mutating operations) and Events (signals/facts). Commands exist to mutate the world state deterministically. Events exist to decouple systems: they either express a request/intent (e.g. SpawnCommand) or a fact (e.g. SolidCollisionEvent, TriggerCollisionEvent, SpawnPlanCommandExecutedEvent).

Phase/Pass Structure

The game loop is organized into Phases and Passes:

┌─────────────────────────────────────────────────────────────────────┐
│ FRAME │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ PRE PHASE ────────────────────────────────────────────────────── │
│ Pass 1 (Input) ──> Pass 2 (Commit) ──> Pass 3 │
│ │ │
│ [Pass Commit Point] │
│ ────────────────────────────────────────────────── Phase Commit │
│ │
│ MAIN PHASE ───────────────────────────────────────────────────── │
│ Pass 1 (Gameplay) ──> Pass 2 (Collision) ──> Pass 3 (AI) │
│ ────────────────────────────────────────────────── Phase Commit │
│ │
│ POST PHASE ───────────────────────────────────────────────────── │
│ Pass 1 (Scene Sync) ──> Pass 2 (Cleanup) │
│ ────────────────────────────────────────────────── Phase Commit │
│ │
│ RENDER │
└─────────────────────────────────────────────────────────────────────┘

Phases are major segments (Pre, Main, Post) with distinct responsibilities. After each phase, a Phase Commit occurs: phase events become readable, pass events are cleared, commands are flushed, and managers process their queues.

Passes are sub-units within phases. A pass can optionally have a Commit Point, making pass-level events readable in subsequent passes.

For detailed event propagation rules, see Event System.

State-Based Pass Filtering

Passes can be configured to run only during specific states. This allows gameplay systems to be automatically disabled during menus or paused states without explicit checks in each system.

TypedPass Architecture

Passes are implemented via the TypedPass<StateType> template class, which wraps state-based filtering around the Pass interface. The state type can be any enum (e.g., GameState, MatchState).

// Pass filtered by GameState
gameLoop.phase(PhaseType::Main)
.addPass<GameState>(GameState::Running)
.addSystem<MovementSystem>()
.addSystem<CollisionSystem>();

// Pass filtered by MatchState
gameLoop.phase(PhaseType::Main)
.addPass<MatchState>(MatchState::Playing)
.addSystem<ScoringSystem>();

State Mask (Bitwise OR)

Multiple states can be combined using bitwise OR. The pass runs if the current state matches any bit in the mask:

// Runs when Running OR Paused
gameLoop.phase(PhaseType::Post)
.addPass<GameState>(GameState::Running | GameState::Paused)
.addSystem<SceneSyncSystem>();

Evaluation

During Phase::update(), each pass's shouldRun() method is called. For TypedPass<StateType>, this queries the current state from the Session and compares it against the configured mask:

[[nodiscard]] bool shouldRun(UpdateContext& ctx) const noexcept override {
auto state = ctx.session().state<StateType>();
return hasFlag(mask_, state); // bitwise AND check
}

Important: Session State Registration

State types must be registered with the Session before passes can filter on them:

auto& session = gameWorld.session();
session.trackState<GameState>();
session.trackState<MatchState>();

For the complete state management system, see State Management.

Practical Example: Twin-Stick Shooter

The following example demonstrates how to configure a complete game loop for a twin-stick shooter with spawning, physics, collision, and rendering:

// Create core objects
helios::engine::runtime::gameloop::GameLoop gameLoop{};
helios::engine::runtime::world::GameWorld gameWorld{};

// Register state types with session
auto& session = gameWorld.session();
session.trackState<GameState>();
session.trackState<MatchState>();

// Register command dispatchers
gameLoop.commandBuffer()
.addDispatcher<SpawnCommand>(
std::make_unique<SpawnCommandDispatcher>())
.addDispatcher<DespawnCommand>(
std::make_unique<DespawnCommandDispatcher>())
.addDispatcher<ScheduledSpawnPlanCommand>(
std::make_unique<ScheduledSpawnPlanCommandDispatcher>());

// ═══════════════════════════════════════════════════════════════════
// PRE PHASE: Input, Spawning, Motion Preparation
// ═══════════════════════════════════════════════════════════════════
gameLoop.phase(PhaseType::Pre)
// Pass 1: Input handling (runs in all states)
.addPass<GameState>(GameState::Any)
.addSystem<TwinStickInputSystem>(*playerGameObject)

// Commit Point: Structural changes (spawn/despawn) execute here
.addCommitPoint(CommitPoint::Structural)

// Pass 2: Spawn scheduling (only when Running)
.addPass<GameState>(GameState::Running)
.addSystem<GameObjectSpawnSystem>(spawnSchedulers)

// Commit Point: New entities are now active
.addCommitPoint(CommitPoint::Structural)

// Pass 3: Motion systems (only when Running)
.addPass<GameState>(GameState::Running)
.addSystem<ScaleSystem>()
.addSystem<SteeringSystem>()
.addSystem<SpinSystem>()
.addSystem<Move2DSystem>();

// ═══════════════════════════════════════════════════════════════════
// MAIN PHASE: Gameplay Logic, Collision, AI
// ═══════════════════════════════════════════════════════════════════
gameLoop.phase(PhaseType::Main)
// Pass 1: Update bounds and check collisions (only when Running)
.addPass<GameState>(GameState::Running)
.addSystem<BoundsUpdateSystem>()
.addSystem<LevelBoundsBehaviorSystem>()
.addSystem<GridCollisionDetectionSystem>(cellSize, levelBounds)

// Commit Point: Collision events become readable
.addCommitPoint()

// Pass 2: React to collisions (only when Running)
.addPass<GameState>(GameState::Running)
.addSystem<ProjectileCollisionSystem>()
.addSystem<EnemyCollisionSystem>();

// ═══════════════════════════════════════════════════════════════════
// POST PHASE: Scene Sync, Transform Cleanup
// ═══════════════════════════════════════════════════════════════════
gameLoop.phase(PhaseType::Post)
// Scene sync runs in all states (needed for UI updates while paused)
.addPass<GameState>(GameState::Any)
.addSystem<ComposeTransformSystem>()
.addSystem<SceneSyncSystem>(scene.get())
.addSystem<TransformClearSystem>()
.addSystem<DelayedComponentEnablerSystem>();

// Initialize and run
gameWorld.init();
gameLoop.init(gameWorld);

// Main loop
while (running) {
float deltaTime = /* calculate delta */;

UpdateContext ctx{deltaTime, gameWorld, /* ... */};
gameLoop.update(ctx);

render();
}

Execution Flow for One Frame

The following diagram shows how a projectile collision is processed:

Frame N
═══════════════════════════════════════════════════════════════════════

PRE PHASE
┌─────────────────────────────────────────────────────────────────────┐
│ Pass 1: TwinStickInputSystem │
│ → Player presses fire button │
│ → Pushes ShootCommand to CommandBuffer │
├─────────────────────────────────────────────────────────────────────┤
│ [Commit Point: Structural] │
│ → CommandBuffer.flush() executes ShootCommand │
│ → SpawnManager creates projectile from pool │
├─────────────────────────────────────────────────────────────────────┤
│ Pass 2: GameObjectSpawnSystem │
│ → Evaluates spawn rules, schedules enemy spawns │
├─────────────────────────────────────────────────────────────────────┤
│ [Commit Point: Structural] │
│ → New enemies spawned and activated │
├─────────────────────────────────────────────────────────────────────┤
│ Pass 3: Move2DSystem, SteeringSystem │
│ → All entities (including new projectile) move │
└─────────────────────────────────────────────────────────────────────┘

[Phase Commit]

MAIN PHASE
┌─────────────────────────────────────────────────────────────────────┐
│ Pass 1: GridCollisionDetectionSystem │
│ → Detects projectile-enemy collision │
│ → Pushes TriggerCollisionEvent to Pass EventBus │
├─────────────────────────────────────────────────────────────────────┤
│ [Commit Point] │
│ → Pass events become readable │
├─────────────────────────────────────────────────────────────────────┤
│ Pass 2: ProjectileCollisionSystem │
│ → Reads TriggerCollisionEvent │
│ → Pushes DespawnCommand for projectile │
│ → Pushes DamageCommand for enemy │
└─────────────────────────────────────────────────────────────────────┘

[Phase Commit]

POST PHASE
┌─────────────────────────────────────────────────────────────────────┐
│ Pass 1: SceneSyncSystem │
│ → Syncs transforms to scene graph │
│ → Despawned projectile removed from scene │
└─────────────────────────────────────────────────────────────────────┘

[Phase Commit]
[Frame Commit]

RENDER

Key Observations

  1. Commit Points control visibility: The projectile spawned in Pre Phase Pass 1 is immediately available for physics in Pass 3 because of the structural commit point.

  2. Pass events enable same-phase reactions: The collision detected in Main Phase Pass 1 is readable in Pass 2, allowing the collision response in the same phase.

  3. Commands execute at phase boundaries: The DespawnCommand pushed in Main Phase executes at the Main Phase Commit, so the entity is removed before rendering.

  4. Deterministic ordering: Systems within a pass execute in registration order. This ensures predictable behavior across frames.

Commands and CommandBuffer

Systems can write Commands into the CommandBuffer during any phase. At each Phase Commit, the CommandBuffer is flushed - i.e., their execute() method is invoked. This method contains the logic that mutates the world state (e.g., spawning, despawning, health changes, component changes).

This means commands added during the Pre Phase are executed at the Pre Phase Commit, commands added during Main Phase at Main Phase Commit, and so on. This allows for responsive gameplay where actions taken in one phase are immediately visible in the next.

Commands are "bare metal" and therefore the lowest level in the game-loop layer - no further preparation of a Command is required. The system should therefore also be able to commit Commands coming directly from a developer console into the GameWorld (optionally delegating them to their respective managers - see below).

For detailed command handling, dispatchers, and manager integration, see Command System.

Events and double-buffered EventBus

In addition, systems can emit Events in frame N, e.g. request events - events that intend to mutate the world state - or plain signals such as SolidCollisionEvent, from which world-mutating Commands (despawn) can be derived.

helios provides three event buses with different visibility scopes:

Event BusPush MethodRead MethodVisibility
PasspushPass<E>()readPass<E>()Subsequent passes (same phase)
PhasepushPhase<E>()readPhase<E>()Next phase
FramepushFrame<E>()readFrame<E>()Next frame

All buses are double-buffered (helios.core.buffer.TypeIndexedDoubleBuffer): events are written into the write buffer and become visible in the read buffer only after a buffer swap.

For detailed event propagation rules and commit points, see Event System.

Frame Order Summary

At each phase boundary, the following commit sequence occurs:

// After each phase completes
phaseEventBus.swapBuffers(); // Phase events become readable
passEventBus.clearAll(); // Pass events are cleared
commandBuffer.flush(); // Commands execute (mutations)
gameWorld.flushManagers(); // Managers process queued requests

// Additionally, at the end of Post phase:
frameEventBus.swapBuffers(); // Frame events become readable in next frame

Overall frame execution:

for (phase : {Pre, Main, Post}) {

for (pass : phase.passes()) {
for (system : pass.systems()) {
system.update(updateContext);
}

if (pass.commitPoint() == CommitPoint::PassEvents) {
passEventBus.swapBuffers(); // Pass events readable
}
}

phaseCommit(); // Phase boundary

if (phase == Post) {
frameEventBus.swapBuffers(); // Frame events readable in next frame
}
}

render();

Immediate Events (single-buffered)

For time-critical feedback that does not mutate the world state (particle effects, audio feedback), there is a separate ImmediateBus (single-buffered). Immediate events should be processed within the same frame without additional double-buffer latency. A suitable dispatch point is after CommandBuffer.flush(), so that feedback can observe the world that was committed at the beginning of the frame in a consistent state. This mitigates latency (e.g. ~16ms at 60fps) and allows feedback to be triggered in the same frame as a specific event, which improves the overall user experience ("game feel").