Entity Subset Patterns¶
There are three main approaches to filtering entities at runtime. Each has different performance characteristics and use cases.
Start simple — partitions in particular are an optimization
Branching on a component value inside a normal iteration (Approach A) is the simplest option and is fast enough for most gameplay code, so it's a fine default. Sets are also a legitimate design tool whenever the subset is itself something you want to name, query, count, or iterate as a first-class concept — don't hesitate to reach for them when they fit the shape of the problem. Partitions, on the other hand, are primarily a performance optimization for cache-friendly dense iteration over very large populations; pick them when the profiler (or population size) calls for it, not as a general way to model state.
Approach A: Check Component Values¶
Iterate all entities and check a condition:
[ForEachEntity(MatchByComponents = true)]
void Execute(ref Health health)
{
if (health.Current <= 0)
{
// Handle dead entity
}
}
Pros: Simple, no setup required. State stays colocated with the data it describes, so there's nothing to keep in sync.
Cons: Visits every entity the query matches (every group with the requested components when using MatchByComponents, or every entity in the tag-scoped groups otherwise), including those that fail the branch. Only a real problem when the population is large and the check sits in a hot loop.
Approach B: Template Partitions¶
Use IHasPartition to define mutually exclusive states that entities can transition between:
// Template with partitions
public partial class EnemyEntity : ITemplate,
IHasTags<GameTags.Enemy>,
IHasPartition<GameTags.Alive>,
IHasPartition<GameTags.Dead>
{
public Health Health;
public Position Position;
}
// Transition
World.MoveTo<GameTags.Enemy, GameTags.Dead>(entity.EntityIndex);
// Iterate only dead enemies
[ForEachEntity(Tags = new[] { typeof(GameTags.Enemy), typeof(GameTags.Dead) })]
void Execute(in DeadEnemy enemy) { ... }
Pros: Dense iteration — only matching entities are visited. Cache-friendly. Cons: Moving between groups copies component data. Adding dimensions multiplies the number of groups (2^N for N boolean partitions).
Approach C: Sets (Indexed Subsets)¶
Use sets for dynamic, sparse membership. Sets must be registered with the world builder via AddSet<T>():
public struct DeadEnemies : IEntitySet { }
// Add to set
World.SetAdd<DeadEnemies>(entity.EntityIndex);
// Iterate set members
[ForEachEntity(Set = typeof(DeadEnemies))]
void Execute(in DeadEnemy enemy) { ... }
Pros: No data movement. Unlimited dimensions without group explosion. Fast add/remove. Cons: Sparse iteration (indexed access, less cache-friendly than dense).
Decision Guide¶
| Factor | Component Check | Template Partitions | Sets |
|---|---|---|---|
| Setup | None | Template + tags | Set struct + registration |
| Change cost | None (just data) | Component copy | Index add/remove |
| Iteration | Visits every entity the query matches (then branches) | Dense (only matching entities visited) | Sparse (indexed lookup per member) |
| Group count | No increase | 2^N per dimension | No increase |
| Best for | The default — gameplay code where the condition is local to the component data | Hot iteration over very large populations where cache locality dominates the cost | Dynamic membership, many dimensions, or any subset that's itself a first-class concept |
Rules of Thumb¶
Pick the approach that matches the shape of the problem. Component checks are the simplest default, sets are a reasonable choice whenever a subset is itself a meaningful concept, and partitions are reserved for when iteration cost actually matters.
- Simple per-iteration filtering, membership not needed elsewhere → Component value check. No registration, no transitions, no extra memory. Just
ifinside the loop. - The subset is a named concept systems want to query, iterate, or count directly → Sets. Use them freely when they model your design well — "visible enemies", "units under player control", "entities currently selected" are all natural fits.
- One system needs to flag a subset for several downstream systems in the same frame → Sets, used as per-frame scratch storage. Clear the set at the top of the producer, fill it with
AddImmediate, and have consumers iterate the set instead of re-running the producer's filter. See Per-Frame Staging for the full pattern (and why the immediate APIs are required here). - 3+ dimensions, or many categories → Sets. Avoids combinatorial explosion of groups.
- Very large entity populations (thousands+) where a hot iteration shows up in the profiler → Template partitions. The dense-array layout gives you cache-friendly iteration, but at the cost of data movement on transitions and extra groups per dimension. Treat this as a performance optimization, not a design primitive — the partition boundary should reflect a real hot path, not just a conceptual state split.
Combinatorial Explosion¶
With template partitions, each dimension doubles the number of groups:
| Dimensions | Groups |
|---|---|
| 1 (Alive/Dead) | 2 |
| 2 (Alive/Dead × Visible/Hidden) | 4 |
| 3 (+ Poisoned/Healthy) | 8 |
| 4 | 16 |
At 3+ dimensions, prefer sets — they don't create additional groups.
Mixing Approaches¶
Nothing forces you to pick one approach per template. A typical mix is to use partitions when a lifecycle split (e.g. Alive / Dead) is hot enough that splitting the storage is worth it, and sets for everything else — dynamic categorizations, designer-meaningful subsets, multi-dimensional filters. Component-value branching covers the rest.
// Partitions: only because Alive/Dead iteration is a measured hot path.
public partial class Enemy : ITemplate,
IHasTags<GameTags.Enemy>,
IHasPartition<GameTags.Alive>,
IHasPartition<GameTags.Dead>
{ ... }
// Sets: design-level subsets that systems want to query directly.
public struct PoisonedEnemies : IEntitySet<GameTags.Enemy> { }
public struct VisibleEnemies : IEntitySet<GameTags.Enemy> { }
public struct TargetedEnemies : IEntitySet<GameTags.Enemy> { }