Architecture¶
High-level overview¶
graph TB
subgraph Build["Build Phase"]
FB["skr::ApplicationBuilder"]
FE["FreyrExtension"]
OPT["FreyrOptions<br/>MaxEntities, ThreadCount,<br/>ChunkCapacity"]
end
subgraph Bootstrap["Bootstrap"]
SC["Registry<br/>Central orchestrator"]
CM["ComponentManager<br/>Archetype routing"]
EM["EntityManager<br/>ID allocation & recycling"]
SM["SystemManager<br/>Pipeline scheduling"]
EVM["EventManager<br/>Pub/sub bus"]
TP["ThreadPool<br/>Work stealing pool"]
end
subgraph Storage["Data Layer"]
ARCH["Archetype[]<br/>Grouped by component signature"]
CHUNK["ArchetypeChunk[]<br/>Fixed-size storage units"]
end
subgraph Execution["Execution"]
WORKERS["Worker Threads"]
QUERY["Query / QueryAggregator<br/>Filter & dispatch"]
end
FB -->|AddExtension| FE
FE -->|registers components| CM
FE -->|registers systems| SM
FE -->|configures| OPT
FB -->|Build| SC
SC --> CM
SC --> EM
SC --> SM
SC --> EVM
SC --> TP
CM --> ARCH
ARCH --> CHUNK
SM --> QUERY
QUERY --> CM
QUERY -->|enqueue chunk tasks| TP
TP -->|distribute| WORKERS
WORKERS -->|process| CHUNK
Registry — the central orchestrator¶
Registry owns all managers and drives the update loop. All entity and component operations flow through Registry, which delegates to the appropriate manager.
graph TB
subgraph RegistryInternals["Internal Structure of Registry"]
SC["Registry"]
subgraph Managers["Managers"]
direction TB
CM["ComponentManager<br/>- Archetype list<br/>- Entity→Archetype map<br/>- Component registration"]
EM["EntityManager<br/>- Entity generation<br/>- MPMC free list"]
SM["SystemManager<br/>- Pipeline list<br/>- System factory map"]
EVM["EventManager<br/>- Publisher map<br/>- Pending listener queues"]
end
subgraph Exec["Execution"]
TP["ThreadPool<br/>- Worker threads<br/>- Per-worker MPMC queues"]
QA["QueryAggregator<br/>- Pending query batch"]
MA["MutationAggregator<br/>- Pending mutation batch"]
end
subgraph Data["Deferred Data"]
DT["mEntitiesToDestroy<br/>(SparseSet<Entity>)"]
end
SC --> CM
SC --> EM
SC --> SM
SC --> EVM
SC --> TP
SC --> QA
SC --> MA
SC --> DT
end
Update loop in detail¶
Registry::Update(dt)
│
├─ 1. EventManager::Flush()
│ Merge pending subscribers into active lists
│ Clear expired listener handles
│
├─ 2. ThreadPool::StartWorkers()
│ Signal workers to begin pulling tasks
│
├─ 3. SystemManager::Accumulate(dt)
│ For each Pipeline:
│ accumulator += dt
│ if accumulator >= rate_interval → mark pipeline ready
│
├─ 4. SystemManager::PreUpdate(dt)
│ For each ready pipeline:
│ For each system in pipeline:
│ system->PreUpdate(dt)
│ ThreadPool::WaitForAllTasks()
│ Registry::DestroyEntities()
│
├─ 5. SystemManager::Update(dt) ← Main work happens here
│ For each ready pipeline:
│ For each system in pipeline:
│ system->Update(dt) ← systems call Query::Each/EachAsync
│ ThreadPool::WaitForAllTasks()
│ Registry::DestroyEntities()
│
└─ 6. SystemManager::PostUpdate(dt)
For each ready pipeline:
For each system in pipeline:
system->PostUpdate(dt)
ThreadPool::WaitForAllTasks()
Registry::DestroyEntities()
ComponentManager — archetype routing¶
The ComponentManager maintains:
- A flat list of
Archetypeshared pointers - A
std::vector<EntityIndex>mapping each entity ID to its current(Archetype*, ArchetypeChunk*)
When a component is added or removed, ComponentManager:
- Computes the new component signature
- Searches existing archetypes for a matching signature
- If found → migrate entity to existing archetype
- If not found → create new archetype, extend the archetype list
- Returns the new
(archetype, chunk)pair
Archetype migration¶
graph LR
subgraph Before["Before: AddComponent<Health> to Entity 5"]
A1["Archetype [Position, Velocity]"]
A1 --> C1["Chunk: Ent 0, Ent 1, Ent 5, Ent 2"]
end
subgraph After["After"]
A1_b["Archetype [Position, Velocity]"]
A2["Archetype [Position, Velocity, Health]"]
A1_b --> C1_b["Chunk: Ent 0, Ent 1, Ent 2"]
A2 --> C2["Chunk: Ent 5, ..."]
end
Before -->|"ComponentManager<br/>moves entity 5"| After Data is copied, not moved
During migration, component data is copied from the source chunk to the target chunk using ComponentArray<T>::CopyComponent. Components should be cheap to copy (prefer POD types).
EntityManager — ID allocation¶
The EntityManager uses:
- A
rigtorp::MPMCQueue<Entity>(lock-free multi-producer/multi-consumer queue) for recycled IDs - An
std::atomic<Entity>counter for new entity generation
When CreateEntity() is called:
- Try to pop from the free list (MPMC queue) → fast path for recycled IDs
- If empty, atomically increment the living count → new sequential ID
When DestroyEntity() is called:
- Push the ID back onto the free list for immediate reuse
Entity CreateEntity() {
if (Entity entity; mAvailableEntities.try_pop(entity))
return entity; // recycled ID
return mLivingEntityCount++; // new ID
}
void DestroyEntity(Entity entity) {
mAvailableEntities.try_push(entity); // return to pool
}
SystemManager — pipeline scheduling¶
The SystemManager holds:
- A
std::vector<Pipeline>— each pipeline has a name, rate, accumulator, and list of system IDs - A
SparseSet<SystemId>of registered systems - A
std::vector<skr::ServiceFactory>— factory functions for lazy system construction
Pipeline timing¶
struct Pipeline {
std::string_view Name;
float Rate; // update interval in seconds
float Accumulator; // elapsed time since last execution
std::vector<SystemId> Systems;
};
Pipelines track their own elapsed time. A pipeline with WithRate(60.0f) has Rate = 1/60 ≈ 0.0167s. The accumulator is incremented each frame by dt. When accumulator >= Rate, the pipeline executes.
timeline
title Pipeline Execution Over Frames
Frame 1 : dt = 16ms : accumulator = 0 → 16 : Physics pipe executes
Frame 2 : dt = 8ms : accumulator = 0 → 8 : doesn't execute
Frame 3 : dt = 12ms : accumulator = 8 → 20 : Physics pipe executes again EventManager — pub/sub bus¶
The EventManager is fully thread-safe:
Publisher<T>instances per event type, indexed byEventId- Pending listener queue — subscribers added during
Publish()are queued and merged before the next flush - Expired handle cleanup — listeners with destroyed handles are removed during
Flush()
graph TB
subgraph EventSystem["Event Manager Internals"]
EVM["EventManager"]
EVM --> P1["Publisher<CollisionEvent>"]
EVM --> P2["Publisher<DamageEvent>"]
EVM --> P3["Publisher<HealEvent>"]
subgraph Pub1["Publisher<T>"]
direction LR
ACTIVE["Active Listeners<br/>(vector)"]
PENDING["Pending Listeners<br/>(vector)"]
LOCK["RwLock"]
end
P1 --> Pub1
end
S1["System A<br/>subscribes"] -->|Subscribe| PENDING
S2["System B<br/>publishes"] -->|Publish| ACTIVE
ACTIVE -->|Flush| CLEANUP["Clear expired<br/>Merge pending"] ThreadPool — work stealing¶
The ThreadPool uses:
- One
rigtorp::MPMCQueue<Task>per worker — MPMC queues allow any thread to push, any thread to pop - Work stealing via LCG hashing —
AddTaskdistributes tasks across queues using a linear congruential generator TaskCounter— atomic counter tracking pending tasks for synchronisation
void AddTask(auto&& func) {
mTaskCounter->AddTasks(1);
mQueueLcgState = mQueueLcgState * LCG_MULTIPLIER + LCG_INCREMENT;
const auto nextQueue = mQueueLcgState % mWorkerQueues.size();
mWorkerQueues[nextQueue]->push(std::forward<decltype(func)>(func));
}
When a worker finishes its queue, it tries to pop from other workers' queues — this is work stealing.
Key design decisions¶
| Decision | Rationale |
|---|---|
| Archetype-based storage | Maximises cache locality — entities with same components are stored together |
| Fixed-size chunks | Enables uniform task granularity for parallel dispatch |
| Per-worker MPMC queues | Minimises contention — producers hash to different queues |
| Deferred entity destruction | Prevents iterator invalidation during iteration |
| RwLock on archetypes | Allows concurrent reads (multiple queries) with exclusive writes (migration) |
| Skirnir DI integration | Systems can inject any dependency via constructor |