Skip to main content

helios - A High-Level Overview of the Game Loop Architecture

· 7 min read

As part of a project assigment at Tier University of Applied Sciences, I'm curently designing a game framework based on C++ / OpenGL.

Over the holidays, I’ve been busy refining the helios GameLoop to enable player interactions with various GameObjects. This included, in particular, controlling the player ship, spawning enemies, and - the holy grail of every game - the collision system.

It quickly became apparent that a purely object-oriented approach was too brittle and inflexible, leaving me no choice but to read up on a more versatile approach: the component-based system. For me, as an OOP scholar, this meant a paradigm shift in how I design software - but it was worth it.

And I’ve come to feel strongly that academic teaching places far too much emphasis on OOP.

Even though I am only scratching the surface of proper ECS in this prototype phase, I am very satisfied with the result. However, the problem remained of how to communicate and process cross-frame actions - who orchestrates the spawning? What is a spawn, anyway? Is it an Event? Is it a Command?

Based on the collision system, I had to turn the architectural screw a little further - resulting in the following design, which I will be pouring into the framework over the coming weeks.

High-Level Overview of the 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. SpawnRequest) or a fact (e.g. SolidCollisionEvent, TriggerCollisionEvent, SpawnedEvent).

Commands and CommandBuffer

Participating systems can write Commands into the CommandBuffer in frame NN. At the beginning of each frame N+1N+1, the CommandBuffer is flushed, which commits the Commands - 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). Commands are bare metal and therefore the lowest level in the game-loop layer, i.e. 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).

Events and double-buffered EventBus

In addition, systems can emit Events in frame NN, 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. The EventBus is double-buffered (helios.core.data.TypeIndexedDoubleBuffer): events are written into the write buffer in frame NN and become visible in the read buffer only after a buffer swap (swapBuffers()) at the beginning of frame N+1N+1.

At the beginning of frame N+1N+1, events are dispatched from the read buffer. As mentioned above, request events can be translated into Commands and written into the CommandBuffer.

This yields the following order at the beginning of frame N+1N+1:

EventBus.swapBuffers;()

EventBus.dispatch(); // Events from which Commands may be
// generated

CommandBuffer.flush(); // Commit - mutates the world state,

ManagerRepository.flush(); // optional manager-based command
// processing

Optional manager-based command processing

Instead of having each Command fully implement the mutation logic in execute(), the GameLoop is aware of additional, registered managers. In this model, execute() primarily performs a registering/planning step, and the managers then process their work as a coordinated batch.

Example:

CommandBuffer::flush() {
for (auto& cmd : commands)
cmd->execute(); // in execute e.g.:
// spawnManager->enqueue(position, enemyType);
}

ManagerRepository::flush() {
for (auto& mgr : managers)
mgr->process(); // the spawnManager processes commands,
// e.g. the spawn list
}

This model allows Commands to be bundled, sorted, and processed deterministically (e.g. handle all spawns first, then all despawns).

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").

Overall, the frame-begin order becomes:

EventBus.swapBuffers() // input
EventBus.dispatch()

CommandBuffer.flush(); // mutation

ManagerRepository.flush(); // orchestrating

ImmediateBus.dispatch() // signals

// gameplay systems updating physics
Move2DSystem.update();
...