DOTS//CORE local · not synced

m02 · Systems · reading · 8 min

Systems — behaviour with no state of its own

Where did the behaviour go?

In object-oriented gameplay code, a Enemy object owns both its data (health, position) and its behaviour (TakeDamage, Update). ECS splits that in half and stores the halves in completely different places. Data lives in components, grouped by archetype into chunks — you spent the last module on that. Behaviour lives in systems, and a system holds no per-entity state at all. It is a transformation: “given all entities matching this query, do this to them, once per frame.”

That separation is the whole reason the memory-wall payoff is reachable. If behaviour lived on the entity, running it would mean visiting each entity’s object and calling a method — pointer-chasing through scattered allocations, the exact anti-pattern the first module condemned. By pulling behaviour out into a system that sweeps contiguous component arrays, the runtime turns “update every enemy” into a linear walk over dense memory. Logic is separated from data so that data can be laid out for the machine instead of for the programmer’s mental model.

A system is a per-frame sweep, not a per-entity callback

A system’s update runs once per frame, and inside that single run it iterates all matching entities:

public partial struct MoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        float dt = SystemAPI.Time.DeltaTime;
        foreach (var (transform, velocity) in
                 SystemAPI.Query<RefRW<LocalTransform>, RefRO<Velocity>>())
        {
            transform.ValueRW.Position += velocity.ValueRO.Value * dt;
        }
    }
}

There is no Update() called ten thousand times. There is one OnUpdate call that walks ten thousand entities in a tight loop. This inversion — the loop lives in the system, not in a method invoked per object — is what makes the inner body operate on already-resident, contiguous data. Each iteration’s LocalTransform and Velocity are part of a stream the prefetcher has been feeding.

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for the system's loop body sees each entity's components as the next elements of a dense stream — one OnUpdate, ten thousand cache-friendly iterations

Because the system carries no per-entity state, it doesn’t matter that the same system instance processes every entity — there’s nothing to reset between them. The state is in the components; the system is a pure function from component values to new component values. That statelessness is also what makes systems safe to run in parallel across chunks later.

Two kinds of system, one meaningful difference

Unity gives you two shapes:

  • ISystem — a struct. It can be Burst-compiled end to end, so its OnUpdate runs as native, optimized machine code with no managed allocations and no garbage collector involvement. This is the default for anything performance-critical.
  • SystemBase — a managed class. It can hold managed fields, capture managed objects in lambdas, and call into managed APIs directly. Its update runs as managed C#. Convenient, but it can’t be Burst-compiled as a whole, so it sits outside the fast path.

The difference isn’t stylistic — it’s whether your per-frame logic crosses into the managed world. Burst (a whole later module) can only compile code that stays within its supported subset: blittable data, no managed heap references, no GC. An ISystem struct is built to stay in that subset; a SystemBase class is built to escape it when you need to.

The trade in the split itself

What this buys you

You know now that a system is a stateless per-frame transformation over a query’s worth of entities, that the loop lives in the system so the data can be laid out for the machine, and that ISystem-vs-SystemBase is a question about staying inside Burst’s world. What decides when each system runs relative to the others — and why that ordering is a first-class, declared thing rather than call order — is the next lesson.

m02.l01