Start simple. Stay simple. Most games never need ECS.
The game we just built — a survival arena with hundreds of agents, projectiles, particles, spatial queries, and camera-following — runs in a fraction of a millisecond using nothing more exotic than seq[Agent] and seq[Color]. The entire architecture is plain Nim: types, arrays, and procs. There is no framework, no query layer, no entity manager, no component registration, no archetype migration.
This is not a compromise. It is the optimal answer for the vast majority of games.
ECS solves a real problem: cache-efficient iteration over heterogeneous entity collections when you need to query "all entities that have components X and Y but not Z." That is a genuine requirement — for some games, in some systems, some of the time.
But ECS is rarely the right starting architecture. Here is why:
Before you write a single line of gameplay, you must build:
- An entity ID system (generation bits, slot recycling, version checks)
- A component storage layer (type-erased columns,
pointercasts, manual=destroyhooks) - A query system (signature bitmasks, archetype graphs, or both)
- An archetype migration system (moving data when components are added/removed)
This is hundreds of lines of infrastructure that produces zero gameplay. It is infrastructure you must debug, maintain, and explain to every new contributor.
Want to know what an enemy looks like? In MI-AoS, you write echo agents[3]. In ECS, you must query the transform column, the sprite column, the health column, assemble a debug view, and hope the indices align. Every inspection, every save/load, every serialization pass must gather-scatter across columns.
Our benchmarks showed ECS archetype SoA winning iteration systems by 1.5–3× at 50k+ entities. But at typical indie game scales (hundreds to low thousands of entities), the entire simulation runs in under 0.1ms. A 2× improvement on 0.05ms is invisible. You are trading significant complexity for performance you cannot perceive.
Our benchmarks showed ECS losing on:
- Systems that touch 3+ fields per entity (combat resolution, AI state machines)
- Structural operations (spawn/despawn — the cost scales with column count)
- Wide-field rendering passes (cull needs pos + sprite; separate columns double cache misses)
ECS wins narrowly: batch iteration over 1–2 fields across many entities. That describes particle systems and maybe pathfinding — not your core gameplay loop.
Model entities as plain structs, stored in per-type arrays, processed by per-system procs.
This is what the survival arena does. It is what most professional game engines did before ECS became fashionable. It is what you should reach for first.
type
Agent = object
pos, vel: Vector2
hp, maxHp: float32
cooldown: float32
state: AIState
kind: AgentKind
alive: bool
World = object
agents: seq[Agent]
projectiles: seq[Projectile]
# ...- Direct field access.
agent.pos.x += agent.vel.x * dt. No column lookup, no indirection, no gather. The compiler sees real field offsets and optimizes aggressively. - One-cache-line entities. A 48-byte Agent fits in a single cache line. AI, combat, movement — all touch one fetch per entity.
- Trivial debugging.
echo agents[i]shows the full state. Set a breakpoint anywhere. Inspect any field. No tooling needed. - Trivial serialization. Write the seq to disk. Read it back. Done.
- Trivial refactoring. Add a field, remove a field, split a type. The compiler finds every call site. No component registration to update.
- Structural operations are cheap. Spawn is one
add(). Despawn is one swap-remove +setLen. No N-column migration.
When two entity types share no systems and have very different sizes, separate them:
World = object
agents: seq[Agent] # complex, 48+ bytes, many systems
buildings: seq[Building] # simple, 16 bytes, few systemsThis is not ECS. It is just normal data modeling. You group by type, not by field.
Entities will reference each other: targets, parents, owners. You have three options.
Typed indices (recommended for most cases):
type
Agent = object
targetIdx: int32 # -1 = none, index into agents[]
# Access:
let target = agents[agent.targetIdx]- Pro: Simple, stable across moves, type-safe with
distinct int32. - Con: Dangling indices if target is removed. Use generation counters if this matters.
Flat entity indices (for cross-type references):
type
Game = object
agents: seq[Agent]
buildings: seq[Building]
# A projectile needs to reference either:
proj: seq[Projectile] # targetKind + targetIdx- Use when references cross type boundaries. Add a
kindfield to disambiguate. - This is what the survival arena does implicitly (projectiles only target agents).
Pointers (avoid):
Agent = object
target: ptr Agent # or ref Agent- Pro: Direct access.
- Con: Dangles on removal, breaks with reallocation, defeats the purpose of value-type arrays. Only safe with arena allocators or fixed pools.
Rule of thumb: Start with typed indices. Add generation bits only if you observe bugs from stale references. Never use raw pointers into a seq.
Overloaded procs (recommended):
proc update(a: var Agent, dt: float32) = ...
proc update(b: var Building, dt: float32) = ...
proc update(p: var Projectile, dt: float32) = ...Nim resolves at compile time. Zero runtime cost. This is what the survival arena does.
Object variants (for mixed-type collections):
type
EntityKind = enum entAgent, entBuilding, entProjectile
Entity = object
case kind: EntityKind
of entAgent: agentFields: AgentFields
of entBuilding: buildingFields: BuildingFields
of entProjectile: projectileFields: ProjectileFields- Use when you genuinely need a single heterogeneous collection.
- Pays an enum check per access. Avoid for hot loops.
Concepts (for generic algorithms):
type Movable = concept x
x.pos is Vector2
x.vel is Vector2
proc move(m: var Movable, dt: float32) =
m.pos += m.vel * dt- Use when you want to write algorithms that work across types without a shared base.
- Zero runtime cost. Compile-time duck typing.
Vtables (avoid unless FFI requires them):
The macro-based interface systems we analyzed at the start of this conversation are powerful but unnecessary for pure Nim code. Use them only when crossing FFI boundaries (C libraries expecting function pointers) or when you need runtime type erasure (plugins, scripting).
Use SoA within a single subsystem when you have:
- Thousands of homogeneous elements
- A narrow hot loop (touches 2–3 fields, nothing else)
- No cross-entity logic (no gather-scatter per element)
The particle system in the survival arena is the textbook case:
type
ParticleBody = object # accessed together by physics
pos: Vector2
vel: Vector2
ParticleSystem = object
bodies: seq[ParticleBody] # physics hot loop — one array
life: seq[float32] # decay — separate
color: seq[Color] # appearance — don't split further
count: int32Do not split vectors. A Vector2 (8 bytes) stored as seq[Vector2] is one array; splitting into posX, posY: seq[float32] doubles the number of memory streams the prefetcher must track, triples the compact-copy cost, and prevents auto-vectorization. Benchmarks show seq[Vector2] is 1.5× faster than split floats and only marginally slower than grouping pos+vel into a single Body struct. Split by access pattern: fields always read/written together in the same loop belong in the same struct.
Do not use SoA for your main entities. The gain is marginal at indie scale and the cost (gather-scatter in every system that touches 3+ fields) is real.
Spatial hash grid (recommended for 2D games):
type SpatialGrid = object
buckets: array[GridCols * GridRows, seq[int32]]- O(1) average insert and query.
- Rebuilt each frame (cheap — just clear and reinsert).
- Handles hundreds of agents with no sweat.
- This is what the survival arena uses.
Brute force (for <100 entities):
for i in 0..<agents.len:
for j in i+1..<agents.len:
if overlaps(agents[i], agents[j]): resolve(agents[i], agents[j])- O(n²) but the constant factor is tiny. For 50 agents, this is 1,225 checks — under 0.01ms.
- Simpler than any spatial structure. Use it until profiling says otherwise.
Quadtree/BVH (for very large or 3D worlds):
- More complex to implement and debug.
- Worth it for static geometry (level collision) or very large open worlds.
- Overkill for most 2D action games.
One proc per system, iterating the relevant array:
proc updateAI(g: var Game, dt: float32) =
for i in 0..<g.agents.len:
if g.agents[i].kind != agEnemy: continue
# AI logic here, direct field access
proc updateMovement(g: var Game, dt: float32) =
for i in 0..<g.agents.len:
g.agents[i].pos += g.agents[i].vel * dt- Systems are just loops. No query language, no archetype matching.
- Filter by a
kindenum oralivebool when needed. - Order systems logically: input → AI → movement → collision → projectiles → particles.
Do not over-decouple. A common mistake is building an event bus or message system so that "the combat system doesn't know about the particle system." At indie scale, direct calls are clearer:
# In updateProjectiles, when a hit happens:
g.particles.spawn(hitPos, 5, Gold)That is one line, immediately understandable. An event system would require: define event, queue it, dispatch it, handle it in particle system, debug the ordering. Direct calls win.
Use ECS when all three conditions are met:
- You have 10k+ entities in a single archetype
- You have a hot system that iterates over them every frame touching only 1–2 fields
- Profiling confirms that iteration is a bottleneck (>1ms per frame)
In practice, this means: particle systems, grass rendering, crowd simulation, or massive RTS units. For everything else — agents, items, buildings, UI, inventory — MI-AoS is faster to write, faster to debug, and fast enough to run.
If you do hit this case, do not build a general ECS framework. Write a targeted SoA structure for that one subsystem, exactly like the particle system in the survival arena. Keep the rest of the game in plain structs.
The survival arena demonstrates the complete picture:
| Subsystem | Architecture | Why |
|---|---|---|
| Agents (player + enemies) | MI-AoS: seq[Agent] |
Complex per-entity logic, many fields, direct access |
| Projectiles | MI-AoS: seq[Projectile] |
Small count, simple struct, spawn/despawn频繁 |
| Particles | SoA: grouped arrays by access pattern | 3000+ entities, narrow hot loop, batch update |
| Collision | Spatial hash grid | O(1) neighbor queries for push-apart resolution |
| Camera | Direct struct | One object, updated once per frame |
Notice what is absent: no entity manager, no component registry, no archetype graph, no query builder, no ID generation system. The Game object holds arrays. Systems are procs that iterate those arrays. That is the entire architecture.
Do you have 10k+ entities in one type?
├── No → seq[YourType], done.
└── Yes → Does one hot system touch only 2-3 fields?
├── No → seq[YourType], maybe hot/cold split if struct > 128 bytes
└── Yes → SoA for that subsystem only, seq[YourType] for everything else
Building an ECS before you have gameplay. The ECS becomes the project, and the game never ships. Start with structs. Refactor to ECS only if profiling demands it.
Splitting vectors into posX, posY: seq[float32] or colors into colR, colG, colB separate arrays. For vectors, this doubles the number of memory streams the prefetcher must track, prevents auto-vectorization (SIMD), and increases compact-copy cost. Benchmarking shows seq[Vector2] is 1.5× faster than split floats. For colors, the same logic applies — a 4-byte Color gains nothing from being split into three 1-byte arrays. Split by access group, not by field. Fields always read/written together in the same loop belong in the same struct (e.g., pos+vel in a ParticleBody).
Using ptr Agent or ref Agent for entity cross-references. This dangles on removal, breaks on seq reallocation, and introduces GC pressure. Use indices.
Optimizing data layout when your real bottleneck is draw calls. Profile the whole frame. In the survival arena, simulation is <0.1ms; rendering is the rest. Data layout optimization on the simulation is irrelevant until draw calls are addressed.
Building "engine code" before "game code." Interfaces, plugins, event systems, component registries — all are answers to questions you have not asked yet. Write the game first. Extract abstractions when patterns emerge, not before.
When starting a new game in Nim:
- Define your entity types as plain objects. Group fields by entity, not by system.
- Store them in
seq[T]per type. One seq for agents, one for projectiles, one for items. - Use typed indices for references.
targetIdx: int32, not pointers. - Write systems as loops. One proc per system, iterating the relevant seq.
- Add a spatial structure only when brute-force collision is slow. Spatial hash grid for 2D, brute force for <100 entities.
- Use SoA only for particle-like systems. Thousands of elements, narrow update, batch processing.
- Profile before optimizing. The frame budget is 16ms. If your simulation is under 1ms, data layout does not matter.
- Keep it boring. The survival arena's architecture can be understood in 5 minutes by anyone who knows Nim. That is its greatest strength.