Back to Journal

DOTS and ECS: Data-Oriented Design in Unity

Modern processors are starved not of arithmetic but of data, and Unity's Data-Oriented Technology Stack reorganizes a game around how memory is laid out, so that thousands of entities can be processed in tight, parallel loops.

The usual way of building a Unity game is object by object. A villager is a GameObject; it has a Transform for its position, a renderer for its body, an Animator, a collider, and a handful of MonoBehaviour scripts that give it a mind. Each object is a small world of its own, and this model is easy to think about because it resembles the way we describe things in speech. The difficulty appears only when the number of things grows large, into the thousands or tens of thousands, and the frame begins to drown.

Unity's Data-Oriented Technology Stack, known as DOTS, starts from a different premise. Instead of asking what each object is, it asks how the data of all objects of a kind can be laid out so that the processor can chew through it quickly. Its core is the Entity Component System, delivered in the Entities package, together with two supporting technologies: the C# Job System for running work across many processor cores, and the Burst compiler for turning that work into fast native code. Entities 1.0, the first release declared ready for production, arrived in 2023.

Understanding DOTS requires a short detour into hardware, because the stack makes sense only in the light of what a modern processor actually spends its time doing. Once that is clear, the vocabulary of entities, archetypes, chunks and systems stops seeming like ceremony and begins to look like the obvious consequence of taking memory seriously.

The cost of scattered memory

A modern processor can perform arithmetic far faster than it can fetch data from main memory. To bridge the gap it keeps small, fast caches close to its cores, and it moves data from memory into those caches in fixed blocks called cache lines, commonly 64 bytes long. When code reads a value, the whole line containing it is brought in. If the next value the code needs lies in that same line, it is available almost instantly. If it lies somewhere else entirely, the processor may wait for hundreds of cycles, doing nothing useful at all.

The traditional GameObject model tends to scatter data. Each MonoBehaviour is a separate managed object placed wherever the allocator found room, and each holds references to other objects that may be anywhere in memory. A loop that updates the position of a thousand villagers by calling into each one's script hops from address to address, and nearly every step risks a cache miss. The arithmetic of moving a villager is trivial; the waiting for its data is what consumes the frame.

The contrast becomes vivid with a small calculation. A position stored as three floats occupies 12 bytes, so a single 64 byte cache line can hold a little over five positions packed side by side. If a thousand positions are stored contiguously in one array, the processor reads them in roughly two hundred cache lines, and its hardware prefetcher, which detects sequential access, will often fetch the next lines before they are even requested. If the same thousand positions are scattered across the heap, each one may cost a separate trip to memory.

Entities, components and systems

The Entity Component System divides a game into three kinds of thing. An entity is merely an identifier, an index and a version number that name something in the world without containing anything. A component is plain data attached to an entity: in Unity, usually a struct implementing IComponentData, holding fields such as a position, a speed, or a health value, and no behaviour at all. A system is code that runs every frame over all entities having a particular set of components, reading some and writing others.

The separation is deliberate and strict. A MonoBehaviour bundles data and the methods that act on it, so the logic for moving a creature lives inside the creature. In ECS, a movement system queries for every entity that has both a position component and a velocity component and updates them all in a single loop. Rather than moving itself, the creature is moved, along with every other entity of its kind, by a piece of code that knows nothing about creatures and cares only about the components they share.

Systems in the Entities package come in two main forms. SystemBase is a managed class, convenient and flexible, while ISystem is a struct whose methods can be compiled by Burst for far greater speed. Inside a system, an EntityQuery describes which component combinations to match, and the idiomatic way to process the matches is often an IJobEntity, a job whose Execute method receives the relevant components by reference for each entity. The system schedules that job, and the engine runs it across worker threads.

Archetypes and chunks

Every entity has an archetype, which is simply the unique set of component types it carries. An entity with position, velocity and health belongs to one archetype; an entity with position and health but no velocity belongs to another. The Entities package stores all entities of the same archetype together, in fixed blocks of memory called chunks, each 16 kilobytes in size. Within a chunk, each component type has its own contiguous array, so all the positions sit side by side, then all the velocities, then all the health values.

This arrangement, sometimes called a structure of arrays, is what makes the system fast. When the movement system runs, it visits each chunk whose archetype includes position and velocity, and walks the two arrays in step, from the first entity to the last. The memory access is linear and predictable, the cache lines are fully used, and the prefetcher can stay ahead. Components the system does not touch, such as health, are never loaded at all, which is a quiet but significant saving.

