โ๏ธ Lesson 4.3: Systems & Queries โ ISystem & SystemAPI
Components are data at rest; systems are what make them move. In this lesson you write real ECS systems with ISystem and SystemAPI, query entities with the idiomatic foreach, control the order systems run in, then turn that loop into a Burst-compiled parallel job with IJobEntity โ bringing all of Module 3 to bear inside ECS.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Write an
ISystemwithOnCreate/OnUpdateand know when to useSystemBaseinstead - Query entities with
SystemAPI.Query<RefRW, RefRO>and read time/singletons viaSystemAPI - Gate a system with
RequireForUpdateand reason aboutEntityQuery - Order systems with system groups and
[UpdateInGroup]/[UpdateBefore] - Run a system's work in parallel with
IJobEntity.ScheduleParallel - Make safe structural changes with an
EntityCommandBuffer
Estimated Time: 60 minutes ยท Prerequisite: Lesson 4.2 (Components & Archetypes) and Module 3 (jobs/Burst)
In This Lesson
ISystem vs SystemBase
ECS offers two ways to write a system. ISystem is an unmanaged struct system: it can be Burst-compiled end to end and is the default you should reach for. SystemBase is a managed class system โ slower, not Burst-compiled itself, but able to touch managed objects and the classic Unity API when you genuinely need to. The guidance is simple: write ISystem unless you must use managed data, in which case use SystemBase.
Systems are discovered and instantiated automatically โ you don't new them or attach them to GameObjects. Define one and Unity creates it in the default world and ticks it every frame in the right group.
Anatomy of a System
Here is a complete movement system โ the runtime cousin of the taste you saw in Lesson 4.1. Note the partial struct (source generators complete it), the [BurstCompile] on both the struct and its methods, and the ref SystemState that gives access to the world:
using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;
[BurstCompile]
public partial struct MovementSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
// Don't run this system until at least one Velocity entity exists.
state.RequireForUpdate<Velocity>();
}
[BurstCompile]
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;
}
}
[BurstCompile]
public void OnDestroy(ref SystemState state) { }
}
OnCreateruns once โ set up queries and requirements here.OnUpdateruns every frame the system is active.RequireForUpdate<T>()skipsOnUpdateentirely when no matching entity exists โ cheap and tidy.
Querying with SystemAPI
The heart of a system is the query. SystemAPI.Query<โฆ>() returns exactly the entities that have all the listed components, and you iterate them with a normal foreach. The wrappers declare your access:
RefRW<T>โ read/write access (use.ValueRW).RefRO<T>โ read-only access (use.ValueRO); lets the system parallelise reads.EnabledRefRW<T>,RefRWwith tags,Entityitself โ you can also pull the entity handle into the loop.
SystemAPI is the entry point for more than queries: SystemAPI.Time.DeltaTime, SystemAPI.GetSingleton<T>(), SystemAPI.GetComponent<T>(entity), and component lookups all hang off it. Behind the scenes the source generator turns your Query call into real chunk iteration โ the packed-array walk from Lesson 4.1's diagram. You write a foreach; you get cache-friendly bulk processing.
๐ก Filter with tags. Add a tag to the query type list to narrow it:SystemAPI.Query<RefRW<LocalTransform>>().WithAll<Enemy>()processes only entities also carrying theEnemytag.WithNone<T>()andWithDisabled<T>()refine it further.
System Groups & Order
Systems don't run in a random order โ they're organised into system groups that update in a fixed sequence each frame. The three top-level groups are:
spawning, input, setup"] --> Sim["SimulationSystemGroup
gameplay: movement, AI, physics"] --> Pres["PresentationSystemGroup
rendering: LocalToWorld โ Entities Graphics"]
Figure 1: The three top-level system groups, in the order they update each frame.
By default a system lands in the SimulationSystemGroup. You place it explicitly and order it relative to siblings with attributes:
using Unity.Entities;
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(SpawnSystem))] // run after spawning...
[UpdateBefore(typeof(MovementSystem))] // ...but before movement
public partial struct SteeringSystem : ISystem { /* ... */ }
This is how you guarantee that, say, forces are computed before velocities are integrated before positions are moved โ the ECS equivalent of the job dependencies you built by hand in Lesson 3.2, but declared once and managed by the framework. The Systems window (Window โธ Entities โธ Systems) shows the full ordered tree at runtime.
Going Parallel with IJobEntity
The foreach in OnUpdate runs on the main thread. For heavy work over many entities, promote it to a Burst-compiled parallel job with IJobEntity โ the ECS-native cousin of IJobParallelFor. You write one Execute describing what happens to a single entity's components, and schedule it across every core:
using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;
[BurstCompile]
public partial struct MoveJob : IJobEntity
{
public float DeltaTime;
// The parameters ARE the query: every entity with LocalTransform + Velocity.
void Execute(ref LocalTransform transform, in Velocity velocity)
{
transform.Position += velocity.Value * DeltaTime;
}
}
[BurstCompile]
public partial struct MovementSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var job = new MoveJob { DeltaTime = SystemAPI.Time.DeltaTime };
// Schedule across all cores; carry the system's job dependency chain.
state.Dependency = job.ScheduleParallel(state.Dependency);
}
}
Two things to notice. First, IJobEntity infers the query from the Execute parameters โ ref/in mirror RefRW/RefRO. Second, you don't call Complete() here: you assign the handle to state.Dependency, and ECS completes it for you at the right moment (before any system that needs the result). This is the same "schedule early, complete late" discipline, now automated by the framework. This is exactly how the 25,600-entity scene in Lesson 4.1 moves at full frame rate.
Structural Changes: EntityCommandBuffer
Remember from Lesson 4.2 that structural changes โ creating, destroying, adding/removing components โ can't happen safely mid-job, because they'd move entities between chunks while other threads read them. The solution is the EntityCommandBuffer (ECB): you record the changes during the frame and play them back at a safe point.
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// A command buffer that plays back at the start of the next simulation step.
var ecb = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>()
.CreateCommandBuffer(state.WorldUnmanaged);
foreach (var (health, entity) in
SystemAPI.Query<RefRO<Health>>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0)
ecb.DestroyEntity(entity); // recorded now, executed at a safe point
}
}
The ECB queues DestroyEntity, AddComponent, Instantiate, and friends; a dedicated ECB system plays them back between system updates when no job is touching the data. For one-off editing from a MonoBehaviour you can also create a standalone new EntityCommandBuffer(Allocator.TempJob), record, then Playback(entityManager) and Dispose(). Either way: never do structural changes directly inside a parallel job โ record them into an ECB.
โ ๏ธ Editing values โ structural change
Writing transform.ValueRW.Position or health.ValueRW.Current is not a structural change โ you're editing data in place, which is always safe in a job. Only add/remove/create/destroy needs the ECB. Keep that line clear and most of your systems never touch an ECB at all.
Hands-on Challenge
๐๏ธ Exercise 1: A spin system
Objective: Write a real system from scratch.
- Define
Spin : IComponentDatawith afloat RadiansPerSecond. - Write
SpinSystem : ISystem. InOnUpdate, queryRefRW<LocalTransform>+RefRO<Spin>and rotate each entity around Y byspin.ValueRO.RadiansPerSecond * SystemAPI.Time.DeltaTime(usetransform.ValueRW = transform.ValueRO.RotateY(angle)). - Add
[BurstCompile]to the struct and both methods; gate withRequireForUpdate<Spin>()inOnCreate. - Give some entities a
Spinand watch them rotate.
๐ก Hint
LocalTransform has helpers: RotateY(radians) returns a rotated copy. So transform.ValueRW = transform.ValueRO.RotateY(spin.ValueRO.RadiansPerSecond * dt);. Forgetting partial on the struct is the classic error โ the source generator needs it.
๐๏ธ Exercise 2: Parallelise it
Convert your SpinSystem to use an IJobEntity: write partial struct SpinJob : IJobEntity with void Execute(ref LocalTransform t, in Spin s), and in OnUpdate do state.Dependency = new SpinJob { DeltaTime = dt }.ScheduleParallel(state.Dependency);. Confirm you did not call Complete().
โ Why no Complete?
Assigning the handle to state.Dependency hands completion to ECS โ it will complete the job before any later system reads LocalTransform. Calling Complete() yourself would stall the main thread and throw away the overlap you just gained.
๐ฏ Quick Quiz
Question 1: Which should you reach for by default, and why?
Question 2: In SystemAPI.Query<RefRW<LocalTransform>, RefRO<Velocity>>(), what does RefRO signal?
Question 3: You need to destroy entities whose health hit zero, from inside a system. What do you use?
Summary
๐ Key Takeaways
- Prefer
ISystem(unmanaged, Burst-able); useSystemBaseonly for managed data. Systems are created and ticked automatically. - A system has
OnCreate/OnUpdate/OnDestroy(ref SystemState); gate work withRequireForUpdate<T>(). SystemAPI.Query<RefRW, RefRO>()+foreachiterates matching entities as cache-friendly chunk walks;SystemAPIalso gives time, singletons, and lookups.- System groups (Initialization โ Simulation โ Presentation) order execution;
[UpdateInGroup]/[UpdateBefore]/[UpdateAfter]refine it. IJobEntityruns a system's per-entity work in parallel with Burst; assign the handle tostate.Dependencyโ don'tComplete()yourself.- Do structural changes through an
EntityCommandBuffer; editing component values in place is always safe.
๐ What's Next?
You've been creating entities in code. Real projects author them visually and convert them at build time. In Lesson 4.4: Baking โ From GameObjects to Entities you'll use SubScenes and Bakers to turn designer-friendly GameObjects into runtime entities.
โ๏ธ Systems, running
Query the components you need, edit them in place, order the work with groups, and parallelise with IJobEntity. That's the ECS runtime โ packed data in, Burst jobs over it, every frame.