Skip to content

Instantly share code, notes, and snippets.

@planetis-m
Last active July 3, 2026 18:44
Show Gist options
  • Select an option

  • Save planetis-m/6e211cd93ee5a0247324d8128d7048e1 to your computer and use it in GitHub Desktop.

Select an option

Save planetis-m/6e211cd93ee5a0247324d8128d7048e1 to your computer and use it in GitHub Desktop.
Architecting Games in Nim: A Pragmatic Guide

Architecting Games in Nim: A Pragmatic Guide

1. The Core Recommendation

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.


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? 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.

The Performance Gain Is Usually Irrelevant

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.

The Performance Gain Is Not Universal

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.


3. The Default Architecture: MI-AoS

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

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]
    # ...

Why It Works

  • 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 to Split Into Multiple Arrays

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 systems

This is not ECS. It is just normal data modeling. You group by type, not by field.


4. Design Choices You Will Face

Choice 1: How to Model Entity References

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 kind field 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.

Choice 2: How to Do Polymorphism

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).

Choice 3: When to Use SoA

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)

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: int32

Do 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.

Choice 4: How to Handle Collision Queries

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.

Choice 5: How to Structure Systems

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 kind enum or alive bool 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.


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 — 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.


6. The Hybrid Architecture in Practice

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.

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

Premature ECS

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.

Over-Splitting SoA

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).

Pointer-Based Entity References

Using ptr Agent or ref Agent for entity cross-references. This dangles on removal, breaks on seq reallocation, and introduces GC pressure. Use indices.

Ignoring the Frame Budget

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.

Abstracting Too Early

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.


8. Final Checklist

When starting a new game in Nim:

  1. Define your entity types as plain objects. Group fields by entity, not by system.
  2. Store them in seq[T] per type. One seq for agents, one for projectiles, one for items.
  3. Use typed indices for references. targetIdx: int32, not pointers.
  4. Write systems as loops. One proc per system, iterating the relevant seq.
  5. Add a spatial structure only when brute-force collision is slow. Spatial hash grid for 2D, brute force for <100 entities.
  6. Use SoA only for particle-like systems. Thousands of elements, narrow update, batch processing.
  7. Profile before optimizing. The frame budget is 16ms. If your simulation is under 1ms, data layout does not matter.
  8. Keep it boring. The survival arena's architecture can be understood in 5 minutes by anyone who knows Nim. That is its greatest strength.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment