Skip to content

Components, Tags and Queries

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

Components, Tags and Queries

The arena contains different kinds of information. A unit's position and health change over time, while its race or current status often matters only as a yes/no fact. Components hold the data; tags describe membership; queries assemble both into the group a system should process.

Register the unit's components

These are the data and marker types used throughout the examples:

using System;
using Delta.ECS;

public struct Position { public float X; public float Y; }
public struct Velocity { public float X; public float Y; }
public struct Acceleration { public float X; public float Y; }
public struct SpeedScale { public float Value; }
public struct Health { public float Value; }
public struct Armor { public float Value; }
public struct Mana { public float Value; }
public struct Weapon { public int Damage; }
public struct AttackCooldown { public float Seconds; }
public struct Target { public Entity Value; }
public struct Damage { public int Value; }
public struct SpeedBoost { public float Seconds; }

public struct Goblin { }
public struct Dwarf { }
public struct Elf { }
public struct Human { }
public struct Golem { }
public struct Recruitable { }
public struct KnockedOut { }
public struct Defeated { }
public struct Melee { }
public struct Ranged { }
public struct Visible { }
public struct Selected { }
public struct OnGround { }
public struct Armed { }

Register component types before creating entities that use them. For ordinary typed code, you can ignore the returned IDs:

var layouts = new ComponentRegistry();
ulong nextSchemaId = 0;
SchemaId NextSchemaId() => new(++nextSchemaId);

layouts.Register<Position>(NextSchemaId());
layouts.Register<Velocity>(NextSchemaId());
layouts.Register<Acceleration>(NextSchemaId());
layouts.Register<SpeedScale>(NextSchemaId());
layouts.Register<Health>(NextSchemaId());
layouts.Register<Armor>(NextSchemaId());
layouts.Register<Mana>(NextSchemaId());
layouts.Register<Weapon>(NextSchemaId());
layouts.Register<AttackCooldown>(NextSchemaId());
layouts.Register<Target>(NextSchemaId());

layouts.Register<Goblin>(NextSchemaId());
layouts.Register<Dwarf>(NextSchemaId());
layouts.Register<Elf>(NextSchemaId());
layouts.Register<Human>(NextSchemaId());
layouts.Register<Golem>(NextSchemaId());
layouts.Register<Recruitable>(NextSchemaId());
layouts.Register<KnockedOut>(NextSchemaId());
layouts.Register<Defeated>(NextSchemaId());
layouts.Register<Melee>(NextSchemaId());
layouts.Register<Ranged>(NextSchemaId());
layouts.Register<Visible>(NextSchemaId());
layouts.Register<Selected>(NextSchemaId());
layouts.Register<OnGround>(NextSchemaId());

using var typedWorld = new World(layouts);

Build the group a system needs

Suppose the next combat pass should consider living melee recruits from several races. WhereAll lists what every candidate must have, WhereAny lists acceptable alternatives, and WhereNone excludes disqualifying states:

Query meleeRecruits = typedWorld
    .WhereAll<Position, Health, Weapon, Recruitable, Melee>()
    .WhereAny<Goblin, Dwarf, Elf>()
    .WhereNone<KnockedOut, Defeated>();

This query means: the entity has position, health, a weapon, recruitable status, and the melee role; it is a goblin, dwarf, or elf; and it is neither knocked out nor defeated. Each call returns a new Query, so the base selection can be extended for another system:

Query rangedRecruits = typedWorld
    .WhereAll<Position, Health, Weapon, Recruitable, Ranged>()
    .WhereNone<KnockedOut, Defeated>();

Query allRecruits = typedWorld
    .WhereAll<Position, Health, Recruitable>()
    .WhereNone<KnockedOut, Defeated>();

Use WhereAny for one shared set of alternatives. Chaining another WhereAny adds more alternatives to that same set; it does not create a separate “one from each group” condition.

Use long typed filters when the whole composition matters

A specialized system can require a longer component list. List every property its callback depends on; the generator emits the matching arity used by the project:

Query fullyEquippedGoblins = typedWorld.WhereAll<
    Position, Velocity, Acceleration, Health, Mana, Armor,
    Weapon, AttackCooldown, Target, Goblin, Melee, Recruitable,
    Visible, Selected, OnGround>();

This is useful when a system needs a precise unit shape—for example, when applying a formation bonus only to selected goblins that can move, fight, carry mana, and stand on the arena floor. You do not need to hand-write an arity-specific query implementation.

Select repeated component types with ComponentId

Most code can use component types directly. Use ComponentId when the same CLR type is registered more than once, such as two independent float values for health and stamina. Keep these IDs from Register when setting up the world:

var idLayouts = new ComponentRegistry();
ulong nextSchemaId = 100;
SchemaId NextSchemaId() => new(++nextSchemaId);

ComponentId positionId = idLayouts.Register<Position>(NextSchemaId());
ComponentId healthId = idLayouts.Register<Health>(NextSchemaId());
ComponentId healthValueId = idLayouts.Register<float>(NextSchemaId());
ComponentId staminaValueId = idLayouts.Register<float>(NextSchemaId());
ComponentId knockedOutId = idLayouts.Register<KnockedOut>(NextSchemaId());

idLayouts.Register<Velocity>(NextSchemaId());
idLayouts.Register<Acceleration>(NextSchemaId());
idLayouts.Register<SpeedScale>(NextSchemaId());
idLayouts.Register<Armor>(NextSchemaId());
idLayouts.Register<Mana>(NextSchemaId());
idLayouts.Register<Weapon>(NextSchemaId());
idLayouts.Register<AttackCooldown>(NextSchemaId());
idLayouts.Register<Target>(NextSchemaId());
idLayouts.Register<Damage>(NextSchemaId());
idLayouts.Register<SpeedBoost>(NextSchemaId());

idLayouts.Register<Goblin>(NextSchemaId());
idLayouts.Register<Dwarf>(NextSchemaId());
idLayouts.Register<Elf>(NextSchemaId());
idLayouts.Register<Human>(NextSchemaId());
idLayouts.Register<Golem>(NextSchemaId());
idLayouts.Register<Recruitable>(NextSchemaId());
idLayouts.Register<Defeated>(NextSchemaId());
idLayouts.Register<Melee>(NextSchemaId());
idLayouts.Register<Ranged>(NextSchemaId());
idLayouts.Register<Visible>(NextSchemaId());
idLayouts.Register<Selected>(NextSchemaId());
idLayouts.Register<OnGround>(NextSchemaId());
idLayouts.Register<Armed>(NextSchemaId());

using var world = new World(idLayouts);

The generator emits positional overloads for the argument counts used at each call site. These are ordinary generated methods, not C# 13 params collections, so this syntax works in a C# 9 project with the generator enabled. Each call returns a new Query; chaining combines additional requirements, alternatives, or exclusions into one reusable selection:

Query resourceCarriers = world
    .WhereAll(positionId)
    .WhereAll(healthValueId, staminaValueId)
    .WhereNone(knockedOutId)
    .WhereNone<Defeated>();

The chain describes one reusable set of matching entities; pass the resulting Query to an iteration operation to process that set.

When the IDs are assembled dynamically or the generator is not available, pass an explicit ReadOnlySpan<ComponentId> to the runtime query API instead.

ID-based and typed filters can also be mixed:

Query activeGoblinOrDwarfResources = world
    .WhereAll(positionId, healthValueId)
    .WhereAll(staminaValueId)
    .WhereAny<Goblin, Dwarf>()
    .WhereNone<KnockedOut, Defeated>();

For fixed component types, the generic form is usually easier to read. IDs are valuable when one CLR type represents several independently selected values, such as health, stamina, damage, or mana. This ID-based world is also used by the structural mutation and iteration examples that follow.

Use tags for membership, components for values

An empty non-primitive struct is automatically a tag when registered. A tag answers a membership question; it does not store a value to read:

Entity scout = typedWorld.Create<Position>();
typedWorld.Add(scout, new Goblin());
bool isGoblin = typedWorld.Has<Goblin>(scout);

Query activeScouts = typedWorld
    .WhereAll<Goblin, Position>()
    .WhereNone<KnockedOut>();

A tag is a good fit for “is a goblin,” “is recruited,” or “is knocked out.” Use a data component when the system needs a value, such as Health.Value, a timer, or the unit's current target. Tag filters work in the same query builder as data components, and adding or removing a tag does not move the entity's component data rows.

Previous: Quick start · Next: Create, Add, Remove, Destroy