Ark
Entity relationships

Entity relationships

In a basic ECS, relations between entities, like hierarchies, can be represented by storing entities in components. E.g., we could have a child component like this:

1type ChildOf struct {
2    Parent ecs.Entity
3}

Or, alternatively, a parent component with many children:

1type Parent struct {
2    Children []ecs.Entity
3}

In conjunction with component mappers, this is often sufficient. However, we are not able to leverage the power of queries to e.g. get all children of a particular parent in an efficient way.

To make entity relations even more useful and efficient, Ark supports them as a first class feature. Relations are added to and removed from entities just like components, and hence can be queried like components, with the usual efficiency. This is achieved by creating separate archetypes for relations with different target entities.

Relation components

To use entity relations, create components that have embedded an ecs.RelationMarker as their first member:

1type ChildOf struct {
2    ecs.RelationMarker
3}

That’s all to make a component be treated as an entity relationship by Ark. The component can contain further variables, but the marker must be the first one.

Creating relations

Most methods of MapX (e.g. ecs.Map2) provide var-args for specifying relationship targets. These are of type Relation, which is an interface with multiple implementations:

Rel is safe, but has some run-time overhead for component ID lookup.

RelIdx is fast but more error-prone.

See the examples below for their usage.

On new entities

When creating entities, we can use a MapX (e.g. ecs.Map2):

 1// Create a component Mapper
 2mapper := ecs.NewMap2[Position, ChildOf](&world)
 3
 4// Create a parent entity.
 5parent := world.NewEntity()
 6
 7// Create an entity with a parent entity, the slow way.
 8_ = mapper.NewEntity(&Position{}, &ChildOf{}, ecs.Rel[ChildOf](parent))
 9// Create an entity with a parent entity, the fast way.
10_ = mapper.NewEntity(&Position{}, &ChildOf{}, ecs.RelIdx(1, parent))

For the faster variant RelIdx, note that the first argument is the zero-based index of the relation component in the ecs.Map2’s generic parameters.

If there are multiple relation components, multiple Rel/RelIdx arguments can (and must) be used.

When adding components

Relation target must also be given when adding relation components to an entity:

 1// Create a component Mapper
 2mapper := ecs.NewMap2[Position, ChildOf](&world)
 3
 4// Create a parent entity.
 5parent := world.NewEntity()
 6// Create a child entity.
 7child := world.NewEntity()
 8
 9// Add components and a relation target to the child, the slow way.
10mapper.Add(child, &Position{}, &ChildOf{}, ecs.Rel[ChildOf](parent))
11
12// Add components and a relation target to an entity, the fast way.
13child2 := world.NewEntity()
14mapper.Add(child2, &Position{}, &ChildOf{}, ecs.RelIdx(1, parent))

Set and get relations

We can also change the target entity of an already assigned relation component. This is done via ecs.Map2.SetRelations et al.:

 1// Create a component Mapper
 2mapper := ecs.NewMap2[Position, ChildOf](&world)
 3
 4// Create parent entities.
 5parent1 := world.NewEntity()
 6parent2 := world.NewEntity()
 7
 8// Create an entity with a parent entity.
 9child := mapper.NewEntity(&Position{}, &ChildOf{}, ecs.RelIdx(1, parent1))
10
11// Change the child's parent.
12mapper.SetRelations(child, ecs.RelIdx(1, parent2))
13
14// Change the child's parent, the slow way.
15mapper.SetRelations(child, ecs.Rel[ChildOf](parent2))

Note that multiple relation targets can be changed in the same call.

Similarly, relation targets can be obtained with ecs.Map2.GetRelation et al.:

 1// Create a component Mapper
 2mapper := ecs.NewMap2[Position, ChildOf](&world)
 3
 4// Create a parent entity.
 5parent := world.NewEntity()
 6
 7// Create an entity with a parent entity.
 8child := mapper.NewEntity(&Position{}, &ChildOf{}, ecs.RelIdx(1, parent))
 9
10// Get a relation target by component index.
11parent = mapper.GetRelation(child, 1)

Note that, due to Go’s limitations on generics, the slow generic way is not possible here.

For a simpler syntax and when only a single relation component is accessed, ecs.Map can be used alternatively:

 1// Create a component Mapper
 2childMap := ecs.NewMap[ChildOf](&world)
 3
 4// Create parent entities.
 5parent1 := world.NewEntity()
 6parent2 := world.NewEntity()
 7// Create a child entity.
 8child := world.NewEntity()
 9
10// Add a component with a target parent entity.
11childMap.Add(child, &ChildOf{}, parent1)
12
13// Change the entity's parent.
14childMap.SetRelation(child, parent2)
15
16// Get the relation target.
17parent := childMap.GetRelation(child)
18_ = parent

Batch operations

All batch operation methods of MapX (e.g. ecs.Map2.NewBatch) can be used with relation targets just like the normal component operations shown above.

Filters and queries

Filters support entity relationships using the same syntax as shown in the examples above.

There are two ways to specify target entities to filter for: when building the filter, and when getting the query. Both ways can be combined.

Relation targets given via ecs.Map2.Relations when building a filter are best used for permanent or long-lived targets.

1// Create a filter with a relation target.
2filter := ecs.NewFilter2[Position, ChildOf](&world).
3	Relations(ecs.Rel[ChildOf](parent))
4
5// Get a query for iteration.
6query := filter.Query()
7// ...
8_ = query

With cached filters, the targets specified this way are included in the cache. For short-lived targets, it is better to pass them when building a query with ecs.Map2.Query

