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.

Survival arena screenshot

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, pointer casts, manual =destroy hooks)
  • 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 and setLens 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:

  1. Thousands of homogeneous elements
  2. A narrow hot loop (touches 2–3 fields, nothing else)
  3. 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:

  1. You have 10k+ entities in a single archetype
  2. You have a hot system that iterates over them every frame touching only 1–2 fields
  3. 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:

SubsystemArchitectureWhy
Agents (player + enemies)Plain structs: seq[Agent]Complex per-entity logic, many fields, direct access
ProjectilesPlain structs: seq[Projectile]Small count, simple struct, frequent spawn/despawn
ParticlesSoA: grouped arrays by access pattern4000 entities, narrow hot loop, batch update
CollisionSpatial hash gridO(1) neighbor queries for push-apart resolution
CameraDirect structOne 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

Popular posts from this blog

Using NimScript for your build system

Naylib Goes Mobile: Porting to Android in Just 3 Days!

An introduction to ECS by example