Skip to content

Relationships

Relationships model directed connections between entities, such as parent-child hierarchies, following behaviors, or targeting. Tecs manages their lifecycle automatically, preserving referential integrity when entities are despawned.

Relationship features

  • Automatic cleanup: When you despawn a target entity, Tecs automatically removes all relationships pointing to it
  • Type safety: Relationships are strongly typed in Teal, providing compile-time guarantees
  • Efficient queries: Query for entities with specific relationships or wildcard relationships
  • Flexible data: Relationships can store just the target or include additional data about the connection
  • Sparse storage: Relationships can opt into sparse storage to avoid per-target archetype fragmentation
  • Cascade delete: Declarative cascade deletion for hierarchies like parent-child

Kinds of relationships

newRelationship is the single entry point. What you pass decides the storage:

  • Target-only (newRelationship({name = "..."}), no fields): the edge carries nothing but the target id. Backed by a compact FFI struct automatically.
  • Data-bearing, Lua payload (newRelationship with a container and fields): the edge carries fields stored in a Lua table, so they can hold any Lua value.
  • Data-bearing, FFI payload (newFFIRelationship): the edge carries fields packed into an FFI struct for tight, cache-friendly storage.

Creating simple relationships

A simple relationship stores only the target entity id, with no extra data on the edge. Create one by calling newRelationship with just a name:

teal
local tecs = require("tecs")

-- "entity A Likes entity B": nothing is recorded but the target.
local Likes: tecs.Relationship = tecs.newRelationship({name = "Likes"})

-- Same target-only shape, but exclusive: one target at a time, so setting a
-- new target replaces the old one.
local Targets: tecs.Relationship = tecs.newRelationship({
    name = "Targets",
    exclusive = true
})

A target-only relationship is backed by a compact FFI struct. Behavior flags still apply: exclusive, sparse, reverseIndex, and cascadeDelete all work here (see Exclusive, Sparse, and Cascade delete below). Once the edge needs to carry data (a delay, a weight), add a container with fields as shown next, or use FFI Relationships.

Applying relationships

Apply relationships like any other component, because they are components!

teal
-- Entity A likes entities B and C (multiple targets)
world:set(entityA, Likes(entityB))
world:set(entityA, Likes(entityC))

-- Entity A targets a single enemy (exclusive: one target at a time)
world:set(entityA, Targets(enemy))

Creating relationships with data

Sometimes relationships need to carry additional information about the connection. For example, a "following" relationship might specify a delay.

First, define a Teal record that implements tecs.Relationship:

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

    --- Relationship creation. __call takes the target first; .new takes a
    --- config table with a target key.
    metamethod __call: function(
        self,
        target: integer,
        delay: number,
        maxDistance: number
    ): self
end

Then create the relationship component. Tecs writes target onto the returned instance for you; you don't store it in fields.

Use fields (with optional defaults) to declare the data fields. The first positional argument is always the target entity ID; the remaining arguments map to fields in order:

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

You now have both a positional and a table construction form:

teal
-- Positional: target first, then fields in order
world:set(follower, Follows(target, 0.3, 50))

-- Table form: target is a key alongside the data
world:set(follower, Follows.new({ target = target, delay = 0.3, maxDistance = 50 }))

See Component Construction for the shared fields / defaults / init / .new rules these inherit.

Exclusive relationships

By default, an entity can have multiple instances of the same relationship type pointing to different targets. Each unique target gets its own entry. For example, an entity can "like" multiple other entities:

teal
world:set(entity, Likes(entityA))
world:set(entity, Likes(entityB))

Setting the same target again replaces the value for that target:

teal
world:set(entity, Likes(entityA))  -- replaces the previous Likes(entityA)

Some relationships are marked as exclusive, meaning an entity can only have one target at a time. A combat AI that Targets a single enemy is a natural fit:

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

When an exclusive relationship is set, any existing target is automatically replaced. Setting Targets(enemy2) on an entity that already has Targets(enemy1) replaces it.

Sparse relationships

By default, each unique target creates a distinct component type, which means entities with different targets end up in different archetypes. This is efficient for querying specific targets but causes archetype fragmentation when many distinct targets exist (e.g., a UI hierarchy with hundreds of parents).

The sparse flag stores relationship data in entity-indexed side storage instead of archetype columns. All entities with the same sparse relationship share the same archetype regardless of their target:

teal
local ChildOf: tecs.Relationship = tecs.newRelationship({
    name = "ChildOf",
    exclusive = true,
    sparse = true,
    reverseIndex = true,    -- maintain inverse index for world:targets()
    cascadeDelete = true,   -- despawning parent despawns children
})

Sparse relationships work seamlessly with queries. A dense wildcard tag is always added to the archetype, so query({include = {ChildOf}}) matches entities with any ChildOf target. Accessing relationship data within a query uses the same row-indexed pattern as dense columns:

teal
local query: tecs.Query = world:query({include = {ChildOf, Transform}})

for archetype, len in query:iter() do
    local children = archetype:get(ChildOf)      -- row-indexed proxy
    local transforms = archetype:get(Transform)  -- dense column
    for row = 1, len do
        local childOf: ChildOf = children[row]        -- same pattern as dense access
        local parentId: integer = childOf.target
        -- ...
    end
end

You can also look up sparse relationship data per entity:

teal
-- Get the ChildOf relationship for a specific entity.
local childOf: ChildOf = world:get(entity, ChildOf)
if childOf then
    print("Parent is:", childOf.target)
end

-- Check a specific target.
local rel: ChildOf = world:get(entity, ChildOf(someParent))

Querying relationships

Query relationships like any other component, but with additional flexibility.

Query for any relationship (wildcard)

Find all entities that have any instance of a relationship type, regardless of target. This works for both dense and sparse relationships:

teal
-- Find all entities that are children of any parent
local allChildren: tecs.Query = world:query({
    include = {tecs.builtins.ChildOf}
})

-- Find all entities that follow something
local followers: tecs.Query = world:query({
    include = {Follows}
})
How this works

When a relationship is added to an entity, Tecs adds a "wildcard" tag (the relationship container) as a dense archetype component. This enables efficient wildcard queries regardless of whether the relationship is sparse or dense.

Query for specific target (dense relationships)

For dense relationships, find all entities with a relationship to a specific target using the :targeting() method:

teal
-- Find all followers of a specific leader
local query: tecs.Query = world:query({
    include = {Follows:targeting(leader)}
})

Sparse relationships don't support :targeting() queries

Sparse relationships don't create per-target archetype components, so :targeting() is not available. Use world:targets() for reverse lookups or world:get(entity, Rel(target)) for forward lookups.

Filtering queries by relationships

You can combine relationships with other components in a single query. The query matches entities that have all the specified components, and you read both component data and relationship data row-by-row.

Example: render only entities that have a parent

teal
local query: tecs.Query = world:query({
    include = {Sprite, Transform, ChildOf}
})

for archetype, len, entities in query:iter() do
    local sprites = archetype:get(Sprite)
    local transforms = archetype:get(Transform)
    local parents = archetype:get(ChildOf)  -- works for both sparse and dense

    for row = 1, len do
        local sprite: Sprite = sprites[row]
        local transform: Transform = transforms[row]
        local parentId: integer = parents[row].target
        -- ...
    end
end

This is the most efficient way to iterate entities with both components and relationships: the wildcard tag on the archetype narrows iteration to matching entities, and column-bound access avoids per-entity lookups.

Example: filter to a specific target (dense relationships)

For dense relationships, use :targeting() to narrow the archetype set to entities targeting one specific entity:

teal
-- Find all sprites that follow a specific leader
local query: tecs.Query = world:query({
    include = {Sprite, Transform, Follows:targeting(leader)}
})

For sparse relationships, this isn't supported (no per-target archetypes). Filter inside the loop instead:

teal
local query: tecs.Query = world:query({include = {Sprite, ChildOf}})

for archetype, len in query:iter() do
    local sprites = archetype:get(Sprite)
    local parents = archetype:get(ChildOf)
    for row = 1, len do
        if parents[row].target == specificParent then
            -- ...
        end
    end
end

If you need fast "find all entities targeting X" lookups for a sparse relationship, use world:targets() instead of a query.

Example: exclude entities that have a relationship

Use the wildcard in exclude to find entities WITHOUT a relationship:

teal
-- Find root entities (entities with Transform but no parent)
local query: tecs.Query = world:query({
    include = {Transform},
    exclude = {ChildOf}
})

Accessing relationship data in queries

For dense relationships, access relationship data from each archetype:

