The ECS and jobs
Entities and archetypes, components split into per-field arrays (AoSoA), queries by entity and by chunk, resources, deferred commands, the staged scheduler, and the job system.
All game logic in a Ragnavoxel game is systems over components, and engine services are ECS resources. ecs/Ecs.hpp, namespace rv::ecs.
Archetypes and chunks
Entities that share exactly the same set of components live together in one archetype. An archetype owns a list of fixed-size chunks of 16 KiB. Inside a chunk, every component is decomposed into its declared fields, and each field gets its own contiguous array of capacity values. A chunk is therefore one packet of an array-of-structures-of-arrays: a hot loop touches one field array at a time with unit stride, and all the data for a group of entities stays within one chunk, close in cache.
An Entity is an index and a generation; a destroyed entity’s index is recycled with a new generation, so stale handles fail alive(). Up to 128 component types are supported.
Declaring components
A plain struct is a component. To split it into per-field arrays, declare its fields before any entity uses it:
struct Position { float x, y, z; };
struct Velocity { float x, y, z; };
auto& reg = rv::ecs::ComponentRegistry::instance();
reg.declareFields<Position>(&Position::x, &Position::y, &Position::z);
reg.declareFields<Velocity>(&Velocity::x, &Velocity::y, &Velocity::z);
Split components must be trivially copyable and default constructible. A component that never declares fields is stored whole as a single field, which also allows non-trivially-copyable components such as handles that own heap memory (a std::string, a mesh, an animator).
The world
rv::ecs::World w;
rv::ecs::Entity e = w.create(Position{0, 0, 0}, Velocity{1, 2, 3});
w.add(e, Name{"third"}); // moves the entity to a new archetype
w.remove<Velocity>(e);
bool alive = w.alive(e);
bool has = w.has<Name>(e);
Position p = w.get<Position>(e); // gathers a value (split or unsplit)
w.set(e, Position{4, 0, 0}); // scatters it back
Name& n = w.ref<Name>(e); // a reference: unsplit components only
w.destroy(e);
ref<T> gives a direct reference only for unsplit components. A split component’s fields live in separate arrays, so there is no T to refer to: use get and set. Asking for a reference to a split component throws in every build.
Resources
Singletons (time, input, renderer handles, a game’s own services) are resources:
w.emplaceResource<GameRules>(rules);
auto& rules = w.resource<GameRules>();
if (auto* weather = w.tryResource<Weather>()) { /* ... */ }
Deferred commands
Structural changes during iteration go through the command buffer, applied later:
w.query<Position, Velocity>().forEach([&](rv::ecs::Entity e, Position& p, Velocity&) {
if (p.y < -100.0f) w.commands().destroy(e);
});
w.flush(); // the scheduler flushes after each stage for you
Commands are destroy, add, remove and custom(fn).
Queries
w.query<A, B, ...>() matches every archetype holding all of the listed components; .without<C>() excludes those holding C.
By entity, with references. Split components are gathered into a temporary struct, passed by reference and scattered back after the callback:
w.query<Position>().forEach([](rv::ecs::Entity, Position& p) { p.z = p.x * 2.0f; });
By chunk, with the raw per-field arrays: the fast path for hot loops.
w.query<Position, Velocity>().forEachChunk([](rv::ecs::ChunkView& v) {
float* px = v.field<&Position::x>();
float* py = v.field<&Position::y>();
const float* vx = v.field<&Velocity::x>();
const float* vy = v.field<&Velocity::y>();
for (uint32_t i = 0; i < v.count(); ++i) {
px[i] += vx[i];
py[i] += vy[i];
}
});
v.field<&T::member>() is the unit-stride array of one field of a split component; v.column<T>() the contiguous array of an unsplit one; v.entities() the entities of the chunk. parallelForEachChunk does the same with the chunks spread across the job system. Callbacks may create entities: archetype and chunk lists are walked by index and re-fetched, never held across a callback.
The scheduler
A Scheduler runs systems in stage order, flushing structural commands after each stage:
| Stage | For |
|---|---|
Input |
reading devices into actions |
PreUpdate |
|
Update |
the simulation |
PostUpdate |
|
PreRender |
gathering what to draw |
Render |
drawing |
fixedSystems().add("movement", rv::ecs::Stage::Update, [](rv::ecs::World& w, float dt) {
w.query<Position, Velocity>().forEachChunk([&](rv::ecs::ChunkView& v) { /* ... */ });
});
Systems within a stage run in the order added. systems() lists them with how long each last took (lastMs), for profiling. The Application has two schedulers: fixedSystems() (every fixed step) and frameSystems() (once a frame).
AoSoA containers
core/AoSoA.hpp has the same layout as a free-standing container, AoSoA<Lanes, Fields...>: elements grouped into packets of Lanes, each field of a packet a contiguous, 64-byte-aligned array. A loop over one field of one packet is a SIMD-friendly sweep, while all fields of an element stay in the same cache-resident packet.
The job system
core/JobSystem.hpp is a work-sharing thread pool:
auto& jobs = rv::JobSystem::global();
std::future<void> f = jobs.submit([] { /* background work */ }, /*urgent=*/false);
jobs.parallelFor(count, /*batch=*/64, [&](size_t i) { /* one index */ });
Background tasks go through FIFO queues, urgent ones first. parallelFor splits an index range into batches that the calling thread and every worker drain together, so it never deadlocks when nested. The engine runs terrain generation, meshing, colliders, fracture and audio loading on it.
Deterministic randomness
core/Noise.hpp gives integer hashing (used wherever deterministic randomness is needed), a small fast PCG32 generator for procedural placement, and seeded simplex noise in 2D and 3D with fractal helpers. A world generated from the same seed is the same world on every machine.
Written from the engine's headers and source as of commit 02e81b2 (2026-11-01).