Skip to content

Entity Component Iteration

Artyom Romskiy edited this page Oct 13, 2026 · 8 revisions

Entity Component Iteration

Each arena pass has one job: move units, choose targets, apply damage, or update a squad-specific value. A Query selects the entities needed for that job; ForEach exposes only the rows the callback will use. Iteration and predicate terminals build reusable operations, so the examples call Invoke() to execute them. These examples continue with the types and registrations from Components, Tags and Queries. The typed examples use typedWorld; examples with ComponentId use the world created from idLayouts. Add using System; and using Delta.ECS; at the top of the source file.

Start with the entities that can move

A movement pass needs velocity, acceleration, and speed scale. It should skip units that are out of action:

Query movingUnits = world
    .WhereAll<Position, Velocity, Acceleration, SpeedScale>()
    .WhereNone<KnockedOut, Defeated>();

Pass a static callback when no external state is needed. Its parameter modifiers say exactly how each row is used:

world.ForEach(
    in movingUnits,
    static (
        in Acceleration acceleration,
        ref Velocity velocity,
        SpeedScale scale) =>
    {
        velocity.X += acceleration.X * scale.Value;
        velocity.Y += acceleration.Y * scale.Value;
    }).Invoke();

ref lets the callback update that component. in and ref readonly are read-only references; a parameter passed by value is a local copy. ref readonly lambda parameters require C# 12; use in for C# 9 projects. A static callback makes it clear that the pass does not capture hidden state.

Pass frame state when the callback needs it

If movement depends on the frame duration, pass that state explicitly after the query. The callback receives the context first:

var frame = new FrameContext { DeltaTime = 1f / 60f };

world.ForEach(
    in movingUnits,
    ref frame,
    static (ref FrameContext context, ref Position position, in Velocity velocity) =>
    {
        position.X += velocity.X * context.DeltaTime;
        position.Y += velocity.Y * context.DeltaTime;
        context.Moved++;
    }).Invoke(ref frame);

public struct FrameContext
{
    public float DeltaTime;
    public int Moved;
}

This keeps the callback static while allowing the caller to inspect frame.Moved after the pass.

Use a functor when iteration has explicit local state

An IForEach functor is useful when the callback itself should own state for the duration of the pass:

Query healthChecks = world.WhereAll<Health>().WhereNone<Defeated>();
var counter = new CountWounded();

world.ForEach(in healthChecks, ref counter).Invoke(ref counter);
int wounded = counter.Count;

public struct CountWounded : IForEach
{
    public int Count;

    public void Invoke(in Health health)
    {
        if (health.Value < 25f)
        {
            Count++;
        }
    }
}

A struct functor uses a constrained generic call that the JIT can inline. The hot loop avoids delegate invocation, boxing, and virtual dispatch, making this a very fast form for iteration. Passing the functor by ref lets its state changes remain visible after the pass. For fixed component types, it is the simplest explicit callback object.

Include the entity when game logic needs its handle

ForEachEntity passes a borrowed EntityRef before the component parameters. Save its Handle when a result must retain the entity, for example the weakest target for a squad:

Entity weakestEntity = default;

world.ForEachEntity(
    in healthChecks,
    ref weakestEntity,
    static (ref Entity result, EntityRef entity, in Health health) =>
    {
        if (health.Value < 25f)
        {
            result = entity;
        }
    }).Invoke(ref weakestEntity);

When an earlier targeting step has already gathered specific units, pass their handles as a span and optionally use a query to filter the list further:

Span<Entity> selectedUnits = stackalloc Entity[4];
int selectedCount = world.Create<Position, Velocity, Acceleration, SpeedScale>(
    selectedUnits.Length,
    selectedUnits);
ReadOnlySpan<Entity> nearbyUnits = selectedUnits[..selectedCount];

world.ForEachEntity(
    nearbyUnits,
    in movingUnits,
    static (EntityRef entity, ref Position position, in Velocity velocity) =>
    {
        position.X += velocity.X;
        position.Y += velocity.Y;
    }).Invoke();

