On this page
  1. Relationships
  2. Defining a relationship
  3. Exclusive relationships
  4. Dense and sparse storage
  5. Reverse lookup and traversal
  6. Removal and target lifetime
  7. Module contents
    1. Submodules

Relationships

A relationship connects one entity to another. Tecs ships ChildOf, an exclusive sparse relationship with a reverse index and cascade delete:

local ChildOf <const> = tecs.ecs.ChildOf

local parent <const> = world:spawn(tecs.Transform2D(100, 100))
local child <const> = world:spawn(
    ChildOf(parent), tecs.ecs.RelativeTransform2D(16, 0)
)

local link <const> = world:getFirstRelationship(child, ChildOf)
print(link.target) -- parent

world:despawn(parent) -- also despawns child

Relationships use the component API. Pass an instance to world:set or world:remove, include the relationship in a query, and read it from an entity or archetype.

Defining a relationship

newRelationship creates either a target-only relationship or a relationship with a Lua payload. Use newFFIRelationship when the payload belongs in a packed C struct.

This target-only relationship lets an entity like several targets:

local Likes: tecs.ecs.Relationship = tecs.ecs.newRelationship({
    name = "Likes",
})

world:set(alice, Likes(bob))
world:set(alice, Likes(carol))

A relationship with data declares the payload fields after the target:

local record Follows is tecs.ecs.Relationship
    delay: number
    maxDistance: number

    metamethod __call: function(
        self, target: integer, delay?: number, maxDistance?: number
    ): Follows
end

tecs.ecs.newRelationship({
    name = "Follows",
    container = Follows,
    fields = {"delay", "maxDistance"},
    defaults = {0.5, 100},
})

world:set(follower, Follows(leader, 0.25, 50))
world:set(
    follower,
    Follows.new({
        target = leader,
        delay = 0.25,
        maxDistance = 50,
    })
)

The target always comes first in the positional form and lives under target in the table form. Tecs owns target; treat it as read-only and replace an edge through world:set instead of changing the field. Do not include "target" in fields. Component construction covers fields, defaults, init, custom __call, and .new.

Exclusive relationships

A relationship normally allows several targets on one source. Setting the same target again replaces that edge's value.

Set exclusive = true when each source may name only one target:

local Targets: tecs.ecs.Relationship = tecs.ecs.newRelationship({
    name = "Targets",
    exclusive = true,
})

world:set(enemy, Targets(player))
world:set(enemy, Targets(decoy)) -- replaces Targets(player)

world:getFirstRelationship(entity, Targets) returns the edge. For an exclusive relationship, it returns the only edge.

Dense and sparse storage

A dense relationship creates a component type for each target. Entities that point at different targets occupy different archetypes. That layout supports a target-specific query:

local followersOfLeader <const> = world:newQuery({
    include = {Follows:targeting(leader)},
})

Use dense storage when systems often query one target and the target set stays small.

sparse = true keeps targets in entity-indexed side storage. Every source shares the relationship's wildcard component in its archetype, so a large target set does not fragment the world. ChildOf uses this layout.

local children <const> = world:newQuery({
    include = {ChildOf, tecs.Transform2D},
})

for archetype, length in children:iter() do
    local parents <const> = archetype:get(ChildOf)
    for row = 1, length do
        print(parents[row].target)
    end
end

The sparse column proxy supports row reads only. Tecs owns its target values; change an edge through world:set. Sparse relationships do not expose targeting; filter the proxy inside the loop or use a reverse index.

For either layout:

  • world:getFirstRelationship(entity, Relationship) returns an arbitrary edge, and the only edge for an exclusive relationship.
  • world:get(entity, Relationship(target)) selects one target.
  • world:has(entity, Relationship) checks for any target.
  • world:has(entity, Relationship(target)) checks one target.
  • A query that includes the bare relationship matches any target.

Callers may mutate a dense payload through getMut. Sparse payloads belong to the side store; replace those edges through world:set.

Reverse lookup and traversal

Set reverseIndex = true when code needs to find the sources that point at a target. Both dense and sparse relationships support the index.

world:targets(
    parent,
    ChildOf,
    function(childId: integer) print("child", childId) end
)

for depth, entityId in world:traverse(root, ChildOf) do
    print(depth, entityId)
end

world:targets accepts a context value and passes it as the callback's second argument. A hoisted callback and reused context avoid a closure allocation:

local countContext = {count = 0}

local function countChild(_childId: integer, context: typeof(countContext))
    context.count = context.count + 1
end

countContext.count = 0
world:targets(parent, ChildOf, countChild, countContext)

world:walkUp follows forward edges, so it needs an exclusive relationship but not a reverse index:

world:walkUp(
    entity,
    ChildOf,
    function(ancestorId: integer, depth: integer)
        print(depth, ancestorId)
    end
)

The callback may return false to stop. The optional maxDepth defaults to 100 and turns a cycle into an error instead of an infinite walk.

Removal and target lifetime

Pass an instance to remove one target:

world:remove(alice, Likes(bob))

Passing a sparse relationship container removes all its targets from the source. Removing an edge never triggers cascade delete, so reparenting can remove or replace ChildOf without despawning the child.

cascadeDelete = true makes target despawn recursively despawn every source. It requires both exclusive = true and reverseIndex = true.

A reverse index also lets target despawn unlink ordinary edges. Without one, Tecs has no inverse to consult; world:compact() later prunes unreachable archetypes whose targets have died.

Relationship(target) interns one weakly held instance per target. Dense storage registers that instance as its target-specific component. Sparse storage uses it as an edge value and keeps the payload in the world's side store.

Snapshots write target plus every declared payload field. Set transient = true for an edge that must not survive a snapshot.

Module contents

Submodules

Submodule Description
FFI relationships FFI struct-backed relationships via newFFIRelationship with packed field types and target semantics