Skip to the content.

ECS Guide

PhoenixSim uses an archetype-based ECS. Entities with the same set of components are stored together in contiguous arrays (ArchetypeList), which makes iterating a filtered set of entities very cache-friendly.


Entities

An EntityId is a lightweight 32-bit handle. You never construct one directly — the ECS allocates them.

// Acquire a new entity
EntityId id = FeatureECS::AcquireEntity(world, "MyKind");

// Release (does not immediately free — deferred until end of frame)
FeatureECS::ReleaseEntity(world, id);

"MyKind" is an FName that categorizes the entity. It’s used in queries to filter by entity type.


Components

A component is a plain data struct. Declare it with the macro:

// MyComponent.h
#include "PhoenixSim/ECS/Component.h"

struct MyComponent : public Phoenix::ECS::IComponent
{
    PHX_ECS_DECLARE_COMPONENT(MyComponent)

    int   Health = 100;
    float Speed  = 1.0f;
};

Add and retrieve components on an entity:

// Add
MyComponent* comp = FeatureECS::AddComponent<MyComponent>(world, entityId);
comp->Health = 200;

// Get (returns nullptr if not present)
MyComponent* comp = FeatureECS::GetComponent<MyComponent>(world, entityId);

// Get or add
MyComponent* comp = FeatureECS::GetOrAddComponent<MyComponent>(world, entityId);

// Remove
FeatureECS::RemoveComponent<MyComponent>(world, entityId);

When you add or remove a component, the entity moves to a different ArchetypeList. This is deferred to the end of the frame — don’t assume the entity is in its new archetype until the next tick.


Querying Entities

EntityQuery lets you filter by component presence. Build one with the fluent builder:

auto query = Phoenix::ECS::EntityQueryBuilder()
    .WithAll<TransformComponent, MyComponent>()   // must have both
    .WithNone<DeadComponent>()                    // must not have this
    .Build();

Then iterate matching entities:

FeatureECS::ForEachEntity(world, query,
    [](Phoenix::ECS::EntityId id,
       Phoenix::ECS::TransformComponent& transform,
       MyComponent& my)
    {
        my.Health -= 1;
        // transform.Transform.Position is the entity's position
    });

The component types in the lambda must be a subset of WithAll<> — the ECS uses them to resolve pointers per entity.

For read-only access, take the component by const&.


Systems

A System encapsulates recurring per-world logic. Prefer systems over putting logic directly in OnWorldUpdate when the logic touches many entities or needs the full ECS iteration machinery.

// MySystem.h
#include "PhoenixSim/ECS/System.h"

class MySystem : public Phoenix::ECS::ISystem
{
    PHX_ECS_DECLARE_SYSTEM(MySystem)

public:
    void OnWorldInitialize(Phoenix::WorldRef world) override;
    void OnWorldUpdate(Phoenix::WorldRef world,
                       const Phoenix::ECS::SystemUpdateArgs& args) override;
    void OnWorldShutdown(Phoenix::WorldRef world) override;

private:
    Phoenix::ECS::EntityQuery m_Query;
};
// MySystem.cpp
void MySystem::OnWorldInitialize(Phoenix::WorldRef world)
{
    m_Query = Phoenix::ECS::EntityQueryBuilder()
        .WithAll<TransformComponent, MyComponent>()
        .Build();
}

void MySystem::OnWorldUpdate(Phoenix::WorldRef world,
                              const Phoenix::ECS::SystemUpdateArgs& args)
{
    FeatureECS::ForEachEntity(world, m_Query,
        [&](Phoenix::ECS::EntityId id, TransformComponent& t, MyComponent& my)
        {
            // per-entity logic
        });
}

Register the system in your feature’s OnWorldInitialize:

void MyFeature::OnWorldInitialize(Phoenix::WorldRef world)
{
    auto sys = std::make_shared<MySystem>();
    FeatureECS::GetECS(world)->RegisterSystem(sys);
}

System lifecycle hooks

Hook When it runs
OnWorldInitialize Once when the world is created
OnPreWorldUpdate Each tick, before OnWorldUpdate — use for sorting/prep
OnWorldUpdate Each tick — main logic
OnPostWorldUpdate Each tick, after OnWorldUpdate — cleanup/deferred work
OnWorldShutdown Once when the world is destroyed

Tags

Tags are zero-size markers on entities. They work like components in queries but carry no data:

// Declare
struct AliveTag : public Phoenix::ECS::IComponent
{
    PHX_ECS_DECLARE_COMPONENT(AliveTag)
};

// Apply
FeatureECS::AddTag<AliveTag>(world, entityId);

// Query — use WithAllTags / WithNoneTags
auto query = Phoenix::ECS::EntityQueryBuilder()
    .WithAll<TransformComponent>()
    .WithAllTags<AliveTag>()
    .WithNoneTags<DeadTag>()
    .Build();

TransformComponent

Every entity has a TransformComponent automatically — you don’t need to add it. It contains:

struct TransformComponent : IComponent
{
    EntityId    AttachParent;   // parent entity (or invalid)
    Transform2D Transform;      // position + rotation
    uint32      ZCode;          // Morton code for spatial sorting (managed by ECS)
};

Access position:

auto* t = FeatureECS::GetComponent<TransformComponent>(world, id);
auto pos = t->Transform.Position;  // Phoenix::Math::Vec2