Use a world-wide query when the system owns the full selection. Use the entity-list form when gameplay code already has a small concrete set, such as units near a chest or the members of one squad.

Select repeated CLR types with ComponentId

If health and stamina are both stored as float, IDs tell the generated callback which registration belongs in each row:

Query resources = world.WhereAll(healthValueId, staminaValueId);

world.ForEach<float, float>(
    in resources,
    healthValueId,
    staminaValueId,
    static (ref float health, in float stamina) =>
    {
        if (stamina <= 0f)
        {
            health -= 1f;
        }
    }).Invoke();

Prefer generic type-based selection when each CLR type has one intended registration. Use IDs when code must distinguish repeated registrations or chooses them at runtime.

Run independent work in parallel

A squad-wide movement pass can use ForEachParallel when each callback changes only the current entity's rows:

world.ForEachParallel(
    in movingUnits,
    static (ref Position position, in Velocity velocity) =>
    {
        position.X += velocity.X;
        position.Y += velocity.Y;
    },
    workerCount: 4).Invoke();

Parallel callbacks can run at the same time. Do not update a shared counter or shared game object from the callback unless access is coordinated. For dependent steps such as target selection followed by damage, keep the passes ordered.

Reuse one open generic functor for runtime-selected component types

Sometimes the algorithm is fixed but the component type is selected by data—for example, a replay system may record whichever stat the player chose to track. An open generic functor lets the generator build a typed loop for that algorithm while ComponentId supplies the runtime type argument.

This example stores the previous value of a selected component in a matching StatHistory component:

var historyLayouts = new ComponentRegistry();
ComponentId healthId = historyLayouts.Register<Health>(new SchemaId(70));
ComponentId historyId = historyLayouts.Register(
    typeof(StatHistory<>),
    healthId,
    new SchemaId(71));

using var historyWorld = new World(historyLayouts);

Entity tracked = historyWorld.Create(stackalloc ComponentId[] { healthId, historyId });
historyWorld.GetRef<Health>(tracked, healthId) = new Health { Value = 80f };

Query trackedHealth = historyWorld.WhereAll(healthId, historyId);

historyWorld.ForEach(
    in trackedHealth,
    healthId,
    typeof(CapturePrevious<>)).Invoke();

public struct StatHistory<T>
{
    public T Previous;
}

public struct CapturePrevious<T> : IForEach
{
    public void Invoke(ref StatHistory<T> history, in T current)
    {
        history.Previous = current;
    }
}

typeof(CapturePrevious<>) is the open generic functor definition. Its Invoke signature determines the component rows (StatHistory and T); the ComponentId argument closes T. The matching StatHistory registration must already exist in the world. This is useful for generic history, serialization, replication, or inspection code that should work with several registered component types without writing one functor per type.

The generator emits a typed executor for the functor shape. Type closure and executor lookup happen before the entity loop and are cached; the entity callback does not box each component. For a fixed type known at build time, prefer ordinary IForEach. This is an explicit functor API, not a delegate callback.

A two-parameter functor uses two IDs in generic-parameter order:

Query comparisonQuery = world.WhereAll(healthValueId, staminaValueId);

world.ForEach(
    in comparisonQuery,
    healthValueId,
    staminaValueId,
    typeof(CompareValues<,>)).Invoke();

public struct CompareValues<TLeft, TRight> : IForEach
{
    public void Invoke(in TLeft left, in TRight right)
    {
        // Compare the two selected component rows.
    }
}

Here both registrations contain float, but they remain distinct rows. The functor can compare health and stamina without hard-coding either registration into its implementation.

This API closes generic types at runtime. NativeAOT cannot currently guarantee code for arbitrary runtime-selected closures; the AOT probe for this API failed with missing native code for the closed executor. Verify the target deployment model when using it.

For the full generated shapes and argument order, see the API grammar.

Previous: Create, Add, Remove, Destroy · Next: Caching Operations