When asked to architect, implement, or review a game system in Nim, follow this protocol sequentially. Do not skip steps. Do not introduce abstractions not prescribed here unless the user explicitly requests them.
Before writing any code, classify every subsystem the user describes into one of three categories. This classification determines the data layout.
Agents, items, buildings, units, player, enemies. Characterized by:
- Complex per-entity logic (AI, inventory, state machines)
- Multiple fields (6+), accessed by multiple different systems
- Counts in the hundreds to low thousands
- Frequent spawn/despawn, field mutation, cross-references
Layout: MI-AoS (struct array). No exceptions.
Particles, grass blades, crowd agents, projectiles in a bullet hell. Characterized by:
- One or two narrow hot loops touching 2–3 fields each
- Counts in the thousands or higher
- No per-element branching logic
- Batch spawn, batch update, batch draw
Layout: SoA (parallel arrays). Only if count > 1000 AND the hot loop touches ≤3 fields. Otherwise, treat as Category A.
Camera, input state, audio manager, render queue, game state. Characterized by:
- One instance, updated once per frame
- No iteration over collections
Layout: Plain struct fields on the World/Game object.
- When unsure between A and B, choose A. SoA is the optimization, not the default.
- A subsystem is B only if it would have 1000+ elements and its update loop touches ≤3 fields with no branching.
- Never classify a subsystem as "both" or "hybrid." Pick one. The code can be refactored later if profiling demands it.
For each Category A subsystem, define a plain Nim object with all fields grouped by entity. Do not group by system, do not split into separate types for "components."
- One object per entity archetype. If Agent and Building share no systems, they are separate types. If they do, they are one type with a
kindenum discriminator. - Fields are flat. Embed sub-objects directly (
pos: Vector2, notpos: ptr Vector2). No pointers, no refs. - References are typed indices (
targetIdx: int32,parentIdx: int32). Use-1for "no reference." Never use pointers into seqs. - State is explicit. Include
alive: booloractive: boolfor lifecycle. Includekind: EntityKindif the seq holds mixed types. - No inheritance. Do not use
object of RootObjfor gameplay entities. Prefer composition (embedded fields) and overloaded procs.
type
AgentKind = enum agPlayer, agEnemy
Agent = object
pos: Vector2
vel: Vector2
radius: float32
hp: float32
maxHp: float32
cooldown: float32
kind: AgentKind
alive: bool
# cross-references as indices
targetIdx: int32
Projectile = object
pos: Vector2
vel: Vector2
life: float32
active: boolAll entity arrays and infrastructure live on a single World (or Game) object. This is the only global state. Pass it explicitly to every system proc.
- One seq per Category A entity type.
agents: seq[Agent],projectiles: seq[Projectile]. - One Category B subsystem per group of parallel arrays. Group them in a dedicated object (
ParticleSystem), not as loose fields on World. - Category C fields directly on World.
camera: Camera2D,score: int32,paused: bool. - Transient work buffers on World.
queryResult: seq[int32]for the spatial grid, reused each frame. Do not allocate per-frame. - No pointers, no refs on World. Everything is a value type. This makes save/load trivial.
type
World = object
# Category A — entity arrays
agents: seq[Agent]
projectiles: seq[Projectile]
# Category B — mass-processed SoA
particles: ParticleSystem
# Category C — singletons
grid: SpatialGrid
camera: Camera2D
score: int32
gameOver: bool
# Transient buffers
queryResult: seq[int32]Each system is a proc that takes var World and dt: float32 (where applicable). Systems iterate the relevant seq directly. No query layer, no component masks, no event bus.
Systems run in a fixed order each frame:
- Input — read keyboard/mouse, update player intent
- AI — decide actions for non-player entities
- Movement — integrate velocity into position
- Collision — resolve overlaps (spatial grid if needed)
- Combat/Interaction — apply damage, trigger events
- Projectiles — move, check hits, expire
- Spawn/Despawn — compact dead entities, spawn new ones
- Particles — update SoA particle system
- Camera — follow target, clamp bounds
- Iterate by index, not by iterator (
for i in 0..<w.agents.len), when you need to read/write other entities by index during the loop. Usefor a in w.agents.mitemsonly for single-entity transforms. - Filter with early continue.
if not a.alive: continue. Do not build separate filtered arrays. - Cross-entity access via index.
let target = w.agents[a.targetIdx]. Validate the index is ≥0 before use. - Direct calls for cross-system effects. When a projectile kills an enemy, call
w.particles.spawn(pos, 20, Maroon)directly. Do not queue an event. - Compact dead entities at the end of the frame, not during iteration. Swap-remove into the gap, then
setLen. Fix the player index if needed.
proc updateAI(w: var World, dt: float32) =
let playerPos = w.agents[w.playerIdx].pos
for i in 0..<w.agents.len:
let a = addr w.agents[i]
if not a.alive or a.kind == agPlayer: continue
# seek player
let dir = normalize(playerPos - a.pos)
a.vel = dir * EnemySpeed
a.cooldown -= dt
proc resolveCollisions(w: var World) =
# uses spatial grid — see Phase 5
...
proc compactAgents(w: var World) =
var writeIdx = 0
for i in 0..<w.agents.len:
if w.agents[i].alive:
if writeIdx != i: w.agents[writeIdx] = w.agents[i]
inc writeIdx
w.agents.setLen(writeIdx)- < 100 entities total: brute-force O(n²). Nested loop,
checkCollisionCirclesor distance check. - 100–10,000 entities: spatial hash grid.
- > 10,000 or 3D: quadtree or BVH (only if profiling demands it).
- Fixed grid dimensions based on world size and cell size. Cell size ≈ 2× average entity radius or ≈ 48 pixels.
buckets: array[GridCols * GridRows, seq[int32]]— each bucket holds entity indices for that cell.- Clear and rebuild every frame. This is cheaper than incremental updates.
- Insert: add entity index to every cell its bounding circle overlaps.
- Query: collect indices from overlapping cells, deduplicate consecutive entries.
- Collision resolution: for each entity, query nearby, test pairs, resolve overlaps.
- Store the grid on World, not as a global.
- Store the query result buffer (
queryResult: seq[int32]) on World. Reuse it every frame. Never allocate inside the loop. - Insert only alive entities. Query only for alive entities.
- Skip pairs where
j <= ito avoid double-processing.
For subsystems classified as Category B in Phase 1.
- 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). Fields read/written by different systems go in separate arrays. - Never split vectors. A
Vector2(8 bytes) stays asseq[Vector2]— splitting intoposX, posY: seq[float32]doubles memory streams, prevents SIMD auto-vectorization, and is 1.5× slower in benchmarks. - Never split types smaller than 8 bytes. A
Color(4 bytes) stays asseq[Color], never split into channel arrays. - Use
seq, not raw pointer allocations.newSeq[Vector2](capacity)pre-allocates.counttracks active elements. - Compact in-place during update: read index and write index advance independently, dead elements skipped.
- Draw reads count, not seq.len. The seq is pre-allocated to capacity; only
countelements are live.
type
ParticleBody = object # accessed together every physics frame
pos: Vector2
vel: Vector2
ParticleSystem = object
bodies: seq[ParticleBody] # physics hot loop
life: seq[float32] # decay — separate
color: seq[Color] # appearance — don't split further
count: int32seq.add(Entity(...)). Pre-allocate withnewSeqOfCapif the initial count is known.- For SoA systems, increment
countand write to each array atcount.
- Category A: set
alive = false. After all systems run, compact with swap-remove. One pass, O(n). - Category B: decrement
lifeduring update. Compact in-place during the same loop.
- Never remove from a seq during iteration. Mark dead, compact after.
- Compact once per frame, after all systems, before draw.
- After compaction, fix index references. If the player is always index 0 (compacted first or never dies), this is trivial. Otherwise, track the player's new index after compaction.
- For SoA systems, compaction happens inside the update loop (read/write index pattern). No separate pass.
- Draw in world space inside
mode2D(camera). Draw HUD outside. - Iterate alive entities only.
if a.alive: drawAgent(a). - Use raylib's built-in colors (SkyBlue, Maroon, Gold, Orange, DarkGreen, Red, White, Lime, DarkGray, LightGray). Do not invent custom
Color(r: ..., g: ..., b: ..., a: 255)values unless the user requests a specific palette. - Outlines darker than fill.
drawCircle(pos, radius, SkyBlue)thendrawCircleLines(pos, radius, DarkBlue). Never outline with pure White on a bright fill — it creates visual noise. - HUD text at standard sizes: 10 for info/controls, 14–20 for stats, 40 for overlays. Use
drawFPS(screenWidth - 80, 10)for FPS. - Center overlay text with
measureText(text, size).
1. Header comment (raylib convention)
2. Imports
3. Constants
4. Type definitions (entities, world, subsystems)
5. Subsystem procs (particles, spatial grid)
6. Game logic procs (init, reset, spawn, update systems)
7. Drawing procs (drawWorld, drawHUD)
8. Frame proc (updateDrawFrame)
9. main() entry point
- One file for most games. Split only when a single file exceeds ~800 lines or when subsystems are independently reusable.
- No modules until needed. A
Gameobject with procs is not a module system. It is a program. - No config files, no ECS frameworks, no component registries. The types and the World object are the architecture.
When reviewing or generating code, reject the following:
| Pattern | Why | Instead |
|---|---|---|
ref Agent for gameplay entities |
GC pressure, pointer chasing, cache misses | seq[Agent] with index references |
ptr Agent for cross-references |
Dangles on reallocation or removal | targetIdx: int32 |
| Component structs with one field each | False SoA — adds indirection for no gain | Group fields in the entity struct |
seq[uint8] for color channels |
Over-splitting — 3 arrays for 4 bytes | seq[Color] |
posX, posY: seq[float32] |
Splitting vectors doubles memory streams, prevents SIMD, 1.5× slower | seq[Vector2] or group pos+vel in ParticleBody |
| Event bus / message queue | Indirection without benefit at this scale | Direct proc calls |
object of RootObj for entities |
Inheritance adds vtable overhead, restricts layout | Plain object, overloaded procs |
| Separate query/filter system | Iteration with if continue is faster to write and run |
for i in 0..<len: if not alive: continue |
Raw alloc/dealloc for entity storage |
Manual memory management for no gain | seq[T] with add/setLen |
keepIf / filter for compaction |
Allocates a new seq, FP style | In-place swap-remove compact |
Entity count > 1000 AND hot loop touches ≤3 fields?
├── Yes → Category B: SoA parallel arrays
Split by access group: group fields always accessed together (e.g., pos+vel in a Body struct). Separate fields accessed by different systems.
└── No → Category A: MI-AoS struct array
One object per type. Index references. Direct field access.
Need collision queries?
├── < 100 entities → brute force O(n²)
└── ≥ 100 entities → spatial hash grid, rebuilt per frame
Need polymorphism?
├── Compile-time → overloaded procs
├── Cross-type collection → object variant (kind enum)
└── Generic algorithm → concept
Need to remove entities?
└── Mark dead → compact once per frame → fix indices
Before outputting the final code, verify:
- Every entity type is a plain
object, notref objectorobject of RootObj - All cross-references are
int32indices, not pointers - The World object holds all state — no globals except the World instance
- Every system proc takes
var World— no hidden state - Dead entities are compacted once per frame, not during iteration
- SoA is used only for subsystems with 1000+ elements and narrow hot loops
- SoA splits by access group (Body struct for pos+vel), not by individual fields (no posX/posY)
- Color is
seq[Color], not split into separate channel arrays - Vectors are
seq[Vector2], never split intoposX, posY: seq[float32] - No event bus, no component registry, no ECS framework
- No
keepIf,filter, or other FP-style seq operations in hot paths - Spatial grid query buffer is reused, not allocated per frame