Unity DOTS · Lesson 01 · 20 min
The object is gone
Why 10,000 MonoBehaviours crawl and 10,000 entities don't — the archetype, the 16 KiB chunk, and your first ISystem.
You have put ten thousand MonoBehaviours in a scene before, and you have watched the
frame time fall over. If asked why, most Unity developers reach for the same answer:
Update() is a managed call, the engine invokes it ten thousand times, and that overhead
is the problem.
That is real, and it is not the main thing. The main thing is that your ten thousand zombies are ten thousand separate allocations scattered across the managed heap, and the CPU spends most of its time waiting for memory that isn’t where it needs it to be.
DOTS is a response to that specific sentence. Today’s win is understanding precisely where the speed comes from — and a first system that moves entities, which will look almost disappointingly ordinary once you know what it is standing on.
01 You have already done the hard part
In the GPU programming course you gave
up the loop. You stopped writing for (int i = 0; i < n; i++), started writing a body
that handles one element, and let the dispatch decide how many times it ran. You packed
enemies into a flat std430 buffer because that is what the hardware wants to read.
DOTS asks for the same surrender on the CPU, and much of what follows should feel familiar: a system body is a kernel, a chunk is a coalesced read, an entity command buffer is an append queue. Where the analogy breaks is that nothing here is automatic. The GPU gave you parallelism by construction. On the CPU you get it only when you ask, from the job system, and only when you have told the scheduler what you intend to read and write. That is lessons 4 and 5.
So the new material is not parallelism. It is architecture — and one number.
02 The object is gone
In the classic stack, a zombie is an object. It has fields and it has methods, and the
two travel together; zombie.TakeDamage(5) is data and behaviour in one place. That is
object-oriented design working exactly as designed.
ECS breaks that apart into three things, and the split is total:
- An entity is “a unique identifier, like a lightweight unmanaged alternative to a GameObject” (Entities: ECS concepts). Not a small object. An ID. It has no fields.
- A component is a plain struct of data —
Health { float Value; }— filed under an entity. It has no methods. - A system is the code. It asks for every entity that has a particular set of components, and transforms their data.
The manual puts the consequence bluntly: “entities contain no code: they’re units of data that the systems you create process.”
This part ports back to Godot. The entity/component/system split is an architectural idea, not a Unity feature — you can hold it in your head while writing GDScript and it will change how you structure a horde. The next section is the part that does not port, because it depends on machinery Unity owns.
03 The number: 16 KiB
Here is where DOTS stops being a design pattern and starts being a performance argument.
Entities that share the same combination of component types share an archetype — “a unique identifier for all the entities in a world that have the same unique combination of component types”. And all entities of one archetype are stored together in chunks: uniform blocks of memory, each exactly 16 KiB, where “each chunk contains an array for each component type, plus an additional array to store the entity IDs” (Entities: Archetypes concepts).
Read that layout again, because it is the whole lesson. Inside a chunk, the positions of every entity are one contiguous array. The healths are another. It is struct-of-arrays, the same layout you chose by hand for your GPU enemy buffer — except here you get it as a consequence of declaring components, and Unity picks the packing.
Why that matters is a hardware fact rather than a Unity one. The CPU does not fetch bytes, it fetches cache lines of 64 bytes. When a movement system reads twelve bytes of position from a scattered 200-byte object, the other fifty-two bytes of that line are dragged along and thrown away. When it reads twelve bytes from a packed array, the next four entities’ positions arrived in the same fetch, already paid for.
Drag the slider to fatten the objects. Note what does not happen to the packed row: the component array’s cost is set by the size of the component you’re reading, and is completely indifferent to how much other state the entity carries. That immunity is the product being sold.
04 The bill: structural changes
Every architecture has a thing it is bad at, and you should learn this one now rather than in lesson 10 with a profiler open.
Because storage is organised by archetype, an entity’s component set is not a free
property of the entity — it is its filing address. Add a component and you have changed
the archetype, so “the world’s EntityManager moves the entity to the appropriate
archetype. […] If no such archetype exists, the EntityManager creates it.” The docs then
warn plainly: “Moving entities frequently is resource-intensive and reduces the
performance of your application.”
This is a structural change, and it means something that trips up every OOP programmer arriving here:
Setting
health.Value = 0is nearly free. Adding aDeadcomponent is a memory move, on the main thread, that cannot run in a job.
In the classic stack those two feel like the same kind of operation. Here they are not remotely alike. Play with the archetype below — toggle components and watch both the per-chunk capacity and the migration count.
Two things to take from it. First, fatter archetypes hold fewer entities per chunk, so every unused component you attach costs you memory traffic on every system that iterates those entities. Second, that migration counter is the one you will eventually be trying to drive to zero on a per-frame basis. The standard answers — entity command buffers, and enableable components that let you flip an entity’s state without changing its archetype — are lessons 6 and 10. For now just carry the instinct: adding a component is not a cheap operation.
Approximate figures. The lab divides 16 KiB by the component sizes. Real capacity is slightly lower, because the chunk header and per-type metadata also live inside those 16 KiB. The shape of the curve is right; the exact integer is not.
05 Your first system
Now the code, which is anticlimactic on purpose. A component — data, no methods:
using Unity.Entities;
using Unity.Mathematics;
public struct Velocity : IComponentData
{
public float3 Value;
}
And a system that moves everything which has both a Velocity and a LocalTransform
(Unity’s own transform component, in Unity.Transforms):
using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;
[BurstCompile]
public partial struct MovementSystem : ISystem
{
[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;
}
}
}
Five details, each carrying weight:
partial struct, notclass.ISystemis the unmanaged form of a system, and unmanaged is what lets Burst compile it.partialis there because the source generator writes the query plumbing for you.[BurstCompile]appears twice — once on the struct, once on the method. It marks the code to be compiled “into highly-optimized native CPU code” rather than run as ordinary managed C#.- You never wrote a query.
SystemAPI.Query<...>is the query: asking for those two component types is what selects the entities. No registration, no list, noFindObjectsOfType. RefRWversusRefROis not decoration. It declares read-write against read-only access, and you use.ValueRWand.ValueROto match. This is the same information you encoded by hand when deciding which GPU buffers needed barriers — and in lesson 5 it is what the job scheduler reads to decide which systems may run at the same time. Declaring write access you don’t need costs you parallelism silently.- There is no loop over chunks in sight, but that is what the
foreachcompiles into: walk the matching chunks, walk each packed array linearly. You gave up the loop; this is what you got for it.
Nothing here says how many entities exist. Ten or ten million, the code is identical — the same property your compute kernel had.
06 Try these
Reason these through on paper. You do not need Unity open yet; lesson 2 builds the project.
-
Price an archetype. An entity has
LocalTransform(32 B),Velocity(12 B),Health(4 B) and an 8 B entity ID. Roughly how many fit in a chunk? Now add a 64 BPathfindingStatethat only 2% of your zombies ever use. What happened to the other 98%’s memory traffic? (This is the argument for splitting rare state into its own archetype — and you can check yourself in the lab above.) -
Classify these four. Which are structural changes? (a) setting a health float to zero; (b) adding a
Deadtag component; (c) instantiating a zombie; (d) writing a new position intoLocalTransform. Two of them are free-ish and two move memory. -
Find the trap in this sentence. “I’ll just add a
Stunnedcomponent when a zombie gets hit, and remove it when the stun expires.” At 10,000 zombies with a busy combat frame, what does that do? Hold your answer; lesson 10 is precisely this problem.
07 Retrieval
Answer from memory before scrolling back. One attempt each — the retrieval is the point, and re-reading first turns it into recognition practice, which does not stick.
08 Where to go from here
Primary source — read this one. Introduction to the Data-Oriented Technology Stack for advanced Unity developers, Unity 6 edition, by Brian Will, a senior software engineer at Unity. Free, and written explicitly for developers who are fluent in MonoBehaviour-based development and new to DOTS — which is exactly where you are standing. It also contains an honest discussion of when DOTS is not worth adopting, which is worth reading before you invest thirteen lessons.
The page to bookmark. Entities: Archetypes concepts. It is short, and it is the load-bearing page of the entire manual.
Before you search for other tutorials — read the version traps. DOTS documentation rot
is unusually bad, and the versioning is actively misleading: Entities 6.5 is newer than
Entities 1.4, not older. Anything showing JobComponentSystem, IJobForEach or
ConvertToEntity is from the 0.x era and will not compile. The
RESOURCES list covers this in full. Check the stamp on everything.
Keep for reference. The glossary now holds the data-model vocabulary — entity, component, system, archetype, chunk, structural change — and it is the language every following lesson uses.
Next lesson is baking: SubScenes, authoring MonoBehaviours and Baker<T>, and
why Unity makes you build entities through an editor-time conversion step rather than
just calling a constructor. That is the piece that turns today’s two code snippets into
something you can actually press play on.
Something here not landing? Ask me. I am your teacher for this course, not just the author of the page. If the chunk model feels like an implementation detail rather than the point, if you want to know how this compares to what you built on the GPU, or if you suspect a claim above is out of date — say so and we will work through it.