Introduction #
Object-oriented design asks “what is this thing,” and organises code around that answer: a Player class, an Enemy class, each bundling its own data and behaviour. Data-oriented design asks a different question: “what data do I have, and what do I need to do to all of it, fast.” At small scale the difference barely matters. At the scale of thousands of active entities, it’s the difference between a smooth frame and a stall.
Best Practices #
- Profile before you restructure. Data-oriented layouts trade code simplicity for cache performance. That trade is only worth making where you’ve actually measured a hot loop, not preemptively everywhere.
- Group by access pattern, not by concept. If two fields are always read together in the same loop, they belong in the same array, even if they don’t feel like they “belong together” conceptually.
- Keep the hot data small. A tightly packed struct of just the fields a hot loop actually touches beats a large struct with those same fields buried among ten others it doesn’t.
The cache problem #
Modern CPUs are fast; fetching data they don’t already have cached is comparatively slow. An array of class references (an “array of pointers”) scatters each object wherever the allocator happened to put it, so iterating the array means chasing a different, likely-uncached memory address on every single element. An array of plain structs, laid out contiguously, lets the CPU prefetch ahead and stream through memory instead of jumping around it.
// Object-oriented: each Enemy is a separate heap allocation, scattered in memory.
public class Enemy
{
public Vector3 Position;
public float Health;
public void Update(float dt) { /* ... */ }
}
List<Enemy> enemies = new List<Enemy>();
foreach (var e in enemies) e.Update(Time.deltaTime); // each e.Update is a cache miss
// Data-oriented: positions and health live in their own contiguous arrays.
struct EnemyData
{
public Vector3[] Positions;
public float[] Health;
public int Count;
}
void UpdateAll(ref EnemyData data, float dt)
{
for (int i = 0; i < data.Count; i++)
{
// straight-line scan through contiguous memory, no per-element cache miss
data.Positions[i] += Vector3.forward * dt;
}
}
Column-major vs. row-major #
A “row-major” layout stores each entity’s fields together (one Enemy struct holding position, health, and everything else). A “column-major” layout instead stores each field as its own array, one array of every entity’s position, a separate array of every entity’s health. Column-major wins when different systems only ever touch a handful of fields at a time: the physics system streams through the position array without ever touching health, the damage system streams through health without touching position. This is the same underlying idea behind Heathen’s own DataLens, a cache-aware in-memory simulation database built specifically around bit-packed, column-major storage for large actor counts, and it’s the same discipline behind HATE‘s (the Attribute Tag Engine’s) flat-memory, branchless-vector approach to evaluating state and actions across hundreds of thousands of actors.
When not to bother #
Data-oriented design is a real cost: less intuitive code, more indices and less “this object owns its own state” clarity. It earns its keep for large, hot collections (thousands of updated-every-frame entities). A gameplay system with a few dozen instances, updated occasionally, almost never needs it, and forcing the pattern there just adds indirection for no measurable win.