Worked Example: Predator and Prey¶
A complete relations walkthrough assembling everything from this section — payload record, cleanup policy,
Commandsacquisition,@ForEachPairpursuit, an@Exclusivecatch scan viaforEachPairLong, and a scoring observer usingRemovedRelations<Hunting>. The same shape ships asPredatorPreyForEachPairBenchmarkin the benchmark suite.
The scenario¶
A fixed population of predators and prey roam a 2D arena.
- Prey wander. (In this example they start with zero velocity, but the rules are the same if they do.)
- Predators without a current hunt pick a random prey and
commit to it via a
Huntingrelation. - Predators with a current hunt steer toward the prey every tick.
- If a predator gets within
catchDistanceof its prey, the prey is despawned.HuntingisRELEASE_TARGET, so the predator survives the catch and can acquire a new hunt next tick. - A scoring system reacts to each dropped
Huntingpair by bumping a counter. - A respawn system keeps prey at a baseline population.
Every moving piece in this scenario was introduced earlier in the section. Here it comes together.
The records¶
import zzuegg.ecs.component.Mut;
import zzuegg.ecs.entity.Entity;
import zzuegg.ecs.relation.CleanupPolicy;
import zzuegg.ecs.relation.Relation;
public record Position(float x, float y) {}
public record Velocity(float dx, float dy) {}
public record Predator(int speed) {}
public record Prey(int alert) {}
// Default policy is RELEASE_TARGET — spelled out here for clarity.
@Relation(onTargetDespawn = CleanupPolicy.RELEASE_TARGET)
public record Hunting(int ticksLeft) {}
Plus three resources:
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public static final class PreyRoster {
public final List<Entity> alive = new ArrayList<>();
}
public static final class Config {
public float catchDistance = 0.5f;
public float arenaSize = 20.0f;
public Random rng = new Random(1234);
}
public static final class Counters {
public long pursuitCalls;
public long catches;
}
PreyRoster is a simple live-set so the acquisition system can
pick a target in O(1). Config carries tunables. Counters is
the sink for pursuit calls and catches; the scoring system bumps
catches every time a Hunting pair is released.
System 1: movement¶
Ordinary @System, no relations. Apply velocity to position.
import zzuegg.ecs.system.System;
import zzuegg.ecs.system.Read;
import zzuegg.ecs.system.Write;
@System
public void movement(@Read Velocity v, @Write Mut<Position> p) {
var cur = p.get();
p.set(new Position(cur.x() + v.dx(), cur.y() + v.dy()));
}
System 2: acquire a hunt¶
Every predator without an active Hunting pair picks a random
prey from the roster and commits to it. The @Without(Hunting.class)
filter narrows the archetype match to predators that don't
currently carry the source marker, so predators that already have
a hunt skip dispatch entirely.
import zzuegg.ecs.command.Commands;
import zzuegg.ecs.resource.Res;
import zzuegg.ecs.system.Without;
import zzuegg.ecs.world.World;
@System(after = "movement")
@Without(Hunting.class)
public void acquireHunt(
@Read Predator pred,
Entity self,
Res<PreyRoster> roster,
Res<Config> config,
World world,
Commands cmds
) {
var preyList = roster.get().alive;
if (preyList.isEmpty()) return;
var target = preyList.get(config.get().rng.nextInt(preyList.size()));
if (!world.isAlive(target)) return;
cmds.setRelation(self, target, new Hunting(3));
}
Key points:
- The relation is created through
Commands.setRelation. Every other system running in the current stage is already iterating the pair store; a directworld.setRelationwould be unsafe.Commandsdefers the write to the next stage boundary. @Without(Hunting.class)references the source marker indirectly via the relation class, so the filter reflects the pair-carrier set accurately. Once this system commits aHunting, the predator gains the source marker at flush time and drops out of theacquireHuntarchetype match.
System 3: per-pair pursuit with @ForEachPair¶
The hot loop. One call per live Hunting pair. Source-side
Position + Velocity, target-side Position, and the
Hunting payload itself.
import zzuegg.ecs.relation.Relation;
import zzuegg.ecs.resource.ResMut;
import zzuegg.ecs.system.ForEachPair;
import zzuegg.ecs.system.FromTarget;
@System
@ForEachPair(Hunting.class)
public void pursuit(
@Read Position sourcePos,
@Write Mut<Velocity> sourceVel,
@FromTarget @Read Position targetPos,
Hunting hunting,
ResMut<Counters> counters
) {
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));
}
counters.get().pursuitCalls++;
}
This is the exact shape the tier-1 generator can emit a hidden
class for: 1 source @Read, 1 source @Write, 1 target @Read,
1 payload, 1 service parameter. The generated runner walks the
forward-index arrays directly, calls Mut.setContext once per
source (not once per pair), and invokes pursuit through
invokevirtual. No walker, no PairReader, no reflection.
No @FromTarget @Write
sourceVel is a source-side Mut<Velocity>, which is fine.
You can't ask for @FromTarget @Write Mut<Velocity> — two
predators may legitimately share the same prey, and the
framework rejects the ambiguous write at parse time. If you
need to apply a target-side effect, emit a Commands.add(...)
and let a separate system apply it.
System 4: exclusive catch scan¶
This is where the per-pair model shows its limits. A catch resolution has to:
- iterate every live
Huntingpair, - look up both entities' positions,
- compare distance,
- stash the catches somewhere without mutating the store mid-walk,
- despawn the prey after the scan finishes.
@Exclusive turns off parallel dispatch and gives the system
sole access to the world for the duration of its body. Inside,
store.forEachPairLong is the raw-long iterator — it hands the
packed entity ids straight to the consumer without allocating an
Entity per pair.
import zzuegg.ecs.component.ComponentReader;
import zzuegg.ecs.system.Exclusive;
import zzuegg.ecs.util.LongArrayList;
// Reusable catch-id buffer. One per-system instance is fine.
private final LongArrayList caughtBuffer = new LongArrayList(32);
@System(stage = "PostUpdate")
@Exclusive
public void resolveCatches(
World world,
ResMut<PreyRoster> roster,
Res<Config> config,
ComponentReader<Position> posReader,
ResMut<Counters> counters
) {
var store = world.componentRegistry().relationStore(Hunting.class);
if (store == null) return;
float catchDistSq = config.get().catchDistance * config.get().catchDistance;
caughtBuffer.clear();
// Raw-long bulk walk over every (predator, prey, Hunting) triple.
// Zero Entity allocation in the hot loop. Mutations MUST be
// deferred to after the walk.
store.forEachPairLong((predatorId, preyId, val) -> {
var predPos = posReader.getById(predatorId);
var preyPos = posReader.getById(preyId);
if (predPos == null || preyPos == null) return;
float dx = predPos.x() - preyPos.x();
float dy = predPos.y() - preyPos.y();
if (dx * dx + dy * dy <= catchDistSq) {
caughtBuffer.add(preyId);
}
});
int caughtCount = caughtBuffer.size();
if (caughtCount == 0) return;
var alive = roster.get().alive;
var caughtRaw = caughtBuffer.rawArray();
for (int i = 0; i < caughtCount; i++) {
var prey = new Entity(caughtRaw[i]);
if (world.isAlive(prey)) {
world.despawn(prey);
alive.remove(prey);
counters.get().catches++;
}
}
}
What's happening at the despawn call:
world.despawn(prey)entersdespawnWithCascade.- The cleanup loop walks every registered relation store.
- For
Hunting, the prey is the target of one or more pairs. The policy isRELEASE_TARGET, so each pair is dropped (tracker updated,PairRemovalLogappended with tick andlastValue). Each predator that lost its lastHuntingpair has its source marker cleared, which drops it out of the pursuit filter and back into theacquireHuntfilter next stage. - The prey's own archetype row is freed. Its incoming-pair target marker is freed along with it.
- On the next tick, scoring reads the drained
RemovedRelations<Hunting>and bumps a counter.
Don't mutate during a forEachPair walk
The forEachPairLong callback runs over the live forward
map. Calling world.despawn or world.removeRelation from
inside the lambda would mutate the map the walk is reading.
Always defer mutations into a local list (like the
LongArrayList above) and apply them after the walk
returns.
System 5: scoring via RemovedRelations<Hunting>¶
Every dropped Hunting pair — whether the prey was caught, the
pair was manually removed, or a Commands.removeRelation flushed
— feeds the per-type PairRemovalLog. The scoring system drains
the log on its own tick.
import zzuegg.ecs.relation.RemovedRelations;
@System(stage = "PostUpdate", after = "resolveCatches")
public void scoreHunts(
RemovedRelations<Hunting> dropped,
ResMut<Counters> counters
) {
if (dropped.isEmpty()) return;
for (var event : dropped) {
// event.source() — predator (may still be alive)
// event.target() — prey (usually dead at this point)
// event.lastValue() — Hunting record as of the drop
counters.get().catches++;
}
}
Running scoreHunts with after = "resolveCatches" in
PostUpdate guarantees it sees the drops from this tick's
catches. Note that we're now double-counting — resolveCatches
already bumped catches — so in a real game you'd pick one or
the other. The benchmark keeps both counters separate
(pursuitCalls and catches) for observability.
System 6: respawn prey¶
Keep the population at BASELINE_PREY_COUNT. @Exclusive
because the body spawns new entities; exclusive execution is the
simplest way to make the spawn count deterministic across threads.
public static volatile int BASELINE_PREY_COUNT;
@System(stage = "PostUpdate", after = "resolveCatches")
@Exclusive
public void respawnPrey(
World world,
ResMut<PreyRoster> roster,
Res<Config> config
) {
while (roster.get().alive.size() < BASELINE_PREY_COUNT) {
float x = config.get().rng.nextFloat() * config.get().arenaSize;
float y = config.get().rng.nextFloat() * config.get().arenaSize;
var p = world.spawn(
new Position(x, y),
new Velocity(0f, 0f),
new Prey(0)
);
roster.get().alive.add(p);
}
}
Wiring the world¶
World world = World.builder()
.addResource(new PreyRoster())
.addResource(new Config())
.addResource(new Counters())
.addSystem(Systems.class) // the enclosing class holding all six @System methods
.build();
// Seed predators and prey.
var rng = new Random(7);
for (int i = 0; i < 500; i++) {
world.spawn(
new Position(rng.nextFloat() * 2f, rng.nextFloat() * 2f),
new Velocity(0.05f, 0.05f),
new Predator(1)
);
}
var roster = /* resolve roster from world */;
for (int i = 0; i < 2000; i++) {
var prey = world.spawn(
new Position(/* ... */),
new Velocity(0f, 0f),
new Prey(0)
);
roster.alive.add(prey);
}
for (int i = 0; i < 1000; i++) world.tick();
Stage ordering recap¶
Default stage (Update):
movement— physics integration, no relations touched.acquireHunt— predators without a hunt pick one viaCommands.setRelation.pursuit—@ForEachPair(Hunting.class)body runs once per pair. The tier-1 runner is in charge.
Command buffer flush between stages. New Hunting pairs become
visible.
PostUpdate:
resolveCatches— exclusive distance check, catches despawned immediately (safe — we're exclusive).respawnPrey— top up the prey population.scoreHunts— observer drains the tick'sHuntingremovals.
How the pieces fit together¶
Picking the right tool per system
This scenario uses all three dispatch styles for a reason:
@ForEachPairforpursuitbecause every pair does identical work and the tier-1 runner makes the hot loop essentially a straight-line store walk.@Exclusive+forEachPairLongforresolveCatchesbecause the body needs to look up arbitrary components (ComponentReader<Position>.getById) and has to mutate the world (despawns) after the walk finishes.RemovedRelationsforscoreHuntsbecause it runs once per dropped pair, it doesn't care about the source or target components at all, and it parallels theRemovedComponentspattern from plain ECS.
If you wrote pursuit as a @Pair + PairReader system,
it would still work — but you'd pay the walker allocation
and the reader.fromSource call per pair, and the tier-1
path wouldn't apply.
Benchmark reference¶
The same six systems (minus some minor accounting) are
implemented in
benchmark/ecs-benchmark/src/jmh/java/zzuegg/ecs/bench/scenario/PredatorPreyForEachPairBenchmark.java.
That file is the canonical reference for current API shape and
current throughput numbers; compare against
PredatorPreyBenchmark in the same package for the
@Pair + PairReader variant.
For the full numbers on current hardware see the predator-prey benchmark page.
What you learned¶
Recap
- A complete relation workflow pulls together the payload
record, a cleanup policy, command-driven acquisition, a
tier-1 per-pair system, an exclusive raw-long scan, and a
RemovedRelations<T>observer. Commands.setRelationis the safe way to create pairs from inside a system body.store.forEachPairLongis the fastest full-scan primitive for systems that need arbitrary entity lookups during iteration; mutations must be deferred until after the walk.RELEASE_TARGETis the right policy whenever the source survives its target dying — which is most gameplay relations.
What's next¶
You've finished the Relations section
Next up, pick whichever deep-dive fits your project:
- Change tracking deep dive — the
PairChangeTrackerthat drives@Added/@Changedfor pair payloads. - Predator / prey benchmark — real numbers on the scenario you just built.
- Architecture deep dive — how the storage, scheduler, and generator interact under the hood.