Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save planetis-m/c381316c81b721d2126646f7d648fba0 to your computer and use it in GitHub Desktop.

Game System Design Protocol for AI Agents (Nim)

Directive

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.


Phase 1: Classify the Subsystem

Before writing any code, classify every subsystem the user describes into one of three categories. This classification determines the data layout.

Category A — Gameplay Entities

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.

Category B — Mass-Processed Elements

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.

Category C — Singletons and Infrastructure

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.

Classification Rules

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

Phase 2: Define Entity Types

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

Rules

  1. 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 kind enum discriminator.
  2. Fields are flat. Embed sub-objects directly (pos: Vector2, not pos: ptr Vector2). No pointers, no refs.
  3. References are typed indices (targetIdx: int32, parentIdx: int32). Use -1 for "no reference." Never use pointers into seqs.
  4. State is explicit. Include alive: bool or active: bool for lifecycle. Include kind: EntityKind if the seq holds mixed types.
  5. No inheritance. Do not use object of RootObj for gameplay entities. Prefer composition (embedded fields) and overloaded procs.

Output Template

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

Phase 3: Define the World Object

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

Rules

  1. One seq per Category A entity type. agents: seq[Agent], projectiles: seq[Projectile].
  2. One Category B subsystem per group of parallel arrays. Group them in a dedicated object (ParticleSystem), not as loose fields on World.
  3. Category C fields directly on World. camera: Camera2D, score: int32, paused: bool.
  4. Transient work buffers on World. queryResult: seq[int32] for the spatial grid, reused each frame. Do not allocate per-frame.
  5. No pointers, no refs on World. Everything is a value type. This makes save/load trivial.

Output Template

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]

Phase 4: Design Systems

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.

Ordering

Systems run in a fixed order each frame:

  1. Input — read keyboard/mouse, update player intent
  2. AI — decide actions for non-player entities
  3. Movement — integrate velocity into position
  4. Collision — resolve overlaps (spatial grid if needed)
  5. Combat/Interaction — apply damage, trigger events
  6. Projectiles — move, check hits, expire
  7. Spawn/Despawn — compact dead entities, spawn new ones
  8. Particles — update SoA particle system
  9. Camera — follow target, clamp bounds

Implementation Rules

  1. 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. Use for a in w.agents.mitems only for single-entity transforms.
  2. Filter with early continue. if not a.alive: continue. Do not build separate filtered arrays.
  3. Cross-entity access via index. let target = w.agents[a.targetIdx]. Validate the index is ≥0 before use.
  4. 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.
  5. 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.

Output Template

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)

Phase 5: Design Collision (If Needed)

Decision

  • < 100 entities total: brute-force O(n²). Nested loop, checkCollisionCircles or distance check.
  • 100–10,000 entities: spatial hash grid.
  • > 10,000 or 3D: quadtree or BVH (only if profiling demands it).

Spatial Hash Grid Implementation

  1. Fixed grid dimensions based on world size and cell size. Cell size ≈ 2× average entity radius or ≈ 48 pixels.
  2. buckets: array[GridCols * GridRows, seq[int32]] — each bucket holds entity indices for that cell.
  3. Clear and rebuild every frame. This is cheaper than incremental updates.
  4. Insert: add entity index to every cell its bounding circle overlaps.
  5. Query: collect indices from overlapping cells, deduplicate consecutive entries.
  6. Collision resolution: for each entity, query nearby, test pairs, resolve overlaps.

Rules

  1. Store the grid on World, not as a global.
  2. Store the query result buffer (queryResult: seq[int32]) on World. Reuse it every frame. Never allocate inside the loop.
  3. Insert only alive entities. Query only for alive entities.
  4. Skip pairs where j <= i to avoid double-processing.

Phase 6: Design SoA Subsystems (Category B Only)

For subsystems classified as Category B in Phase 1.

Rules

  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.
  2. Never split vectors. A Vector2 (8 bytes) stays as seq[Vector2] — splitting into posX, posY: seq[float32] doubles memory streams, prevents SIMD auto-vectorization, and is 1.5× slower in benchmarks.
  3. Never split types smaller than 8 bytes. A Color (4 bytes) stays as seq[Color], never split into channel arrays.
  4. Use seq, not raw pointer allocations. newSeq[Vector2](capacity) pre-allocates. count tracks active elements.
  5. Compact in-place during update: read index and write index advance independently, dead elements skipped.
  6. Draw reads count, not seq.len. The seq is pre-allocated to capacity; only count elements are live.

Output Template

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

Phase 7: Entity Lifecycle

Spawn

  • seq.add(Entity(...)). Pre-allocate with newSeqOfCap if the initial count is known.
  • For SoA systems, increment count and write to each array at count.

Despawn

  • Category A: set alive = false. After all systems run, compact with swap-remove. One pass, O(n).
  • Category B: decrement life during update. Compact in-place during the same loop.

Rules

  1. Never remove from a seq during iteration. Mark dead, compact after.
  2. Compact once per frame, after all systems, before draw.
  3. 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.
  4. For SoA systems, compaction happens inside the update loop (read/write index pattern). No separate pass.

Phase 8: Rendering

Rules

  1. Draw in world space inside mode2D(camera). Draw HUD outside.
  2. Iterate alive entities only. if a.alive: drawAgent(a).
  3. 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.
  4. Outlines darker than fill. drawCircle(pos, radius, SkyBlue) then drawCircleLines(pos, radius, DarkBlue). Never outline with pure White on a bright fill — it creates visual noise.
  5. HUD text at standard sizes: 10 for info/controls, 14–20 for stats, 40 for overlays. Use drawFPS(screenWidth - 80, 10) for FPS.
  6. Center overlay text with measureText(text, size).

Phase 9: Code Organization

File Structure for a Single-File Game

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

Rules

  1. One file for most games. Split only when a single file exceeds ~800 lines or when subsystems are independently reusable.
  2. No modules until needed. A Game object with procs is not a module system. It is a program.
  3. No config files, no ECS frameworks, no component registries. The types and the World object are the architecture.

Phase 10: Anti-Patterns to Reject

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

Quick Reference: Decision Flowchart

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

Agent Self-Check

Before outputting the final code, verify:

  • Every entity type is a plain object, not ref object or object of RootObj
  • All cross-references are int32 indices, 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 into posX, 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment