Skip to content

@Exclusive systems

Most systems are per-entity iterators: the framework selects matching entities and calls your method once per row. @Exclusive turns that off. An exclusive system runs exactly once per tick, takes no component parameters, and receives the World itself as a handle to do bulk work the normal iteration model cannot express.

Use it sparingly — every exclusive system is a parallel-scheduling barrier.

Declaration

Annotate the method with @Exclusive. The rest of the method stays a normal @System:

import zzuegg.ecs.system.Exclusive;
import zzuegg.ecs.world.World;

class Bookkeeping {

    @Exclusive
    @System(stage = "Last")
    void resetTickCounters(World world, ResMut<Stats> stats) {
        stats.get().reset();
    }
}

What you can put in the parameter list:

  • World — the owning world. The exclusive runner always fills this slot with the world instance.
  • Any standard service parameter — Commands, Res, ResMut, EventReader, EventWriter, Local, RemovedComponents, PairReader, ComponentReader.
  • Not @Read / @Write component parameters. Exclusive systems have no per-entity iteration to bind them to.
  • Not Entity. There is no "current entity" because no entity is being iterated.

If the parser sees component parameters on an @Exclusive method, you'll get a validation error at world-build time.

What "exclusive" means to the scheduler

The DAG builder hard-wires exclusive systems against every other system in the same stage. hasConflict returns true whenever either side is exclusive, regardless of declared component or resource access:

// from DagBuilder.hasConflict
if (a.isExclusive() || b.isExclusive()) {
    return true;
}

The practical effect is that an exclusive system runs alone in its stage. No parallel system runs alongside it. This is the whole point — inside the method, the world is quiescent and you can poke at it without worrying about a neighbour writing to the same data.

Exclusive systems are serialization points

Every @Exclusive system forces the multi-threaded executor to funnel down to one thread for the duration of that system's execution. Putting a hot exclusive system in the middle of Update can wipe out 50% of your parallel gains. Prefer First or Last for bookkeeping.

Tier-1 generated runner

Because exclusive systems have such a restricted shape — service-only parameters, no per-entity dispatch — the framework compiles them to a dedicated tier-1 hidden class: GeneratedExclusiveProcessor. The generated bytecode:

  1. Hoists the pre-resolved service argument array once into a local.
  2. Loads each slot, casts to the declared parameter type via checkcast.
  3. Calls the user method with a direct invokevirtual (or invokestatic if the method is static).

The win versus the reflective SystemInvoker path is roughly one MethodHandle.asSpreader setup and one MethodHandle.invoke per call — modest because exclusive systems are called at most once per tick per stage. What you actually get is the JIT treating the call as fully monomorphic, which can inline the body into whatever the caller looks like.

The fallback path is SystemInvoker with reflective Method.invoke. Both paths produce identical results; the runner lookup is transparent at executeSystem time.

When to reach for @Exclusive

Three patterns justify the barrier cost.

1. Bulk structural edits

When you need to visit every entity of a type and issue a structural change — despawn every projectile that left the screen, promote every queued entity into a real archetype — a normal system can use Commands per-entity, but the dispatch overhead of iterating millions of rows is wasted if the work is really "scan a list, apply to the world".

@Exclusive
@System(stage = "Last")
void despawnExpired(World world, ResMut<ExpiryQueue> queue, Commands cmds) {
    for (var entity : queue.get().drain()) {
        cmds.despawn(entity);
    }
}

The exclusive guarantee means no other system is adding to ExpiryQueue while you drain it.

2. Cleanup passes that touch the store directly

Some cleanup passes can't be expressed as "iterate entities with component X" — for example, shrinking an internal index or compacting a resource. Take the World reference and call whatever API you need on it.

@Exclusive
@System(stage = "First")
void compactWorld(World world) {
    world.debugDump();   // or any world-level API
}

3. Reset-at-end-of-tick resources

Resources that are "per-tick" — frame counters, queued inputs, accumulated diagnostics — benefit from an exclusive system at Last that zeroes them after everyone has read. Because the system runs alone, there is no race between "I'm still reading the accumulator" and "I'm clearing it".

@Exclusive
@System(stage = "Last")
void resetFrameStats(World world, ResMut<FrameStats> stats) {
    stats.get().clear();
}

Prefer Last for resets, First for setup

Stages run in ascending order, so a cleanup system in Last observes the final state of the tick, and a setup system in First primes the world before anything else runs. Both positions keep the exclusive barrier out of the hot update loop.

Difference from a regular system

Behaviour Regular @System @Exclusive @System
Called per entity Yes No — called once per tick
Parameters Components + services Services only
World parameter allowed No Yes, filled with owner
Parallel with other systems Yes, subject to DAG No — blocks entire stage
Tier-1 backing GeneratedChunkProcessor GeneratedExclusiveProcessor
Typical use Game logic Bookkeeping, bulk edits

A complete cleanup example

public record Expired(long at) {}        // marker: this entity should die
public record Tick(long value) {}        // resource: current tick count

class Lifetimes {

    // Regular system: flag expired entities.
    @System
    void checkExpiry(@Read Expired expiry, Res<Tick> tick, Entity self, Commands cmds) {
        if (tick.get().value() >= expiry.at()) {
            cmds.add(self, new PendingDespawn());
        }
    }

    // Exclusive system: scan the pending list and drop them all at end of tick.
    // Runs alone in `Last`, so we can safely drain a resource the regular
    // systems just finished writing.
    @Exclusive
    @System(stage = "Last")
    void applyDespawns(World world, ResMut<PendingDespawnQueue> queue, Commands cmds) {
        for (var entity : queue.get().drain()) {
            cmds.despawn(entity);
        }
    }
}

Note that even an exclusive system uses Commands for the actual despawns — the structural pipeline is still single-threaded and applied at the stage boundary, so letting the framework handle it is simpler than calling world.despawn(...) directly.

What's next