Architecting Games in Nim: A Pragmatic Guide
1. The Core Recommendation
Start simple. Stay simple. Most games never need ECS.
To test this claim, I built a top-down survival shooter in Nim using raylib. The player controls a blue circle in a 4000×3000 arena, moving with WASD and auto-firing at the nearest enemy. Red enemies spawn in waves, up to 500 at once, chasing the player on contact. Projectiles streak across the screen, particles burst on every hit and death, a spatial hash grid resolves collisions, and a camera follows the action. It is the kind of game that, on paper, sounds like it might need a real architecture.
It runs in a fraction of a millisecond per frame using nothing more exotic than seq[Agent], seq[Projectile], and a handful of parallel arrays for particles. The entire codebase 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.

2. Why ECS Is a Trap for Most Projects
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:
It Front-Loads Complexity
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.
It Makes Everything Indirect
Want to know what an enemy looks like? With plain structs, 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.
The Performance Gain Is Usually Irrelevant
My benchmarks showed archetype ECS 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.
The Performance Gain Is Not Universal
My benchmarks showed ECS losing on:
- Structural operations (spawn/despawn, where the cost scales with column count)
- Wide-field rendering passes (cull needs pos + sprite; separate columns double cache misses)
- Systems that read and write many fields per entity in a tight loop (projectile homing: 4 reads + 3 writes across separate columns)
ECS wins batch iteration over 1–2 fields across many entities, and also wins random-access systems where a narrow column footprint keeps more targets in cache. That describes particle systems, movement, and combat targeting, but not wide-field passes or structural churn.
3. The Default Architecture: Plain Structs in Arrays
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.
Structure
The game's Agent type holds everything an agent needs (position, velocity, health, kind, state) in a single flat struct:
type
AgentKind = enum
agPlayer, agEnemy
Agent = object
pos: Vector2
vel: Vector2
radius: float32
hp: float32
maxHp: float32
cooldown: float32
kind: AgentKind
alive: bool
Game = object
agents: seq[Agent]
projectiles: seq[Projectile]
particles: ParticleSystem
grid: SpatialGrid
playerIdx: int32
score: int32
camera: Camera2D
The Game object is just a container of arrays. The player is agents[0]. Enemies are agents[1..^1]. Projectiles live in their own seq. There is no entity ID, no component mask, no archetype lookup. The player is an Agent, an enemy is an Agent, and the compiler knows every field offset at compile time.
Why It Works
- Direct field access.
a.pos.x += a.vel.x * dt. No column lookup, no indirection, no gather. The compiler sees real field offsets and optimizes aggressively. - One-cache-line entities. The Agent struct is 36 bytes, fitting comfortably in a single 64-byte cache line. AI, combat, movement, and collision 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 a compaction loop that swaps dead agents out andsetLens the seq. No N-column migration.
When two entity types share no systems and have very different sizes, separate them:
Game = object
agents: seq[Agent] # complex, 36+ bytes, many systems
projectiles: seq[Projectile] # simple, 20 bytes, few systems
This is not ECS. It is just normal data modeling. You group by type, not by field.
4. Design Choices You Will Face
Entity References
The survival arena's projectiles need to hit enemies, enemies need to chase the player, and the player auto-fires at the nearest enemy. All of these are cross-entity references, and none of them use pointers. They use typed indices: an int32 that indexes into the relevant array, with -1 for "no reference":
# findNearestEnemy returns an index into agents[], or -1
let target = g.findNearestEnemy(p.pos)
if target >= 0:
g.fireAt(p.pos, target)
Simple, stable across array reallocation, and type-safe with distinct int32. The only downside is dangling indices if the target is removed; the game handles this by compacting dead agents at the end of each collision pass and resetting playerIdx to 0. Add generation counters only if you observe bugs from stale references in practice.
Never use ptr Agent or ref Agent for entity cross-references. Pointers dangle on removal, break when the seq reallocates, and introduce GC pressure. The benchmarks confirmed this: heap-allocated ref objects were consistently the slowest approach across every system, with combat running 2× slower than index-based access due to pointer chasing destroying prefetching.
Polymorphism
The survival arena has player agents and enemy agents in the same seq[Agent]. It does not use inheritance or vtables. It uses a kind: AgentKind field and filters in the system loop:
proc updateEnemies(g: var Game, dt: float32) =
for i in 0..<g.agents.len:
if g.agents[i].alive and g.agents[i].kind == agEnemy:
# AI: seek the player
For most games, overloaded procs are all you need for type-level polymorphism. Nim resolves them at compile time with zero runtime cost. If you genuinely need a single heterogeneous collection, use object variants (Nim's case fields). For generic algorithms across types, use concepts (compile-time duck typing with no runtime overhead). Avoid vtable-based dispatch unless you are crossing FFI boundaries or need runtime type erasure for plugins.
Collision Queries
With up to 500 enemies packed into a 4000×3000 world, brute-force O(n²) collision detection would mean 250,000 pairwise checks per frame. The survival arena uses a spatial hash grid instead: a 2D array of buckets where each cell covers a 48×48 pixel region.
type SpatialGrid = object
buckets: array[GridCols * GridRows, seq[int32]]
Each frame, the grid is cleared and rebuilt. Every agent is inserted into the buckets its bounding circle overlaps. To find potential collision partners, you query only the buckets near the agent. The average case is O(1), and the result set is tiny. For fewer than 100 entities, brute force is fast enough (50 agents is 1,225 checks, under 0.01ms). Use it until profiling says otherwise. Quadtrees and BVHs are worth the complexity only for static geometry or very large open worlds.
When to Use SoA
The survival arena has up to 4,000 particles in flight at once. Each particle has a position, velocity, lifetime, and color. The physics loop touches position and velocity; the render loop touches position, color, and lifetime. These are narrow, batch operations over thousands of homogeneous elements, the textbook case for Structure of Arrays.
The particle system splits fields by access group, not by individual field:
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: int32
The physics update streams through bodies in one tight loop. The render loop walks bodies and color together. Dead particles are compacted out in a single pass. This is SoA done right: grouped by access pattern, not blindly per-field.
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)
Do not split vectors into posX, posY: seq[float32]; this doubles the memory streams the prefetcher must track and prevents auto-vectorization. And do not use SoA for your main entities; the gain is marginal at indie scale and the gather-scatter cost in every system that touches 3+ fields is real.
5. When You Actually Need ECS
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), plain structs in arrays are 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 above. Keep the rest of the game in plain structs.
6. The Hybrid Architecture in Practice
The survival arena demonstrates the complete picture:
| Subsystem | Architecture | Why |
|---|---|---|
| Agents (player + enemies) | Plain structs: seq[Agent] | Complex per-entity logic, many fields, direct access |
| Projectiles | Plain structs: seq[Projectile] | Small count, simple struct, frequent spawn/despawn |
| Particles | SoA: grouped arrays by access pattern | 4000 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 |
The main loop is flat and readable, each system a proc call in sequence:
proc updateDrawFrame(g: var Game) =
let dt = getFrameTime()
g.updatePlayer(dt)
g.updateEnemies(dt)
g.resolveCollisions()
g.updateProjectiles(dt)
g.updateSpawn(dt)
g.particles.update(dt)
g.updateCamera()
drawing():
g.drawWorld()
g.drawHUD()
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.
The Decision Tree
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
7. Common Pitfalls
The same mistakes come up repeatedly when developers reach for ECS before they need it.
Building ECS before gameplay. The framework becomes the project, and the game never ships. Start with structs. Refactor to ECS only if profiling demands it, and by then, you will know exactly which system needs it and why.
Using pointers for entity references. ptr Agent or ref Agent dangles on removal, breaks on seq reallocation, and introduces GC pressure. The benchmarks were unambiguous: heap-allocated ref objects were the slowest approach across every system, with combat running 2× slower than index-based access. Use indices.
Optimizing data layout when draw calls are the bottleneck. Profile the whole frame. In the survival arena, the entire simulation (AI, movement, combat, collisions, projectiles, particles) runs in under 0.1ms. Rendering is the rest. Tuning cache lines in the simulation is irrelevant until draw calls are addressed.
Abstracting too early. 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.
8. Conclusion
The survival arena is a small example, but it is representative of the scale most indie games operate at: a few hundred agents, a few thousand particles, real-time collision detection, and a camera. And yet the entire architecture is a handful of structs in arrays, processed by plain procs, with one targeted SoA detour for the particle system.
The benchmarks back this up. At indie game scales, plain structs win or tie on every system that matters, and the cases where archetype ECS pulls ahead (narrow batch iteration over 50k+ entities) are precisely the cases that don't appear in most games. When they do, a focused SoA layout for that one subsystem captures the benefit without the framework tax.
The survival arena's architecture can be understood in five minutes by anyone who knows Nim. That is not a sign of simplicity; it is a sign of clarity. The hardest part of game development is not building the engine; it is building the game. Every hour spent on infrastructure is an hour not spent on the thing that makes your game worth playing.
Comments
Post a Comment