helios - A High-Level Overview of the Game Loop Architecture
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
- [english]
- [deutsch]
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 .
At the beginning of each frame , 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 , 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 and become visible in the read buffer only after a buffer swap (swapBuffers()) at the beginning of frame .
At the beginning of frame , 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 :
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();
...
helios unterscheidet zwischen Commands (weltverändernde Mutationen) und Events (Signale/Fakten).
Commands dienen dazu, den Weltzustand deterministisch zu verändern. Events dienen dazu, Systeme zu entkoppeln: Sie drücken entweder einen Request/Intent (z. B. SpawnRequest) oder ein Fakt (z. B. SolidCollisionEvent, TriggerCollisionEvent, SpawnedEvent) aus.
Commands und CommandBuffer
Teilnehmende Systeme können beliebig Commands in Frame in den CommandBuffer schreiben.
Zu Beginn jedes Frames wird der CommandBuffer geflusht, wodurch die Commands committed werden - es wird deren execute()-Methode aufgerufen. Die Methode enthält die Logik, die den Weltzustand mutiert (z. B. Spawning, Despawning, Health-Änderungen, Component-Änderungen). Commands sindbare metal und damit die niedrigste Ebene in der GameLoop-Schicht, d.h., es ist keine weitere Aufbereitung eines Commands mehr nötig. Das System sollte also auch in der Lage sein, Commands von einer Entwickler-Console direkt in die GameWorld zu übertragen (ggf. an ihre entsprechenden Manager - s.u.).
Events und Double-Buffered EventBus
Zusätzlich können Systeme in Frame Events erzeugen, z. B. Request Events - solche bspw. die beabsichtigen, einen den Weltzusatend zu mutieren, oder auch einfach nur Signale, wie bspw. SolidCollisionEvent, aus der weltzustandmutierende Commands (despawn) abgeleitet werden.
Der EventBus ist double-buffered (helios.core.data.TypeIndexedDoubleBuffer): Events werden in Frame in den Write-Buffer geschrieben und sind erst nach einem Buffer-Swap (swapBuffers()) zu Beginn von Frame im Read-Buffer sichtbar.
Zu Beginn von Frame N+1 werden die Events aus dem Read-Buffer dispatcht; wie oben bereits erwähnt, können dabei aus Request-Events wiederum Commands generiert und in den CommandBuffer geschrieben werden.
Daraus ergibt sich zu Beginn von Frame die Reihenfolge:
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
Optionale Manager-basierte Command-Verarbeitung
Statt dass jeder Command die Logik einer Mutation vollständig in execute() implementiert, verfügt die GameLoop über ein ManagerRepository, in dem verschiedene Manager die Ausführung von Commands orchestrieren. In diesem Modell führen Commands in execute() primär eine registrierende/planende Aktion aus, und die Manager führen anschließend ihre Arbeit koordiniert als Batch aus.
Beispiel:
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
}
Dieses Modell erlaubt es, Commands zu bündeln, zu sortieren und deterministisch abzuarbeiten (z. B. alle Spawns zusammen, dann alle Despawns).
Immediate Events (Single-Buffered)
Für zeitkritisches Feedback, das nicht den Weltzustand verändert (Partikeleffekte, Audio-Feedback) existiert ein separater ImmediateBus (single-buffered).
Immediate-Events sollen im selben Frame ohne zusätzliche Double-Buffer-Latenz verarbeitet werden. Ein geeigneter Dispatch-Punkt ist nach dem CommandBuffer.flush(), damit das Feedback die zu Beginn dieses Frames committete Welt konsistent sehen kann. Hierdurch kann Latenz mitigiert werden (bspw. 16ms bei 60fps) und Feedback kann noch im selben Frame zu einem bestimmten Ereignis gegeben werden, was das "Game Feel" verbessern kann.
Insgesamt ergibt sich damit zu Frame-Beginn:
EventBus.swapBuffers() // input
EventBus.dispatch()
CommandBuffer.flush(); // mutation
ManagerRepository.flush(); // orchestrating
ImmediateBus.dispatch() // signals
// gameplay systems updating physics
Move2DSystem.update();
...