@ForEachPair¶
The tuple-oriented dispatch model. The framework calls the system once per live pair of a given relation type, with source and target components bound directly as method parameters. No walker, no reader, no per-source dispatch — the tier-1 bytecode generator emits a tight loop that calls your body through
invokevirtual.
The big idea¶
@Pair is "give me every entity that has pairs and let me walk
them." @ForEachPair is "just give me each pair — one at a time."
The framework takes over the iteration for you. It walks the
forward-index slice of every source, and for every (source, target)
in the store it:
- resolves the source's components and loads the
@Readvalues, - sets up the source's
@Write Mut<T>slots, - resolves the target's components and loads any
@FromTarget @Readvalues, - fetches the payload record,
- invokes your method once.
Because nothing inside your body has to "walk" anything, the body collapses to the actual per-pair work — a handful of arithmetic operations in the typical case.
Minimal example¶
import zzuegg.ecs.component.Mut;
import zzuegg.ecs.entity.Entity;
import zzuegg.ecs.relation.Relation;
import zzuegg.ecs.system.ForEachPair;
import zzuegg.ecs.system.FromTarget;
import zzuegg.ecs.system.Read;
import zzuegg.ecs.system.System;
import zzuegg.ecs.system.Write;
public record Position(float x, float y) {}
public record Velocity(float dx, float dy) {}
@Relation
public record Hunting(int ticksLeft) {}
public static class Pursuit {
@System
@ForEachPair(Hunting.class)
public void pursue(
@Read Position sourcePos,
@Write Mut<Velocity> sourceVel,
@FromTarget @Read Position targetPos,
Hunting hunting
) {
float dx = targetPos.x() - sourcePos.x();
float dy = targetPos.y() - sourcePos.y();
float mag = (float) Math.sqrt(dx * dx + dy * dy);
if (mag > 1e-4f) {
sourceVel.set(new Velocity(dx / mag * 0.1f, dy / mag * 0.1f));
}
}
}
No PairReader, no self, no iteration loop inside the body. The
method fires once per live Hunting pair; the four parameters are
the source's Position, a writable Mut<Velocity> on the source,
the target's Position, and the Hunting payload itself.
Parameter binding rules¶
The @ForEachPair dispatch looks at each parameter and decides
what to bind based on its annotations and type.
| Parameter shape | Binds to |
|---|---|
@Read Component |
Source entity's component (read-only copy) |
@Write Mut<Component> |
Source entity's component (writable handle) |
@FromTarget @Read Component |
Target entity's component (read-only copy) |
@FromTarget @Write Mut<...> |
Forbidden. Rejected at plan-build time. |
Entity |
Source entity id (per pair) |
@FromTarget Entity |
Target entity id (per pair) |
| Relation payload record (by type) | The pair's payload (no annotation required) |
Commands, Res<T>, ResMut<T> |
Regular service parameters |
ComponentReader<T>, World |
Regular service parameters |
The key rules:
- Source is default. Any
@Read/@Writewithout@FromTargetbinds to the source entity of the current pair. @FromTargetflips to target. Applicable to read-only components and toEntityparameters. Not applicable toMut<T>.- The payload binds by type. The system descriptor looks for a
parameter whose type matches the
@ForEachPair(T.class)value and hands the per-pair record into it. No annotation needed; the type match is unambiguous because a relation pair has exactly one payload record. - Service parameters are unchanged.
Commands,Res<T>,ResMut<T>,ComponentReader<T>, andWorldwork exactly the same as in a plain@Systemmethod.
Why @FromTarget @Write is forbidden¶
Writing target-side components from @ForEachPair is rejected
The tuple-oriented dispatch iterates pairs in forward-index
order. Two different predators can legitimately share the same
prey — both pairs would fire the system body, and both bodies
would try to set a new value into the same target slot. Last
write wins, but which write is "last" depends on iteration
order, which is an implementation detail. Rather than define
undefined semantics, the plan builder rejects
@FromTarget @Write at parse time.
If you need to accumulate target-side updates, do it through
Commands — the command buffer is ordered and deterministic.
Example: apply damage via
cmds.add(targetEntity, new IncomingDamage(...)) and let a
separate @System apply the damage once per prey.
How the dispatch works under the hood¶
Here's the execution path. You don't need to know any of this to
use @ForEachPair, but it explains why the benchmark numbers
look the way they do.
Tier 1: bytecode generation¶
When a @ForEachPair system is built, the framework first asks
GeneratedPairIterationProcessor to emit a hidden class. The hidden
class has a run(long tick) method that:
- Hoists the relation store's forward-map key/value arrays into local variables.
- Walks the outer table slot by slot, skipping nulls.
- For each live source:
- resolves the source's
EntityLocation, - compares the source's archetype id against a one-slot cache and re-resolves the per-component storages on a miss,
- loads every
@Readsource component into a local, - calls
Mut.setContext+Mut.resetValueonce per source for every@Writeslot, not once per pair.
- resolves the source's
- Inner loop walks the
TargetSlice's rawlong[]target ids andObject[]payload values. - For each pair:
- resolves the target's location + archetype cache,
- loads
@FromTarget @Readcomponents, - calls your system method via a direct
invokevirtual— noMethodHandle, noSystemInvoker, no reflection.
- After the inner loop, flushes each source-side
Mut<T>back into the chunk storage.
The code is in
ecs-core/src/main/java/zzuegg/ecs/system/GeneratedPairIterationProcessor.java.
The store-level forEachPairLong callback in
ecs-core/src/main/java/zzuegg/ecs/relation/RelationStore.java is
the same walk, minus the component-resolution steps.
Tier 2: reflective fallback¶
Some signatures don't fit the tier-1 emitter (current caps: ≤ 4
source @Read, ≤ 2 source @Write, ≤ 2 @FromTarget @Read,
non-static methods). When that happens the framework silently
falls back to PairIterationProcessor, which does the same work
through ComponentReader.get + SystemInvoker.invoke. Correct
but slower. The caps aren't fundamental — they're bytecode-emission
complexity limits, and they'll loosen over time.
@Pair vs @ForEachPair: pick by shape¶
Both models are first-class. Neither is a footgun. Use the one whose shape matches your system:
| If your body… | Prefer |
|---|---|
| Does entity-level setup once, then optionally walks pairs | @Pair + PairReader |
| Does identical work on every pair, with no entity-level state | @ForEachPair |
| Reads target components | Either — @ForEachPair is terser |
| Writes target components | @Pair (with Commands) |
| Needs "at most one pair per entity" semantics | @Pair + reader.get |
| Is the hot-path pair kernel | @ForEachPair (tier-1 wins) |
Prefer @ForEachPair for per-pair hot paths
If profiling shows the pair walk dominating a system's CPU cost,
and the body does the same thing for every pair, move it to
@ForEachPair. The tier-1 generator removes the PairReader
wrapper, the fromSource iterable allocation, and the per-pair
ComponentReader.get dispatch, and it cuts the source-side
Mut<T> setup cost down to once per source instead of once per
pair.
Safety: same mutation rule as @Pair¶
Don't mutate the store during iteration
A @ForEachPair body runs inside the iteration loop — same
constraint as the @Pair walker. Calls to world.setRelation
or world.removeRelation for the relation type being iterated
are unsafe. Use Commands.setRelation /
Commands.removeRelation to defer mutations to the next stage
boundary. Service parameters like Commands and ResMut<T>
are resolved before the iteration starts and remain valid for
the full stage.
What you learned¶
Recap
@ForEachPair(T.class)fires a system method once per live pair of relation typeT.- Parameters bind by annotation: default to source-side,
@FromTargetflips to target-side, the payload binds by type match. @FromTarget @Writeis forbidden because pair iteration order is not user-visible.- The tier-1 runner is a generated hidden class with a
direct
invokevirtualinto your body — no reflection on the hot path.
What's next¶
Next chapter
When an entity despawns, what happens to its pairs — and to pairs pointing at it? The answer depends on the relation's cleanup policy. See Cleanup policies.