Jobs and Parallel Scheduling

The job system is the primary way to process entities in parallel. Jobs participate in a DAG-based scheduler that serializes conflicting jobs and runs independent ones concurrently.

IJob — per-entity job

Subclass IJob<TComponents...> where TComponents is the list of component types the job needs. Each entity is processed by a single Execute() call:

struct MyJob : Phoenix::ECS::IJob<TransformComponent&, const MyComponent&>
{
    FName GetName() const override { return "MyJob"_n; }

    void Execute(WorldConstRef world, EntityId id, CommandBuffer& cb,
                 TransformComponent& transform,
                 const MyComponent& my) override
    {
        if (my.ShouldRelease)
            cb.Append<Commands::ReleaseEntity>(id);
    }
};

Component types follow the same rules as ForEachEntityT& for read-write, const T& for read-only. The scheduler uses these access modes to detect conflicts between jobs.

Optional batch hooks run once per matching archetype, outside the entity loop:

void BeginBatch(WorldConstRef world, const JobBatch& batch, CommandBuffer& cb) override;
void EndBatch(WorldConstRef world, const JobBatch& batch, CommandBuffer& cb) override;

ITask — single-execution task

ITask participates in the same DAG as jobs but runs once per scheduler execution rather than once per entity. Use it for setup/teardown that must be ordered relative to IJob work:

struct MySortTask : Phoenix::ECS::ITask
{
    void Run(WorldConstRef world, CommandBuffer& cb) override
    {
        std::sort(...);
    }
};

Registering jobs in the global scheduler

Register jobs from ISystem::OnWorldInitialize. The global scheduler runs automatically each tick — before system OnXxxWorldUpdate methods:

void MySystem::OnWorldInitialize(WorldRef world)
{
    JobHandle hA = FeatureECS::RegisterJob(world,
        std::make_unique<MyJobA>(), EJobPhase::Update);

    JobHandle hB = FeatureECS::RegisterJob(world,
        std::make_unique<MyJobB>(), EJobPhase::Update);

    // B must not start until A has finished
    FeatureECS::AddJobDependency(world, EJobPhase::Update, /*after=*/hB, /*before=*/hA);
}

EJobPhase controls when the scheduler fires relative to the world tick:

Phase Runs before
PreUpdate ISystem::OnPreWorldUpdate
Update ISystem::OnWorldUpdate
PostUpdate ISystem::OnPostWorldUpdate

System-owned schedulers

The global scheduler runs before system update methods, so globally-registered jobs cannot receive per-frame data (DeltaTime, config values) that the system sets during its own update. For those cases, own the JobScheduler on the system and drive it manually:

class MySystem : public ISystem
{
    void OnWorldInitialize(WorldRef world) override
    {
        auto job = std::make_unique<MyJob>();
        JobPtr = job.get();
        Scheduler.RegisterJob(std::move(job));
        // call Scheduler.AddDependency(...) as needed
        // Build() is called internally by ExecuteScheduler on first run
    }

    void OnWorldUpdate(WorldRef world, const SystemUpdateArgs& args) override
    {
        JobPtr->DeltaTime = args.DeltaTime;     // inject per-frame data before running
        FeatureECS::ExecuteScheduler(world, Scheduler);
    }

    ECS::JobScheduler Scheduler;
    MyJob* JobPtr = nullptr;   // raw ptr for per-frame param updates; Scheduler owns lifetime
};

FeatureECS::ExecuteScheduler runs the caller-owned scheduler using the world’s thread pool and command buffers, and rebuilds archetype batches automatically when archetypes change.

CommandBuffer — deferring mutations

Each Execute() call receives a per-thread CommandBuffer. Use it to defer any mutation that touches shared or cross-entity state:

// Structural changes
cb.Append<Commands::ReleaseEntity>(id);
cb.Append<Commands::AddTag<AliveTag>>(id);

// Construct-in-place from args
cb.Append<Commands::SetBlackboardValue<float>>(id, "Speed"_n, 2.5f);

// From a pre-constructed value
cb.Append(Commands::SetEntityKind{id, "NewKind"_n});

Command buffers are flushed and applied serially after the parallel job phase completes. Register a handler to react to custom commands:

FeatureECS::RegisterCommandHandler<MyCommand>(world,
    [](WorldRef world, const MyCommand& cmd) { /* ... */ });

What is and isn’t safe from Execute()

Operation Safe in parallel? How
Read component args Yes Each entity owns distinct memory
Write component args Yes Each entity’s slot is exclusive
Read feature/world block data Yes Treat as a read-only snapshot during job execution
Mutate shared or global state No Use CommandBuffer
Call feature statics that mutate No Use CommandBuffer
Acquire or release entities No Use CommandBuffer

Practical Tips

Don’t add/remove components inside ForEachEntity — structural changes are deferred, but modifying the archetype list you’re currently iterating causes undefined behavior. Queue the changes and apply them after the loop.

Build queries once — construct EntityQuery in OnWorldInitialize and store it as a member. Building a query every frame allocates and compares component lists unnecessarily.

Use WithNone<> aggressively — excluding dead, dormant, or cargo entities from a query is cheaper than checking inside the loop.

Component access is O(1)GetComponent<T> does a direct offset lookup into the archetype block. It’s fast but not free in tight loops; prefer getting a pointer once outside the loop if you need it multiple times.