1// Create a filter.
2filter := ecs.NewFilter2[Position, ChildOf](&world)
3
4// Get a query with a relation target.
5query := filter.Query(ecs.RelIdx(1, parent))
6// ...
7_ = query

These targets are not cached, but the same filter can be used for different targets.

Filters also support both Rel and RelIdx. In the filter examples above, we used the slow but safe Rel when building the filter. When getting the query, we use the faster RelIdx, because in real-world use cases this is called more frequently than the one-time filter construction.

Relation targets not specified by the filter are treated as wildcard. This means that the filter matches entities with any target.

Dead target entities

Entities that are the target of any relationships can be removed from the world like any other entity. When this happens, all entities that have this target in a relation get assigned to the zero entity as target. The respective archetype is de-activated and marked for potential re-use for another target entity.

When to use, and when not

When using Ark’s entity relations, an archetype is created for each target entity of a relation. Thus, entity relations are not efficient if the number of target entities is high (tens of thousands), while only a low number of entities has a relation to each particular target (less than a few dozens). Particularly in the extreme case of 1:1 relations, storing entities in components as explained in the introduction of this chapter is more efficient.

However, with a moderate number of relation targets, particularly with many entities per target, entity relations are very efficient.

Beyond use cases where the relation target is a “physical” entity that appears in a simulation or game, targets can also be more abstract, like categories. Examples:

  • Different tree species in a forest model.
  • Behavioral states in a finite state machine.
  • The opposing factions in a strategy game.
  • Render layers in a game or other graphical application.

This concept is particularly useful for things that would best be expressed by components, but the possible components (or categories) are only known at runtime. Thus, it is not possible to create ordinary components for them. However, these categories can be represented by entities, which are used as relation targets.

Longer example

To conclude this chapter, here is a longer example that uses Ark’s entity relationships feature to represent animals of different species in multiple farms.

  1package relations
  2
  3import (
  4	"fmt"
  5	"math/rand/v2"
  6
  7	"github.com/mlange-42/ark/ecs"
  8)
  9
 10// ####################### Components ###########################
 11
 12// Farm component.
 13type Farm struct{ ID int }
 14
 15// Weight component for animals.
 16type Weight struct{ Kilograms float64 }
 17
 18// IsInFarm component for animals.
 19type IsInFarm struct{ ecs.RelationMarker }
 20
 21// IsOfSpecies component for animals.
 22type IsOfSpecies struct{ ecs.RelationMarker }
 23
 24func main() {
 25
 26	// ####################### Preparations ###########################
 27
 28	world := ecs.NewWorld()
 29
 30	// Create a component mapper for farms.
 31	farmMap := ecs.NewMap1[Farm](&world)
 32	// Create a component mapper for farm animals.
 33	animalMap := ecs.NewMap3[Weight, IsInFarm, IsOfSpecies](&world)
 34
 35	// Create a filter for farms.
 36	farmFilter := ecs.NewFilter1[Farm](&world)
 37	// Create a filter for farm animals.
 38	animalFilter := ecs.NewFilter3[Weight, IsInFarm, IsOfSpecies](&world)
 39
 40	// ####################### Initialization ###########################
 41
 42	// Create species.
 43	cow := world.NewEntity()
 44	pig := world.NewEntity()
 45
 46	// Create farms.
 47	farms := []ecs.Entity{}
 48	for i := range 10 {
 49		farm := farmMap.NewEntity(&Farm{i})
 50		farms = append(farms, farm)
 51	}
 52
 53	// Populate farms.
 54	for _, farm := range farms {
 55		// How many animals?
 56		numCows := rand.IntN(50)
 57		numPigs := rand.IntN(200)
 58
 59		// Create cows.
 60		animalMap.NewBatch(numCows, // How many?
 61			&Weight{500}, &IsInFarm{}, &IsOfSpecies{}, // Initial values.
 62			ecs.Rel[IsInFarm](farm),   // This farm.
 63			ecs.Rel[IsOfSpecies](cow), // Species cow.
 64		)
 65		// Create pigs.
 66		animalMap.NewBatch(numPigs, // How many?
 67			&Weight{100}, &IsInFarm{}, &IsOfSpecies{}, // Initial values.
 68			ecs.Rel[IsInFarm](farm),   // This farm.
 69			ecs.Rel[IsOfSpecies](pig), // Species pig.
 70		)
 71	}
 72
 73	// ####################### Logic in systems ###########################
 74
 75	// Do something with all pigs.
 76	query := animalFilter.Query(ecs.Rel[IsOfSpecies](pig))
 77	for query.Next() {
 78		weight, _, _ := query.Get()
 79		weight.Kilograms += rand.Float64() * 10
 80	}
 81
 82	// Print total weight of the pigs in each farm.
 83	// Iterate farms.
 84	farmQuery := farmFilter.Query()
 85	for farmQuery.Next() {
 86		farm := farmQuery.Get()
 87		farmEntity := farmQuery.Entity()
 88
 89		totalWeight := 0.0
 90
 91		// Iterate pigs in the farm.
 92		animalQuery := animalFilter.Query(
 93			ecs.Rel[IsInFarm](farmEntity), // This farm.
 94			ecs.Rel[IsOfSpecies](pig),     // Pigs only.
 95		)
 96		for animalQuery.Next() {
 97			weight, _, _ := animalQuery.Get()
 98			totalWeight += weight.Kilograms
 99		}
100		// Print the farm's result.
101		fmt.Printf("Farm %d: %.0fkg\n", farm.ID, totalWeight)
102	}
103}

Note that this examples uses the safe and clear, but slower generic variant to specify relationship targets. As an optimization, RelIdx could be used instead of Rel, particularly for queries.