The price is paid in structural changes. Adding or removing a component changes an entity's archetype, which means its data must be copied out of one chunk and into another. Doing this for many entities in the middle of a loop would be both slow and unsafe, so such changes are usually recorded in an EntityCommandBuffer and played back at a defined point in the frame. Entities 1.0 also introduced enableable components, which can be switched on and off without moving the entity, for state that toggles often.

There is a design lesson hidden here. Component granularity matters: very large components waste cache space when systems need only a field or two, while very small ones multiply the number of archetypes and fragment entities across many partly filled chunks. Good ECS design thinks about which systems read which data together, and shapes components accordingly, much as a careful librarian shelves books by how often they are borrowed together rather than by the colour of their spines.

Burst and the Job System

The C# Job System, introduced in Unity 2018.1, lets you describe a unit of work as a struct implementing an interface such as IJob or IJobParallelFor, schedule it, and let Unity run it on worker threads. Jobs operate on native containers such as NativeArray, which live outside the managed heap and therefore produce no garbage. A safety system tracks which jobs read and write which containers, and in the editor it reports an error if two jobs could touch the same data at once without a declared dependency, which catches most race conditions before they cause mysterious corruption.

The Burst compiler is the second half of the performance story. It takes jobs and ISystem code written in a restricted subset of C#, often called High Performance C#, which forbids managed objects and garbage producing operations, and compiles them through LLVM into native machine code. Because the data is laid out in tight arrays of plain structs, Burst can often apply vectorization, processing several values with a single SIMD instruction. Marking a job with the BurstCompile attribute is usually all it takes to request this.

The three technologies reinforce one another. ECS lays data out so that it can be processed linearly, the Job System spreads that processing across all available cores, and Burst makes each core's share run as fast as the hardware permits. A loop that would take several milliseconds in an ordinary MonoBehaviour can, in favourable cases, shrink to a small fraction of that. The gains depend heavily on the workload, though, and they are largest where many similar entities undergo the same simple transformation.

Working with the stack

Writing gameplay in ECS feels different from writing MonoBehaviours, and the difference is not only syntax. Designers still need to place things in scenes, so the Entities package uses a process called baking: you author content as ordinary GameObjects inside a SubScene, and Baker classes convert them into entities and components, either in the editor or at build time. The authoring scripts never run in the final game; they exist only to describe the data that baking produces.

Rendering and physics need their own packages to work with entities. Entities Graphics draws entities that carry mesh and material components, and the Unity Physics package provides a stateless, data oriented physics simulation built for ECS. Some engine features remain tied to GameObjects, so many real projects are hybrids, keeping the user interface, audio and certain one of a kind objects in the traditional model while moving large populations of similar things into entities.

Debugging also requires new habits. The Entities Hierarchy window, the Systems window and the inspector for entity components let you watch the world while it runs, and the Profiler shows how jobs are distributed across worker threads. The learning curve is real. A programmer accustomed to calling GetComponent and writing Update methods will need time to think in queries, chunks and dependencies, and should expect early code to be clumsy before the patterns settle.

When data-oriented design fits

DOTS is at its best when a game has many similar things doing similar work: flocks, crowds, swarms of projectiles, large simulations of units or particles, procedural worlds with vast numbers of tiles. A strategy game in the vein of Crown & Ashes, with a procedurally generated world and the dead pressing in on a settlement each night, is the kind of design where such thinking deserves at least a careful look. A game of a few dozen hand crafted characters, each unique, gains far less.

Even without adopting the full stack, the ideas transfer. The Job System and Burst can be used from ordinary MonoBehaviour projects, operating on NativeArray data that a script prepares and reads back. Simply keeping hot data in contiguous arrays of structs rather than in scattered objects, and processing it in tight loops, captures much of the benefit of data oriented thinking. Many teams start there, moving a single expensive calculation into a Burst compiled job, before deciding whether to go further.

What DOTS finally teaches is a change of attention. Object oriented design asks what a thing is and what it can do; data oriented design asks what data exists, how it is laid out, and how it flows through the frame. Neither question is wrong, and the best Unity projects often ask both. But when the frame is drowning beneath thousands of creatures, it is the second question that rescues it, because the processor never cared what anything was. It cared only where the bytes were, and how far it had to reach for them.