RemovedRelations<T>¶
Per-system, watermark-drained access to pair-removal events. Declare
RemovedRelations<T>as a system parameter and the framework hands you every pair of typeTthat was dropped since this system last ran — same pattern asRemovedComponents<T>, but keyed on pair identity instead of component id.
When you need it¶
A relation is a typed edge. Dropping an edge is meaningful — sometimes as meaningful as creating one. Common reactions to pair removal:
- Scoring. A
Huntingpair disappears when a prey is caught. A scoring system drains the removal log and increments a counter per event. - Cache invalidation. You have a
PairKey-indexed cache of derived data (e.g. path-finding results between source and target). When the pair goes, flush the cache entry. - Notification. UI overlay that highlights "X is targeting Y" has to drop the overlay when the targeting relation ends — by despawn, by manual removal, or by a policy cascade.
- Resource release. The source had a reservation held against the target; when the pair ends, release the reservation.
All of these would be clumsy to write on top of the
World.setRelation / removeRelation call sites, because the
calls come from many systems and some removals happen implicitly
via cleanup policies in the despawn path. RemovedRelations<T> is
the one place every drop is visible.
Requesting the observer¶
Add a parameter of type RemovedRelations<T> to any @System
method, same as you would with RemovedComponents<T>:
import zzuegg.ecs.relation.RemovedRelations;
import zzuegg.ecs.resource.ResMut;
import zzuegg.ecs.system.System;
public record Score(long kills) {}
public static class Scoring {
@System(stage = "PostUpdate")
public void tallyCatches(
RemovedRelations<Hunting> dropped,
ResMut<Score> score
) {
if (dropped.isEmpty()) return;
for (var event : dropped) {
// event.source() — the predator
// event.target() — the prey (may be dead)
// event.lastValue() — the Hunting payload at the instant of drop
score.get(); // bump score
}
}
}
The iterator is single-use per tick. Iterating once consumes the
current window; the system's watermark advances on return so
consecutive ticks observe disjoint removal windows. If a RunIf
or a disabled stage causes this system to skip a tick, the
watermark does not advance — the next run sees everything
since the last real execution, so observers can't miss removals.
The Removal<T> record¶
public interface RemovedRelations<T extends Record> extends Iterable<RemovedRelations.Removal<T>> {
record Removal<T extends Record>(Entity source, Entity target, T lastValue) {}
Iterator<Removal<T>> iterator();
List<Removal<T>> asList();
boolean isEmpty();
}
Each Removal carries:
source()— the source entity of the dropped pair. Often still alive (in theRELEASE_TARGETcase, the normalremoveRelationcase, and theremoveAllRelationscase). May be dead if the source entity was itself despawned.target()— the target entity. Often dead, especially when the removal came from the cleanup policy path.lastValue()— the payload record as it existed in the store the instant the pair was dropped. Capturing this means a scoring or audit system can read the final state without having to peek into the store before the drop.
Don't assume either entity is alive
The only thing Removal guarantees is that the pair was
live recently. For pairs dropped by a despawn cleanup, the
target is always dead and the source is dead if the policy was
CASCADE_SOURCE. Always gate world.isAlive before doing
anything with the entities beyond reading the payload.
Removal sources tracked¶
Every code path that drops a pair feeds the per-type
PairRemovalLog, so a RemovedRelations<T> observer sees all
removals regardless of origin:
| Call site | Tracked? |
|---|---|
World.removeRelation(source, target, T) |
Yes |
World.removeAllRelations(source, T) |
Yes |
Commands.removeRelation(...) (flushed via CommandProcessor) |
Yes |
World.despawn(entity) → cleanup policy drops a pair |
Yes |
World.despawn(entity) with CASCADE_SOURCE |
Yes |
RelationStore.remove(source, target, tick) directly |
Yes |
RelationStore.remove(source, target) (tick-less) |
No — untracked sentinel |
The tick-less set / remove overloads on RelationStore are
reserved for unit tests that want to seed state without polluting
the change tracker. Application code never calls them directly.
Per-consumer watermarks¶
The retention model is deliberately pay-as-you-go:
PairRemovalLog.appendis a no-op unless at least one consumer has registered. Worlds with noRemovedRelations<T>observers pay zero memory for the log — the write side short-circuits onconsumerCount == 0.- When a consumer is present, every drop is appended as
(source, target, lastValue, tick). - Each
RemovedRelations<T>system parameter is bound to aSystemExecutionPlan. On iteration, the backingRemovedRelationsImplreads the log withplan.lastSeenTick()as an exclusive lower bound — it returns only entries whose tick is strictly greater. - At end-of-tick,
Worldadvances the per-type minimum watermark across all plans that consume this relation type, andPairRemovalLog.collectGarbagedrops entries at or below that watermark. Plans that never ran this tick (disabled,RunIf == false) hold the log back with their old watermark, so their next real run still observes every drop since the last time they actually fired.
The net effect: every consumer observes every drop exactly once, in tick order, even when dispatch is irregular.
Worked example: GC a derived-data cache¶
Suppose you maintain a cache of path-finding results keyed by
PairKey. The cache entries are expensive to recompute, so you
only want to drop one when the underlying pair goes away.
import zzuegg.ecs.relation.PairKey;
import zzuegg.ecs.relation.RemovedRelations;
import zzuegg.ecs.resource.ResMut;
import zzuegg.ecs.system.System;
import java.util.HashMap;
public final class PathCache {
public final HashMap<PairKey, int[]> entries = new HashMap<>();
}
@Relation
public record Pursuing() {}
@System(stage = "PostUpdate")
public void evictDeadPaths(
RemovedRelations<Pursuing> dropped,
ResMut<PathCache> cache
) {
if (dropped.isEmpty()) return;
var table = cache.get().entries;
for (var event : dropped) {
table.remove(new PairKey(event.source(), event.target()));
}
}
PairKey (zzuegg.ecs.relation.PairKey) is the record
(Entity source, Entity target) that the change tracker and
removal log already use internally — it's reusable in your own
data structures because Entity equality carries generation, so
stale entity slots never collide with fresh ones.
Worked example: score on drop¶
import zzuegg.ecs.relation.RemovedRelations;
import zzuegg.ecs.resource.ResMut;
import zzuegg.ecs.system.System;
public final class Counters {
public long hunts;
public long catches;
}
@System(stage = "PostUpdate")
public void scoreHunts(
RemovedRelations<Hunting> dropped,
ResMut<Counters> counters
) {
var c = counters.get();
for (var event : dropped) {
// Every dropped Hunting is one completed (or abandoned) hunt.
c.hunts++;
// A catch is a drop whose target has also died this tick.
// event.target() might not be alive anymore, so we can
// distinguish catches from abandoned hunts without touching
// the prey's component data at all.
}
}
This is the pattern used by the predator-prey worked example in
the next chapter: RELEASE_TARGET drops the Hunting pair when
the prey is caught and despawned, and a scoring system drains
RemovedRelations<Hunting> during PostUpdate to keep the tally.
Why not a callback?¶
Callback-driven observers look tempting but have two failure modes:
- Order coupling. If the observer runs inline with the drop, it sees partial world state — some cleanup policies have run, others haven't. The watermark drain model sees a consistent view because it runs in its own system in its own stage.
- Re-entrancy. An inline callback can call back into
setRelation/removeRelationfrom inside the cleanup loop, which mutates the store mid-iteration. The watermark drain is intrinsically deferred — the observer runs when the scheduler schedules it, not inside the despawn path.
RemovedRelations<T> also parallels RemovedComponents<T>
one-to-one. If you already know one, you know the other.
What you learned¶
Recap
RemovedRelations<T>is a per-consumer, watermark-drained view of every pair of typeTdropped since the last run.- Each
Removal<T>carries the source, target, and the payload at the instant of drop — entities may or may not still be alive. - The log short-circuits when no consumer is registered, so worlds with no observers pay nothing.
- Use it for scoring, cache invalidation, and any system that reacts to the disappearance of a relation.
What's next¶
Next chapter
Time to assemble everything into a complete worked example.
The Predator-prey walkthrough mirrors
the benchmark suite and uses every piece in this section:
acquisition, pursuit with @ForEachPair, exclusive catch
detection via forEachPairLong, cleanup via
RELEASE_TARGET, and scoring via RemovedRelations<Hunting>.