Skip to main content

Phase/Pass Architecture: Evolution of the Game Loop

Β· 7 min read

In my previous post, I outlined the high-level separation between Commands and Events in helios, along with the basic double-buffered EventBus and CommandBuffer concepts. Since then, I've iterated significantly on the architecture, and I'm excited to share what has emerged: A Phase/Pass structure that provides much finer control over when events become visible and when commands execute.

The original design worked well for simple scenarios, but as I added more systems, like spawn scheduling, collision response and scene synchronization, I ran into timing issues: Events pushed in one system weren't visible to systems that needed to react within the same logical block of processing. So I broke the monolithic frame into hierarchical units.

From Flat to Hierarchical: The Phase/Pass Model​

The original architecture treated the frame as a sequence of steps:

// Old approach
EventBus.swapBuffers();
EventBus.dispatch();
CommandBuffer.flush();
ManagerRepository.flush();
ImmediateBus.dispatch();
// ... systems update ...

This worked well in simple scenarios, but forced all events to wait until the next frame to be readable - obviously, this approach was rather suboptimal for systems that needed to communicate in the same logical phase.

The new game loop architecture introduces a two-level hierarchy:

Phases and Passes​

The game loop is now 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 divided into three 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 of the same phase.

Three Event Buses - Three Scopes​

The single EventBus has evolved into three distinct buses, each with a different visibility scope:

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 remain double-buffered, but the swap timing is now tied to the appropriate commit point (manual for passes, automatic for phases and frames).

Why Three Buses?​

Consider collision detection and response:

MAIN PHASE
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Pass 1: GridCollisionDetectionSystem β”‚
β”‚ β†’ Detects projectile-enemy collision β”‚
β”‚ β†’ pushPass<TriggerCollisionEvent>(...) β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ [Pass Commit Point] β”‚
β”‚ β†’ Pass events become readable β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Pass 2: ProjectileCollisionSystem β”‚
β”‚ β†’ for (auto& evt : readPass<TriggerCollisionEvent>()) β”‚
β”‚ β†’ Pushes DespawnCommand for projectile β”‚
β”‚ β†’ Pushes DamageCommand for enemy β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

With pushPass(), the collision event is visible within the same phase. We do not need to wait for the next frame. Instead, it's scoped to the phase, so it doesn't pollute other phases and allows for generating commands that can be executed in the next phase: pushPhase() enables cross-phase communication (e.g., Pre phase schedules spawns, Main phase reads spawn confirmations), and pushFrame() handles true cross-frame scenarios like audio triggers.

Fine-Grained Control with Commoit-Points​

The key innovation is the Commit Point. A pass can declare a commit point, which triggers:

  1. Pass event bus swap β†’ events become readable in the same phase
  2. (Optionally) Structural commit β†’ commands execute immediately (β†’ Managers in next pass process Commands)
gameLoop.phase(PhaseType::Pre)
.addPass()
.addSystem<TwinStickInputSystem>(*playerGameObject)
.addCommitPoint(CommitPoint::Structural) // Move-Commands execute HERE

.addPass()
.addSystem<GameObjectSpawnSystem>(spawnSchedulers)
.addCommitPoint(CommitPoint::Structural) // New entities active, pass to CommandBuffer
// so the underlying SpawnManager can create them

.addPass()
.addSystem<Move2DSystem>(); // Operates on ALL entities

The CommitPoint::Structural variant flushes the CommandBuffer at that point, enabling newly spawned entities to participate in subsequent passes within the same phase.

The Refined Phase Commit​

At each phase boundary, the following 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 readable in next frame

This is the evolved version of the original flush() sequence, now contextualized within phases.

Complete Frame Execution​

Here's how a complete frame flows:

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

for (pass : phase.passes()) {
for (system : pass.systems()) {
system.update(updateContext);
// Systems can:
// - pushPass<E>() for same-phase communication
// - pushPhase<E>() for next-phase communication
// - pushFrame<E>() for next-frame communication
// - readPass<E>() for events from previous passes
// - readPhase<E>() for events from previous phase
// - readFrame<E>() for events from previous frame
}

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

if (pass.commitPoint() == CommitPoint::Structural) {
passEventBus.swapBuffers();
commandBuffer.flush(); // Immediate mutation
gameWorld.flushManagers();
}
}

// Phase Commit
phaseEventBus.swapBuffers(); // Phase events readable
passEventBus.clearAll(); // Clear pass events
commandBuffer.flush(); // Execute commands
gameWorld.flushManagers(); // Process manager queues

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

render();

Practical Example: Spawning a Projectile​

Let's trace through shooting a projectile:

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 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The projectile exists and moves in the same frame it was spawned. In the original architecture, it would have been invisible until frame N+1.

Key Improvements Over the Original Design​

AspectOriginalPhase/Pass
Event visibilityNext frame onlySame phase (pass) or next phase
Command executionOnce per frameAt commit points + phase boundaries
Structural changesWait for next frameImmediate within phase
Event scopeGlobalPass / Phase / Frame
ComplexitySimpleMore setup, more control

When to Use What​

ScenarioUse
Collision β†’ Damage responsepushPass() + Commit Point
Spawn request β†’ Spawn confirmationpushPhase()
Audio trigger for next framepushFrame()
Immediate entity creationaddCommitPoint(CommitPoint::Structural)

Of ourse, this Phase/Pass architecture didn’t appear out of thin air. It’s shaped by scheduling and deferred commit patterns that have proven themselves in other ECS-driven runtimes. Unity DOTS, for example, structures execution into system groups and uses explicit EntityCommandBuffer playback points to apply structural changes at well-defined times (Unity Entities: ECB playback). Bevy follows a similar philosophy with schedules and an explicit ApplyDeferred barrier that tells the executor when to apply buffered commands (like Commands) (Bevy: ApplyDeferred). Flecs takes yet another angle on the same idea: Systems often run in a "readonly" mode where structural changes are deferred and later merged, preserving iteration safety and keeping mutation timing explicit (Flecs: Systems / readonly & deferred ops). helios leans into this family of approaches, but makes the commit semantics first-class: Pass/phase/frame scopes are explicit APIs, and commit points turn "when does this become visible?" from an implicit convention into a rule you can point at in code.

Looking Forward​

The Phase/Pass architecture has proven flexible enough to handle increasingly complex game logic while maintaining determinism. The explicit commit points make it clear when state changes occur, which aids debugging and reasoning about system interactions.

I have already started to migrate the underlying systems to a real ECS architecture. The next step is to seal development on the current game demo and start working on the follow up to the paper on the prototype.