Skip to content

Relations between Entities

fennecs allows component attachment to entities reference an additional Entity, the relation target. This relation becomes part of the Key that is used to group Entities into an Archetype.

The relation is said to be backed by a Component type, and any data can be stored in it. The relation is a secondary key on top top of the normal matching logic that fennecs performs.


Relations are unidirectional, so the Target doesn't "know" it is being related to.

But despawning the Target of one or more Relations will remove these Relation Components from any Entities that reference it. See the ending of the Kill Bill appetizer for a demonstration.

Relations can be backed by any type (reference or value types). The backing data is only shared if the component is a shared component itself.

The target of a relation must be Alive.

Creating & Removing Relations

Any Component can be backing a Relation.

record struct Owes(decimal Amount);

Entity bob, alice, eve;

We can use them as follows:

bob.Add<Owes>(new(10M), alice); // he owes alice $10 (Relation Owes->alice)
bob.Add<Owes>(new(23M), eve); // and owes eve $23 (Relation Owes->eve)
bob.Remove<Owes>(alice); // bob no longer owes alice
if (!bob.Has<Owes>(eve)) { // the relation must be present to access it,
    bob.Add<Owes>(eve);    // exactly like any other component

bob.Ref<Owes>(eve).Amount += 7M;

Querying Relations

Relations can be queried like any other component. The Query method can be used to find all entities that have a relation to a specific target.

We can also query for all entities that have a relation to any target. Here's a variety of ways, ordered from most common to least common / performant. They all have their uses and semantics.

// Specific target, fixed query 
var entitiesOwingEve = world.Query<Owes>(eve).Compile();

// Wildcard target, fixed query
var entitiesOwingAnyone = world.Query<Owes>(Entity.Any).Compile();

// Wildcard target, specific exclusion, fixed query
var entitiesOwingAnyoneExceptEve = world

// All Entities, specific exclusion, fixed query
var entitiesExceptAnyOwingEve = world

// Any target, wildcard exclusion, fixed query
var entitiesThatAreDebtFree = world

// Wildcard target, specific exclusion, stream filter
var entitiesOwingAnyoneExceptEve = world
    .Stream() with // do this on-the-fly where needed
        Exclude = [Component.SpecificEntity<Owes>(eve)]  

Neofox: googly_reverseNeofox: think_googly DUDE, where's my ENTITY?

To query for Relations, you must either specify a concrete target Entity or use a wildcard:

  • Entity.Any (Relations)
  • Match.Target (Links and Relations)
  • Match.Any (anything, including relation-less plain components)

The following won't match:

var entities = world.Query<Owes>().Compile();

var entities = world.Query<Owes>(Match.Plain).Compile();

var entities = world.Query<Owes>(Match.Object).Compile();

Multi-Enumeration (Cross Join)

When a wildcard, like Entity.Any, is used as the Match expression, iterating a Stream view of the Query that outputs the component yield multiple instances of the same entity if it has multiple relations.

This is used to great effect in the 3-Body Problem staple.

var world = new World();
var debtors = world.Query<Owes>(Entity.Any).Stream();

var tom = world.Spawn();
var eve = world.Spawn();
var bob = world.Spawn(); //E-000000003:0001

bob.Add<Owes>(new(10M), eve);
bob.Add<Owes>(new(23M), tom);

// We use IEnumerable here, but it's the same for Stream.For/Job/Raw
foreach (var (Entity entity, Owes owes) in debtors) 
    Console.writeLine($"{entity} owes {owes.Amount} to someone!");
E-000000003:0001 owes 10 to someone!
E-000000003:0001 owes 23 to someone!

Neofox: what Wait, I owe money to "someone" ?!?

If it's important to know the target of a relation as it is being enumerated (bi-directional relation), you can add an Entity field in the backing component and store it there. This is a loosely coupled backlink (you can do with it what you want).

If it's only the Entity and Relation you care about, you can just make some records, but note that the backing data is still uncoupled here and adding can look awkward).

record struct Child(Entity ValueAndTarget);
record struct Parent(Entity ValueAndTarget);
record struct Uncle(Entity ValueAndTarget);

var me = world.Spawn();
var monkey = world.Spawn().Add<Uncle>(new(me), me); //this is how memes are born

fennecs is released under the MIT License. Neofox is released under the CC BY-NC-SA 4.0 License.