ECS Architecture
Everything in ColumbaEngine is a system running inside one EntitySystem (the “world”, ECS/entitysystem.h) — rendering, audio, scripting, and your game logic all use the same machinery. This page explains the model; see ECS API Reference for the full API reference.
The execution model
The world runs on a Taskflow graph that re-executes continuously after ecs->start(). Each iteration:
BasicTask (the root node) runs alone. It drains the event queues, commits all deferred structural changes (entity/component creation and deletion), and applies pending script hot-reloads. Nothing else runs during this window, which is what makes those operations safe.
Your systems run — after BasicTask, and in parallel with each other unless you order them.
Ordering is explicit and per-pair:
ecs.createSystem<CollisionSystem>();
ecs.createSystem<CollisionHandlerSystem>();
ecs.succeed<CollisionHandlerSystem, CollisionSystem>(); // handler runs AFTER collision
Execution policies (set via traits) change how a system participates: the default (Sequential) runs after BasicTask each frame; IndependentPolicy runs with no ordering edge; StoragePolicy opts out of execution entirely — the system is a pure component container / event receiver, and its execute() never runs.
dumbTaskflow() dumps the whole graph (GraphViz) — invaluable when debugging ordering.
Deferred mutation
While the world is running, structural changes never happen immediately:
createEntity,removeEntity,attach,detachare routed through a command dispatcher and committed at the next BasicTask, when no system is running.This is why
EntityRef/CompRefare lazy: a ref created in the same tick as the attach resolves itself on first use, after the commit.entity->has<C>()also checks pending components, so attach-then-check within one tick works.The corollary: after a
removeEntity, refs resolve tonullptr— always null-check components you re-fetch (if (not pos) return;), as every bundled example does.
Thread-safety rules of thumb
sendEventis safe from any thread (it enqueues unless it is already on the safe path).Structural changes are safe from any thread (deferred, see above).
Reading and writing component data is where you think: two systems that touch the same components and run in the same frame must be ordered with
succeed/precede, or they will race.
Iterating components
Components live in sparse sets — O(1) lookup by entity, packed arrays for iteration. A system that Own<C>’s or Ref<C>’s a component iterates it directly:
for (const auto& timer : view<Timer>()) // single component, packed iteration
if (timer->running) { ... }
For multi-component joins, register a group once (usually in init()) and iterate it; membership is maintained incrementally as components attach/detach, not recomputed per frame:
registerGroup<PositionComponent, Velocity, Ball>();
for (auto ball : viewGroup<PositionComponent, Velocity, Ball>())
{
auto pos = ball->get<PositionComponent>();
auto vel = ball->get<Velocity>();
...
}
(Real example: the ball/alien collision loop in examples/InvadersBreaker/enemy.h.)
Declaring systems: the trait list
A system is a class inheriting System<Traits...>; each trait opts into one capability, and the engine wires exactly what you list:
Trait |
What it gives you |
|---|---|
|
|
|
this system owns storage for component |
|
iterate/read a |
|
|
|
|
|
|
|
|
|
no |
|
scheduled with no ordering edge to BasicTask |
Override getSystemName() in every real system — it names the Taskflow node for profiling and is required for SaveSys.
Two ways to build a system
Beyond System<Traits...> classes, createStandardSystem(...) builds a system at runtime from a fluent description — including handlers written in PgScript:
createStandardSystem(ecs, "spawner")
.onInit([](...) { ... })
.onEvent("OnMouseClick", "res/scripts/spawn.pg") // hot-reloadable script handler
.onExecute("res/scripts/tick.pg");
See examples/StandardSys and the StandardSystem guides in docs/STANDARD_SYSTEM_INDEX.md. Use C++ System classes for engine-shaped, performance-sensitive systems; use StandardSystems for game logic you want to iterate on with hot reload.
Saving state
Two channels, both flushed by ecs->forceSaveNow() (which the web build calls automatically on tab close):
Key-value:
ecsRef->sendEvent(SaveElementEvent{"highscore", score}), read back withecs->getSavedData("highscore"). Coalesced to at most one disk write per frame.Per-system blobs: the
SaveSystrait — yoursave(Archive&)/load(...)run automatically at registration/teardown, keyed by system name.firstLoad()covers the no-save-yet case. Seeexamples/SimpleBoxBouncerfor a complete 10-line example, and Serialization for the format underneath.
AutoSaveSystem (Systems/autosavesystem.h) adds periodic flushes (default: every 3 minutes).