Phase/Pass Architecture: Evolution of the Game Loop
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 Bus | Push Method | Read Method | Visibility |
|---|---|---|---|
| Pass | pushPass<E>() | readPass<E>() | Subsequent passes (same phase) |
| Phase | pushPhase<E>() | readPhase<E>() | Next phase |
| Frame | pushFrame<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:
- Pass event bus swap β events become readable in the same phase
- (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β
| Aspect | Original | Phase/Pass |
|---|---|---|
| Event visibility | Next frame only | Same phase (pass) or next phase |
| Command execution | Once per frame | At commit points + phase boundaries |
| Structural changes | Wait for next frame | Immediate within phase |
| Event scope | Global | Pass / Phase / Frame |
| Complexity | Simple | More setup, more control |
When to Use Whatβ
| Scenario | Use |
|---|---|
| Collision β Damage response | pushPass() + Commit Point |
| Spawn request β Spawn confirmation | pushPhase() |
| Audio trigger for next frame | pushFrame() |
| Immediate entity creation | addCommitPoint(CommitPoint::Structural) |
Related worksβ
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.