Cleanup Policies¶
What happens to a relation pair when the entity at one end of it despawns — controlled per-type by the
cleanupargument to the@Relationannotation. Three choices: release the pair, cascade the despawn, or do nothing.
The problem¶
Relations are typed edges between entities, so every pair has two liveness dependencies — one on the source and one on the target. When either entity dies, we have to decide what the pair means.
- For a
ChildOf(parent)pair, the natural answer is "the child dies with the parent." - For a
Hunting(prey)pair, the natural answer is "when the prey is caught and despawned, the predator forgets about it and moves on." - For a
LastSeen(entity)debug pair, the natural answer is "leave the record alone; we'll check liveness at read time."
All three are valid. The framework supports them as three named
policies on zzuegg.ecs.relation.CleanupPolicy.
The CleanupPolicy enum¶
And you select one on the payload record:
import zzuegg.ecs.relation.CleanupPolicy;
import zzuegg.ecs.relation.Relation;
@Relation(onTargetDespawn = CleanupPolicy.CASCADE_SOURCE)
public record ChildOf() {}
The default — applied to every @Relation that doesn't mention a
policy — is RELEASE_TARGET.
RELEASE_TARGET (default)¶
When the target of a pair is despawned, drop the pair. The source entity stays alive. The source observes the drop as a normal pair removal (change tracker updated,
RemovedRelations<T>log entry appended) and loses its source marker if it ran out of pairs of this type.
This is the right choice when pairs represent references — the
source cares about the target, but its own existence is not
coupled to the target's. Hunting, targeting, "last attacker",
"current destination", "mounted on" — all RELEASE_TARGET.
Every pair the despawning target participated in on the receiving
side is dropped. The source side of the pair survives; it just
stops pointing at the dead target. If another system is watching
RemovedRelations<Hunting>, it will see the removal event on its
next tick and can react to it (score a point, pick a new target,
etc).
CASCADE_SOURCE¶
When the target of a pair is despawned, despawn every source that was pointing at it. Cascades transitively — sources of sources are drained in FIFO order until the cascade queue is empty.
Use this for ownership relations: the source cannot logically outlive the target. Parent-child hierarchies, sub-entities of a composite object, equipment slots tied to a unit.
The World.despawn path maintains a FIFO queue of entities that
still need to be despawned. When it removes a cascade pair, it
enqueues the source for the next iteration. Because the queue is
drained to exhaustion, every transitive source reaches
despawnInternal — even if it itself was the target of a third
entity waiting on a ChildOf cascade. The isAlive check at the
top of each loop iteration silently skips entities that an earlier
cascade already killed, so you can't double-despawn.
Cycles in CASCADE_SOURCE
If your relation topology has a cycle — A has a ChildOf pair
pointing at B, and B has a ChildOf pair pointing at A — then
despawning either will cascade to the other. This is safe
(the isAlive guard prevents infinite loops and double frees)
but it may not be what you want. If your domain doesn't have
a strict parent→child acyclic constraint, consider using
RELEASE_TARGET and deciding liveness at read time.
The same applies to diamond topologies: if A → B and A → C and both B and C are cascade targets of D, despawning D despawns B and C, and each of those cascades A exactly once thanks to the alive check.
IGNORE¶
Do nothing. The pair stays in the store even though its target is gone. Reads can return payloads whose target entity is dead.
This is escape hatch / debug territory. Use it when you explicitly
want to keep a record of a dead reference — "this unit used to be
garrisoned in that destroyed building; show the wreckage icon
until we detach it manually." The user is responsible for checking
world.isAlive(pair.target()) before acting on the target.
How cleanup runs¶
The full cleanup state machine lives in World.despawnWithCascade
and World.applyRelationCleanup. At a high level:
despawn(entity)entry checks the fast path: if no relation type is registered, it jumps straight todespawnInternaland skips all the cleanup plumbing. The common case for component-only worlds pays nothing.- Otherwise, the entity is pushed onto a cascade queue and the loop begins.
- For each dequeued entity,
applyRelationCleanupwalks every registeredRelationStoreand:- drops every outgoing pair (the entity is the source), clearing target markers on each former target that just lost its last incoming pair,
- drops every incoming pair (the entity is the target),
respecting the store's
onTargetDespawnpolicy:IGNORE→ skip the store entirely,RELEASE_TARGET→ drop the pair and, if the source just lost its last outgoing pair, clear its source marker,CASCADE_SOURCE→ drop the pair and enqueue the source for despawn.
despawnInternalfrees the entity's archetype row; the markers it owned are dropped along with everything else in that row.- The loop moves to the next queued entity.
The cascade queue and the isAlive dedup make the process both
cycle-safe and deterministic: every entity reachable from the root
via CASCADE_SOURCE edges is despawned exactly once.
Worked example: parent-child with cascade¶
import zzuegg.ecs.entity.Entity;
import zzuegg.ecs.relation.CleanupPolicy;
import zzuegg.ecs.relation.Relation;
import zzuegg.ecs.world.World;
@Relation(onTargetDespawn = CleanupPolicy.CASCADE_SOURCE)
public record ChildOf() {}
World world = World.builder().build();
Entity root = world.spawn(new Name("root"));
Entity nodeA = world.spawn(new Name("A"));
Entity nodeB = world.spawn(new Name("B"));
Entity leaf = world.spawn(new Name("leaf"));
world.setRelation(nodeA, root, new ChildOf()); // A is child of root
world.setRelation(nodeB, root, new ChildOf()); // B is child of root
world.setRelation(leaf, nodeA, new ChildOf()); // leaf is child of A
// Tree:
//
// root
// / \
// A B
// |
// leaf
world.despawn(root);
// After despawn(root):
// * root is dead
// * A is dead (cascade via A -> root)
// * leaf is dead (cascade via leaf -> A, picked up in the 2nd loop)
// * B is dead (cascade via B -> root)
//
// Every ChildOf pair is gone from the store because its source
// was despawned in the same cascade pass.
The ordering isn't guaranteed between siblings — the FIFO queue
drains in insertion order, which depends on the order
applyRelationCleanup visits reverse-index slices. Don't write
game code that relies on sibling order during cascade; if you
need ordered teardown, emit commands from a pre-despawn observer
instead.
Worked example: hunting with release¶
import zzuegg.ecs.relation.CleanupPolicy;
import zzuegg.ecs.relation.Relation;
@Relation(onTargetDespawn = CleanupPolicy.RELEASE_TARGET)
public record Hunting(int ticksLeft) {}
// ... later, during catch resolution:
Entity predator = /* ... */;
Entity prey = /* ... */;
world.setRelation(predator, prey, new Hunting(3));
// The catch system decides this prey is caught:
world.despawn(prey);
// After despawn(prey):
// * prey is dead
// * predator is STILL ALIVE — Hunting is RELEASE_TARGET.
// * The Hunting pair is gone from the store.
// * predator's source marker for Hunting is cleared iff this was
// its only outgoing Hunting pair — the archetype is updated.
// * A RemovedRelations<Hunting> entry is appended for the next
// scoring system to drain. See the next chapter.
The predator can now acquire a new hunt on its next tick. Nothing
in the predator's own state is coupled to the prey's continued
existence — which is exactly the RELEASE_TARGET contract.
Choosing between them¶
A short decision tree:
- Does the source have any meaningful existence without the
target? If no —
CASCADE_SOURCE. If yes — continue. - Does the source want to observe the drop? If yes —
RELEASE_TARGETand subscribe aRemovedRelations<T>observer. If no —RELEASE_TARGETanyway; the drop is silent when nobody is watching. - Is the dead-target record itself the value you care about
(history, audit log)? Only then —
IGNORE. And even then, prefer a dedicated audit component.
What you learned¶
Recap
CleanupPolicypicks what happens to incoming pairs when an entity is despawned.RELEASE_TARGET(default) drops the pair and keeps the source alive, observed viaRemovedRelations<T>.CASCADE_SOURCEdespawns the source transitively — perfect for ownership relations likeChildOf.IGNOREleaves the pair in place; user code must check liveness on read.World.despawndrains cleanup FIFO with anisAliveguard, so cycles and diamonds are safe.
What's next¶
Next chapter
RELEASE_TARGET is only useful if something watches for the
drops. Next: RemovedRelations<T>,
the per-consumer watermarked observer that parallels
RemovedComponents<T> for pair drops.