Command System
This document describes the Command pattern implementation in helios, including compile-time typed command buffering, handler routing, and integration with the game loop.
Overview
The Command System provides a mechanism for deferred action execution. Instead of immediately modifying game state, actions are encapsulated as lightweight command structs, buffered in compile-time typed queues, and executed in a controlled batch during the game loop. This decouples input handling from action processing and enables deterministic, reproducible game logic. Commands can also be timer-gated — held in a scratch queue until an associated GameTimer finishes — enabling deferred transitions without polling.
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Input / Systems│────>│ EngineCommandBuffer │────>│ Managers │
│ (produce cmds) │ │ (typed queues) │ │ (process) │
└─────────────────┘ └──────────────────────┘ └─────────────────┘
│ flush()
▼
┌──────────────────────┐
│ Registered Handler │
│ (via function ptr) │
└──────────────────────┘
Command Types
Commands are plain structs (value types) that carry the data needed for a specific action. They do not inherit from a common base class — instead, the compile-time typed buffer ensures type safety.
Handler-Routed Commands
Most commands are routed to a Manager's registered submit function during flush:
struct DespawnCommand {
SpawnProfileId spawnProfileId_;
EntityHandle entityHandle_;
SpawnProfileId spawnProfileId() const noexcept { return spawnProfileId_; }
EntityHandle entityHandle() const noexcept { return entityHandle_; }
};
Self-Executing Commands
Commands can also execute themselves if they satisfy the ExecutableCommand concept. During flush(), the TypedCommandBuffer resolves the handler via the GameWorld's ResourceRegistry internally — self-executing commands receive the UpdateContext directly:
struct StateCommand {
StateTransitionContext context_;
void execute(UpdateContext& ctx) const noexcept {
// Self-executing commands receive the UpdateContext;
// handler routing is resolved internally by TypedCommandBuffer
}
};
Timer-Gated Commands (DelayedCommandLike)
Commands that satisfy the DelayedCommandLike concept carry a GameTimerId and are held in a scratch queue until their associated timer finishes. This enables timer-deferred actions without polling in systems.
template<typename StateType>
class DelayedStateCommand {
StateTransitionRequest<StateType> transitionRequest_;
GameTimerId timerId_;
public:
explicit DelayedStateCommand(
StateTransitionRequest<StateType> transitionRequest,
GameTimerId timerId
);
[[nodiscard]] StateTransitionRequest<StateType> transitionRequest() const noexcept;
[[nodiscard]] GameTimerId gameTimerId() const noexcept; // required by DelayedCommandLike
};
During flush, the TypedCommandBuffer checks each delayed command's timer state:
| Timer State | Action |
|---|---|
| Running | Command is moved to a scratch queue and survives the flush cycle |
| Finished | Command is dispatched normally (handler or self-execute) |
| Cancelled | Command is silently dropped (timer was cancelled) |
| Undefined | Command is silently dropped (timer was removed) |
Usage example:
// Schedule a state transition gated by a countdown timer
ctx.queueCommand<DelayedStateCommand<MatchState>>(
StateTransitionRequest<MatchState>{
MatchState::Countdown,
MatchStateTransitionId::PlayerSpawnRequest
},
GameTimerId::CountdownTimer
);
TypedCommandBuffer
TypedCommandBuffer<...CommandTypes> is the core of the system. It stores commands in a std::tuple<std::vector<CommandType>...>, providing one contiguous queue per command type.
Adding Commands
// Systems add commands via UpdateContext::queueCommand<T>()
ctx.queueCommand<Move2DCommand>(entityHandle, direction, speed);
ctx.queueCommand<DespawnCommand>(entityHandle, profileId);
Flush Routing
During flush(), each command type is processed in template parameter order:
- Handler route: If a handler for
Cmdis registered in theCommandHandlerRegistry, all queued commands are submitted to the handler via the stored function pointer. - Direct execution: Otherwise, if the command satisfies
ExecutableCommand, it executes itself - Assertion: If neither applies, an assertion fires (misconfiguration)
In both branches, if Cmd satisfies DelayedCommandLike, each command undergoes an additional timer check before dispatch (see Timer-Gated Commands above).
// Pseudocode for flush routing (per command type):
if (registry.has<Cmd>()) {
for (auto& cmd : queue) {
if constexpr (DelayedCommandLike<Cmd>) {
auto* timer = timerManager.gameTimer(cmd.gameTimerId());
if (shouldDelay(timer)) { delayed.push_back(cmd); continue; }
if (!isReady(timer)) { continue; } // drop
}
registry.submit<Cmd>(cmd);
}
} else if constexpr (ExecutableCommand<Cmd>) {
for (auto& cmd : queue) {
// same DelayedCommandLike check as above …
cmd.execute(updateContext);
}
}
queue.clear();
queue.swap(delayed); // surviving delayed commands become the new queue
delayed.clear();
Deterministic Ordering
Commands are flushed in the order of the template parameter list. The EngineCommandBuffer defines this order:
- Combat commands (Aim2D, Shoot, ApplyDamage)
- Motion commands (Move2D, Steering)
- Scoring commands (UpdateScore)
- Spawn commands (ScheduledSpawnPlan, Spawn, Despawn)
- State commands (StateCommand<GameState>, DelayedStateCommand<GameState>, StateCommand<MatchState>, DelayedStateCommand<MatchState>)
- UI commands (UiAction)
- Timing commands (TimerControl)
- Lifecycle commands (WorldLifecycle)
Command Handlers
Most of the Managers in helios process commands by providing a submit method for each command type they handle. Concrete managers are plain classes — they do not inherit from any handler interface. Instead, they declare using EngineRoleTag = ManagerRole; to opt in:
class SpawnManager {
public:
using EngineRoleTag = helios::engine::common::tags::ManagerRole;
bool submit(const SpawnCommand& cmd) noexcept {
spawnCommands_.push_back(cmd);
return true;
}
bool submit(const DespawnCommand& cmd) noexcept {
despawnCommands_.push_back(cmd);
return true;
}
bool submit(ScheduledSpawnPlanCommand cmd) noexcept {
scheduledCommands_.push_back(cmd);
return true;
}
void flush(UpdateContext& ctx) noexcept { /* batch processing */ }
};
Handlers are registered with the ResourceRegistry (via GameWorld convenience methods) during init():
void init(GameWorld& gameWorld) {
gameWorld.registerCommandHandler<SpawnCommand>(*this);
gameWorld.registerCommandHandler<DespawnCommand>(*this);
}
EngineCommandBuffer
EngineCommandBuffer is a thin facade over TypedCommandBuffer instantiated with all engine command types. It is registered as a resource in the GameWorld and wrapped by a type-erased CommandBuffer via the Concept/Model pattern. Systems access it via UpdateContext::queueCommand<T>().
class EngineCommandBuffer {
using BufferImpl = TypedCommandBuffer<
Aim2DCommand, ShootCommand, ApplyDamageCommand,
Move2DCommand, SteeringCommand,
UpdateScoreCommand,
ScheduledSpawnPlanCommand, SpawnCommand, DespawnCommand,
StateCommand<GameState>, DelayedStateCommand<GameState>,
StateCommand<MatchState>, DelayedStateCommand<MatchState>,
UiActionCommand,
TimerControlCommand,
WorldLifecycleCommand
>;
BufferImpl impl_;
public:
template<class T, class... Args>
void add(Args&&... args) {
impl_.add<T>(std::forward<Args>(args)...);
}
void flush(GameWorld& gameWorld, UpdateContext& ctx) noexcept override {
impl_.flush(gameWorld, ctx);
}
void clear() noexcept override { impl_.clear(); }
};
Managers
Managers handle cross-cutting concerns that require deferred or batched processing. Commands route to Managers via registered submit functions during the buffer flush, and Managers process their queues during Manager::flush().
Manager Lifecycle
- Registration: Manager is added via
GameWorld::registerManager<T>() - init(): Called during
GameWorld::init(). Registers command handlers with theGameWorld. - submit(): Receives commands during
CommandBuffer::flush() - flush(): Processes queued commands during
GameWorld::flushManagers()
Registration
auto& spawnManager = gameWorld.registerManager<SpawnManager>();
Game Loop Integration
The Command System integrates with the Phase/Pass game loop architecture. Commands can be added during any phase, and are flushed at each commit point.
┌─────────────────────────────────────────────────────────────────────┐
│ FRAME │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ PRE PHASE ────────────────────────────────────────────────────── │
│ Pass 1 (Input): Systems read input, add commands │
│ ctx.queueCommand<Move2DCommand>(dir, spd); │
│ Pass 2, Pass 3, ... │
│ ────────────────────────────────────────────────── Phase Commit │
│ │ 1. eventBus.swapBuffers() │
│ │ 2. commandBuffer.flush(gameWorld, ctx) ◄─ Commands │
│ │ 3. flushManagers(ctx) ◄─ Managers process │
│ ▼ commands │
│ MAIN PHASE ───────────────────────────────────────────────────── │
│ Pass 1 (Gameplay): Movement, Physics systems │
│ Pass 2 (Collision): Collision detection │
│ Commands can still be added here! │
│ ────────────────────────────────────────────────── Phase Commit │
│ │ commandBuffer.flush(gameWorld, ctx) + flushManagers(ctx) │
│ ▼ │
│ POST PHASE ───────────────────────────────────────────────────── │
│ Pass 1 (Scene Sync): Sync transforms to scene graph │
│ Pass 2 (Cleanup): Clear dirty flags │
│ ────────────────────────────────────────────────── Phase Commit │
│ │ commandBuffer.flush(gameWorld, ctx) + flushManagers(ctx) │
│ ▼ │
│ RENDER │
└─────────────────────────────────────────────────────────────────────┘
Key Points
- Commands are not limited to the Pre Phase. Systems in any phase can add commands.
- At each commit point, commands are flushed before managers, ensuring handlers receive all commands before processing.
- Spawns and despawns triggered in one phase are visible to systems in the next phase.
Best Practices
Command Design
- Commands should be immutable value types (structs with const accessors)
- Keep commands lightweight — store only essential data (IDs, handles, values)
- Use handler-routed commands for operations that require Manager coordination
- Use self-executing commands for simple state transitions
Handler Registration
- Register handlers in
Manager::init(), not in constructors - Ensure the Manager is registered in the
ResourceRegistrybefore dependent Managers callinit() - A single Manager can register handlers for multiple command types
Ordering
- The template parameter order of
EngineCommandBufferdetermines flush order - Design command ordering to avoid dependencies (e.g., despawns before spawns)
Related Modules
helios.engine.runtime.messaging.command.CommandBuffer— Type-erased buffer wrapper (Concept/Model)helios.engine.runtime.messaging.command.TypedCommandBuffer— Compile-time typed bufferhelios.engine.runtime.messaging.command.CommandBufferRegistry— Type-indexed registry for CommandBuffer instanceshelios.engine.runtime.messaging.command.CommandHandlerRegistry— Function-pointer based registry for command handlershelios.engine.runtime.messaging.command.EngineCommandBuffer— Concrete engine buffer facadehelios.engine.runtime.world.Manager— Type-erased wrapper for managershelios.engine.runtime.world.ResourceRegistry— Unified resource storage for Managers and CommandBuffers
Related Documentation
- Resource Registry — Type-indexed resource storage and handler lookup
- Event System — Phase/pass event propagation
- Game Loop Architecture — Overall frame structure
- Component System — GameObject, Component, System architecture
- Spawn System — Entity lifecycle with spawn scheduling and pooling