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
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.
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 itsOnUpdateruns 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.