teal
for archetype, len, entities in query:iter() do
    for row = 1, len do
        archetype:forEachRelationship(Follows, row, function(follow: Follows)
            print(string.format("Entity %d follows %d", entities[row], follow.target))
        end)
    end
end

For sparse relationships, use the row-indexed column proxy:

teal
for archetype, len in query:iter() do
    local children = archetype:get(ChildOf)
    for row = 1, len do
        local childOf: ChildOf = children[row]
        if childOf then
            print("Parent:", childOf.target)
        end
    end
end

Cascade delete

The cascadeDelete flag causes all source entities to be automatically despawned when their target is despawned. This is how ChildOf implements parent-child hierarchies: despawning a parent automatically despawns all children (and grandchildren, recursively).

teal
local ChildOf: tecs.Relationship = tecs.newRelationship({
    name = "ChildOf",
    exclusive = true,
    sparse = true,
    reverseIndex = true,
    cascadeDelete = true,
})

local parent: integer = world:spawn()
local child: integer = world:spawn(ChildOf(parent))
local grandchild: integer = world:spawn(ChildOf(child))

-- Despawning parent cascades through the hierarchy.
world:despawn(parent)
world:update(0)
-- parent, child, and grandchild are all despawned.

Removing a relationship (without despawning the target) does NOT trigger cascade delete. This allows safe reparenting:

teal
-- Orphan the child (remove relationship, keep both entities alive)
world:remove(child, ChildOf)

-- Reparent (exclusive replaces old target, no cascade)
world:set(child, ChildOf(newParent))

cascadeDelete requires exclusive = true and reverseIndex = true. It works for both sparse and dense relationships.

Hierarchy traversal

Relationships with reverseIndex = true maintain an inverse index that enables efficient reverse lookups. This works for both sparse and dense relationships; the only difference is where forward data is stored.

world:targets

Invokes a callback once for every source entity targeting the given entity:

teal
-- Get all children of a parent.
world:targets(parent, ChildOf, function(childId: integer)
    print("Child:", childId)
end)

world:targets accepts an optional context value that is forwarded to the callback as its second argument. This lets you hoist the visitor function to module/plugin scope and keep its accumulator state on the context table, avoiding per-call closure allocation in hot paths:

teal
-- Hoisted once at plugin scope.
local visitorCtx: {count: integer} = {count = 0}
local function countChild(_childId: integer, ctx: {count: integer})
    ctx.count = ctx.count + 1
end

-- Per call: zero new closures.
visitorCtx.count = 0
world:targets(parent, ChildOf, countChild, visitorCtx)

Callbacks declared with a single parameter (function(childId: integer)) also work; Lua silently drops the extra context argument when the callback doesn't declare it.

world:traverse

Depth-first traversal over the full subtree of an entity:

teal
-- Walk the entire hierarchy under root.
for depth, entityId in world:traverse(root, ChildOf) do
    local indent: string = string.rep("  ", depth)
    print(indent .. "Entity:", entityId)
end

world:walkUp

Walks up a relationship chain from an entity to its root, calling a callback for each ancestor. Follows the first target per level (equivalent to repeated getFirstRelationship), so semantics match exclusive relationships like ChildOf exactly:

teal
-- Print every ancestor of `entity`
world:walkUp(entity, ChildOf, function(ancestorId: integer, depth: integer)
    print(depth, ancestorId)
end)

Like world:targets, an optional context is forwarded to the callback (third argument):

teal
-- Sum scroll offsets up the parent chain, no closure allocation
local accum = {x = 0.0, y = 0.0, offsets = scrollOffsets}
local function sumOffset(ancestorId: integer, _depth: integer, ctx: typeof(accum))
    local off = ctx.offsets[ancestorId]
    if off then
        ctx.x = ctx.x + off[1]
        ctx.y = ctx.y + off[2]
    end
end

world:walkUp(entity, ChildOf, sumOffset, accum)

The callback may return false to stop the walk early; any other return value (including nil or no return) continues. The optional maxDepth parameter (default 100) is a safety cap; exceeding it raises an error so accidental cycles surface immediately rather than silently truncating.

All three methods require a relationship with reverseIndex = true (sparse or dense).

Removing relationships

Remove a specific target with the relationship instance:

teal
-- Remove Likes(entityB) but keep Likes(entityA)
world:remove(entity, Likes(entityB))

Remove all targets with the relationship container:

teal
-- Remove ALL Likes relationships from entity
world:remove(entity, Likes)

When the last relationship of a type is removed from an entity, the entity leaves queries that include that relationship.

Configuration reference

The tecs.newRelationship function accepts a configuration table with these fields:

PropertyDescription
nameRequired - The name of the relationship
exclusiveWhether only one target can exist per entity (default: false)
sparseUse entity-indexed storage instead of per-target archetype components (default: false)
reverseIndexMaintain an inverse index for world:targets(), world:traverse(), and world:walkUp() (works on sparse or dense)
cascadeDeleteDespawning the target despawns all source entities. Requires exclusive and reverseIndex.
containerType for relationships with data. If omitted, creates a simple relationship.
fieldsOrdered field names for the generated positional base shape.
defaultsDefault values for fields, in matching order (nil = no default). Requires fields.
initOptional hook to validate or refine a relationship instance after allocation. Requires container, plus fields or new.
transientIf true, omit this relationship from snapshots. Mutually exclusive with serialize.

To run code when a relationship is added or removed, create a query on the relationship (or its wildcard container) and attach onEntitiesAdded / onEntitiesRemoved.

Relationship API methods

All relationships provide these methods:

  • Relationship(targetId, ...) - Creates a relationship instance targeting the specified entity
  • Relationship:targeting(targetId) - Returns the component for target-specific queries (dense relationships only)

World methods for relationships with reverseIndex = true:

  • world:targets(entity, Relationship, callback, context?) - Invokes callback(sourceId, context) for each source entity targeting entity. context is optional and forwarded to the callback unchanged.
  • world:traverse(root, Relationship) - DFS iterator yielding (depth, entityId) for the full subtree
  • world:walkUp(entity, Relationship, callback, context?, maxDepth?) - Walks up the parent chain from entity, invoking callback(ancestorId, depth, context) for each ancestor. Return false from the callback to stop early. maxDepth defaults to 100; exceeding it raises an error.

Data fields and initialization

Relationships follow the same positional-plus-table pattern as regular components. See Component Construction for the full shared rules (fields / defaults / init / new, validation, and when to reach for each). A few details are relationship-specific:

  • Target is always required. Every relationship carries a target entity id, written onto the instance by Tecs. In the positional form it's the first argument (Rel(targetId, ...)); in the table form it's the target key (Rel.new({ target = id, ... })).
  • fields lists data fields only. Don't include "target"; it's implicit and set by the framework. Positional args after the target map to fields in order.
  • init signature starts with instance, targetId. function(instance, targetId, field1, field2, ...). When paired with fields, it refines the allocated instance while fields drives the base positional mapping and .new.

Reusing the Follows relationship from Creating relationships with data (fields = {"delay", "maxDistance"}, defaults = {0.5, 100}), positional arguments fill fields left to right and defaults cover any omitted:

teal
-- Positional: target first, then fields in order
Follows(target)          -- {delay = 0.5, maxDistance = 100, target = …}
Follows(target, 0.2)     -- {delay = 0.2, maxDistance = 100, target = …}
Follows(target, 0.2, 50) -- {delay = 0.2, maxDistance = 50, target = …}

-- Table form names them instead
Follows.new({ target = target, delay = 0.2, maxDistance = 50 })

fields and init can coexist. Use a custom init hook when you need non-trivial initialization (validation, derived values, external resources); pair it with fields so .new still codegens:

teal
local record CustomRel is tecs.Relationship
    customData: string
    metamethod __call: function(self, target: integer, customData: string): CustomRel
end

tecs.newRelationship({
    name = "CustomRel",
    container = CustomRel,
    fields = {"customData"},
    init = function(instance: CustomRel, _targetId: integer, customData: string)
        assert(customData, "CustomRel requires customData")
        instance.customData = customData
    end,
})

Snapshot deserialize for relationships routes through .new(data) where data.target is the restored target id, identical in shape to the table form above.

Relationship lifecycle

Automatic cleanup

When you despawn an entity, Tecs automatically removes all relationships pointing to it. For sparse relationships with cascadeDelete, source entities are also despawned recursively.

Dense relationship cleanup

For dense relationships, when a target entity is despawned, Tecs removes the relationship components from all source entities. To run cleanup logic, attach onEntitiesRemoved to a query whose include contains the relationship (or its wildcard container); the callback fires for each archetype that loses members.