Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

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

Results are stable across 3 runs. The pattern holds:

System ECS-Arch MI-AoS Consistency
ai_update ~2.13 ~1.71-2.93 Noisy — genuine tie (data fits L2)
move ~0.76 ~1.19 Stable — Arch wins 1.6x
combat ~0.27 ~0.45 Stable — Arch wins 1.6x
projectile ~1.12 ~0.74 Stable — AoS wins 1.5x
cull ~2.8 ~0.55 Rock solid — AoS wins 5x
despawn ~0.002 ~0.001 Stable — AoS wins 2x

Only ai_update shows variance (the MI-AoS run 2 spike to 2.93 is a scheduling outlier). Every other system is within ~5% across runs. The crossover pattern is reliable: archetype wins narrow-field batch iteration, AoS wins wide-field per-entity access and structural ops.

## Archetype ECS vs MI-AoS — Realistic Game Benchmark
## ===================================================
## A tactical combat arena tick. Tests both uniform batch systems
## (where archetype SoA shines) and per-entity complex logic
## (where AoS struct locality should win).
##
## Systems per frame:
## 1. aiUpdate — per-agent state machine (idle/seek/attack/flee)
## reads: pos, hp, target, cooldown, aiState
## writes: vel, aiState, cooldown
## 2. move — velocity integration + bounds clamp
## reads: pos, vel. writes: pos
## 3. combat — if target in range & cooldown ready: deal damage
## reads: pos(self+target), cooldown. writes: hp(target), cooldown
## 4. projectile — homing steer + lifetime decay + hit detection
## reads: pos, vel, target pos, lifetime. writes: pos, vel, lifetime
## 5. cull — viewport visibility count
## reads: pos, sprite
## 6. spawn — create projectiles
## 7. despawn — remove dead projectiles
import std/[times, monotimes, strformat, strutils, math]
const
NBuildings = 1_000
NAgents = 8_000
NProjectiles = 2_000
N = NBuildings + NAgents + NProjectiles
Frames = 500
SpawnBatch = 200
SpawnIters = 100
Dt = 0.016'f32
ScreenW = 1920'f32
ScreenH = 1080'f32
AttackRange = 50.0'f32
AttackRangeSq = AttackRange * AttackRange
AttackDamage = 8.0'f32
AttackCooldown = 0.5'f32
FleeHpThreshold = 0.25'f32
SeekSpeed = 30.0'f32
FleeSpeed = 50.0'f32
ProjHomingStrength = 3.0'f32
ProjLifetime = 2.0'f32
ProjSpeed = 200.0'f32
ArmorReduction = 0.3'f32 # damage *= (1 - armor * 0.1)
var sink: float32 = 0.0
var aliveCount: int = 0
var rngState = 42'u32
proc rng(): int =
rngState = rngState * 1664525'u32 + 1013904223'u32
result = int(rngState shr 16)
type
Vec2f = tuple[x, y: float32]
SpriteC = tuple[color: uint32, size: float32]
template v2(x, y: float32): Vec2f = (x, y)
template lenSq(v: Vec2f): float32 = v.x*v.x + v.y*v.y
template norm(v: Vec2f): Vec2f =
let l = sqrt(v.x*v.x + v.y*v.y)
if l > 0.0001'f32: (v.x / l, v.y / l) else: (0'f32, 0'f32)
# ================================================================
# 1. ECS-Archetype — SoA columns within archetype buckets
# ================================================================
type
ComponentKind = enum
ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite,
ckParent, ckLifetime, ckAI
ArchetypeKind = enum
BuildingArch, AgentArch, ProjectileArch
TransformC = object
pos: Vec2f
worldPos: Vec2f
VelocityC = object
vel: Vec2f
HealthC = object
hp: float32
maxHp: float32
TargetC = object
targetIdx: int32
SpriteCol = object
sprite: SpriteC
ParentC = object
parentIdx: int32
LifetimeC = object
lifetime: float32
AIC = object
cooldown: float32
state: uint8 # 0=idle 1=seek 2=attack 3=flee
armor: float32
Archetype = object
mask: set[ComponentKind]
columns: array[ComponentKind, pointer]
ArchWorld = object
archetypes: array[ArchetypeKind, Archetype]
# --- column memory management ---
proc newColumn[T](): pointer = create(seq[T])
proc destroyColumn[T](column: pointer) =
if column != nil:
`=destroy`(cast[ptr seq[T]](column)[])
dealloc(column)
proc `=destroy`(x: var Archetype) =
if ckTransform in x.mask: destroyColumn[TransformC](x.columns[ckTransform])
if ckVelocity in x.mask: destroyColumn[VelocityC](x.columns[ckVelocity])
if ckHealth in x.mask: destroyColumn[HealthC](x.columns[ckHealth])
if ckTarget in x.mask: destroyColumn[TargetC](x.columns[ckTarget])
if ckSprite in x.mask: destroyColumn[SpriteCol](x.columns[ckSprite])
if ckParent in x.mask: destroyColumn[ParentC](x.columns[ckParent])
if ckLifetime in x.mask: destroyColumn[LifetimeC](x.columns[ckLifetime])
if ckAI in x.mask: destroyColumn[AIC](x.columns[ckAI])
proc `=wasMoved`(x: var Archetype) =
x.mask = {}
for ck in ComponentKind: x.columns[ck] = nil
proc `=copy`(dest: var Archetype; src: Archetype) {.error.}
proc `=dup`(src: Archetype): Archetype {.error.}
proc `=destroy`(x: var ArchWorld) =
for kind in ArchetypeKind: `=destroy`(x.archetypes[kind])
proc `=wasMoved`(x: var ArchWorld) =
for kind in ArchetypeKind: `=wasMoved`(x.archetypes[kind])
proc `=copy`(dest: var ArchWorld; src: ArchWorld) {.error.}
proc `=dup`(src: ArchWorld): ArchWorld {.error.}
# --- column access ---
template col(w, kind, ck, T): untyped =
cast[ptr seq[T]](w.archetypes[kind].columns[ck])[]
template colData(w, kind, ck, T): untyped =
cast[ptr UncheckedArray[T]](addr col(w, kind, ck, T)[0])
template colLen(w, kind, ck, T): untyped =
col(w, kind, ck, T).len
proc initArchetype(mask: set[ComponentKind]): Archetype =
result = Archetype(mask: mask)
if ckTransform in mask: result.columns[ckTransform] = newColumn[TransformC]()
if ckVelocity in mask: result.columns[ckVelocity] = newColumn[VelocityC]()
if ckHealth in mask: result.columns[ckHealth] = newColumn[HealthC]()
if ckTarget in mask: result.columns[ckTarget] = newColumn[TargetC]()
if ckSprite in mask: result.columns[ckSprite] = newColumn[SpriteCol]()
if ckParent in mask: result.columns[ckParent] = newColumn[ParentC]()
if ckLifetime in mask: result.columns[ckLifetime] = newColumn[LifetimeC]()
if ckAI in mask: result.columns[ckAI] = newColumn[AIC]()
proc reserve(w: var ArchWorld; kind: ArchetypeKind; cap: int) =
template reserveCol(ck, T): untyped =
if ck in w.archetypes[kind].mask:
cast[ptr seq[T]](w.archetypes[kind].columns[ck])[] = newSeqOfCap[T](cap)
reserveCol(ckTransform, TransformC)
reserveCol(ckVelocity, VelocityC)
reserveCol(ckHealth, HealthC)
reserveCol(ckTarget, TargetC)
reserveCol(ckSprite, SpriteCol)
reserveCol(ckParent, ParentC)
reserveCol(ckLifetime, LifetimeC)
reserveCol(ckAI, AIC)
proc initArchWorld(capB, capA, capP: int): ArchWorld =
result.archetypes[BuildingArch] = initArchetype({ckTransform, ckSprite})
result.archetypes[AgentArch] = initArchetype(
{ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, ckParent, ckAI})
result.archetypes[ProjectileArch] = initArchetype(
{ckTransform, ckVelocity, ckTarget, ckLifetime})
result.reserve(BuildingArch, capB)
result.reserve(AgentArch, capA)
result.reserve(ProjectileArch, capP)
proc populate(w: var ArchWorld) =
rngState = 42'u32
for i in 0..<NBuildings:
col(w, BuildingArch, ckTransform, TransformC).add(
TransformC(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
worldPos: v2(0'f32, 0'f32)))
col(w, BuildingArch, ckSprite, SpriteCol).add(
SpriteCol(sprite: (0xFF0000FF'u32, 10'f32)))
for i in 0..<NAgents:
col(w, AgentArch, ckTransform, TransformC).add(
TransformC(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
worldPos: v2(0'f32, 0'f32)))
col(w, AgentArch, ckVelocity, VelocityC).add(
VelocityC(vel: v2(0'f32, 0'f32)))
col(w, AgentArch, ckHealth, HealthC).add(
HealthC(hp: 100'f32, maxHp: 100'f32))
col(w, AgentArch, ckTarget, TargetC).add(
TargetC(targetIdx: int32(rng() mod NAgents)))
col(w, AgentArch, ckSprite, SpriteCol).add(
SpriteCol(sprite: (0x00FF00FF'u32, 20'f32)))
col(w, AgentArch, ckParent, ParentC).add(
ParentC(parentIdx: if i < NAgents div 2: int32(rng() mod NBuildings) else: -1'i32))
col(w, AgentArch, ckAI, AIC).add(
AIC(cooldown: 0'f32, state: 1'u8, armor: float32(rng() mod 5) * 0.1'f32))
for i in 0..<NProjectiles:
col(w, ProjectileArch, ckTransform, TransformC).add(
TransformC(pos: v2(0'f32, 0'f32), worldPos: v2(0'f32, 0'f32)))
col(w, ProjectileArch, ckVelocity, VelocityC).add(
VelocityC(vel: v2(float32(rng() mod 10), float32(rng() mod 10))))
col(w, ProjectileArch, ckTarget, TargetC).add(
TargetC(targetIdx: int32(rng() mod NAgents)))
col(w, ProjectileArch, ckLifetime, LifetimeC).add(
LifetimeC(lifetime: ProjLifetime))
# --- archetype systems ---
proc archAIUpdate(w: var ArchWorld, dt: float32) =
let n = colLen(w, AgentArch, ckTransform, TransformC)
let transforms = colData(w, AgentArch, ckTransform, TransformC)
let velocities = colData(w, AgentArch, ckVelocity, VelocityC)
let healths = colData(w, AgentArch, ckHealth, HealthC)
let targets = colData(w, AgentArch, ckTarget, TargetC)
let ais = colData(w, AgentArch, ckAI, AIC)
for i in 0..<n:
if healths[i].hp <= 0'f32: continue
ais[i].cooldown -= dt
let hpRatio = healths[i].hp / healths[i].maxHp
# state machine: flee if low hp, attack if in range, else seek
if hpRatio < FleeHpThreshold:
ais[i].state = 3 # flee
let ti = targets[i].targetIdx
if ti >= 0 and healths[ti].hp > 0'f32:
let away = norm(v2(transforms[i].pos.x - transforms[ti].pos.x,
transforms[i].pos.y - transforms[ti].pos.y))
velocities[i].vel = v2(away.x * FleeSpeed, away.y * FleeSpeed)
else:
let ti = targets[i].targetIdx
if ti >= 0 and healths[ti].hp > 0'f32:
let dx = transforms[ti].pos.x - transforms[i].pos.x
let dy = transforms[ti].pos.y - transforms[i].pos.y
let distSq = dx*dx + dy*dy
if distSq < AttackRangeSq:
ais[i].state = 2 # attack
velocities[i].vel = v2(0'f32, 0'f32)
else:
ais[i].state = 1 # seek
let dir = norm(v2(dx, dy))
velocities[i].vel = v2(dir.x * SeekSpeed, dir.y * SeekSpeed)
else:
ais[i].state = 0 # idle
velocities[i].vel = v2(0'f32, 0'f32)
proc archMove(w: var ArchWorld, dt: float32) =
let n = colLen(w, AgentArch, ckTransform, TransformC)
let transforms = colData(w, AgentArch, ckTransform, TransformC)
let velocities = colData(w, AgentArch, ckVelocity, VelocityC)
for i in 0..<n:
transforms[i].pos.x += velocities[i].vel.x * dt
transforms[i].pos.y += velocities[i].vel.y * dt
# bounds clamp
if transforms[i].pos.x < 0'f32: transforms[i].pos.x = 0'f32
elif transforms[i].pos.x > ScreenW: transforms[i].pos.x = ScreenW
if transforms[i].pos.y < 0'f32: transforms[i].pos.y = 0'f32
elif transforms[i].pos.y > ScreenH: transforms[i].pos.y = ScreenH
let pn = colLen(w, ProjectileArch, ckTransform, TransformC)
let pt = colData(w, ProjectileArch, ckTransform, TransformC)
let pv = colData(w, ProjectileArch, ckVelocity, VelocityC)
for i in 0..<pn:
pt[i].pos.x += pv[i].vel.x * dt
pt[i].pos.y += pv[i].vel.y * dt
proc archCombat(w: var ArchWorld, dt: float32) =
let n = colLen(w, AgentArch, ckTransform, TransformC)
let transforms = colData(w, AgentArch, ckTransform, TransformC)
let healths = colData(w, AgentArch, ckHealth, HealthC)
let targets = colData(w, AgentArch, ckTarget, TargetC)
let ais = colData(w, AgentArch, ckAI, AIC)
for i in 0..<n:
if healths[i].hp <= 0'f32 or ais[i].state != 2: continue
if ais[i].cooldown > 0'f32: continue
let ti = targets[i].targetIdx
if ti >= 0 and healths[ti].hp > 0'f32:
let dx = transforms[i].pos.x - transforms[ti].pos.x
let dy = transforms[i].pos.y - transforms[ti].pos.y
if dx*dx + dy*dy < AttackRangeSq:
let dmg = AttackDamage * (1'f32 - ais[ti].armor * ArmorReduction)
healths[ti].hp -= dmg
ais[i].cooldown = AttackCooldown
if healths[ti].hp < 0'f32: healths[ti].hp = 0'f32
proc archProjectile(w: var ArchWorld, dt: float32) =
let pn = colLen(w, ProjectileArch, ckTransform, TransformC)
let pt = colData(w, ProjectileArch, ckTransform, TransformC)
let pv = colData(w, ProjectileArch, ckVelocity, VelocityC)
let ptg = colData(w, ProjectileArch, ckTarget, TargetC)
let pl = colData(w, ProjectileArch, ckLifetime, LifetimeC)
let at = colData(w, AgentArch, ckTransform, TransformC)
let ah = colData(w, AgentArch, ckHealth, HealthC)
for i in 0..<pn:
pl[i].lifetime -= dt
let ti = ptg[i].targetIdx
if ti >= 0 and ah[ti].hp > 0'f32:
let dx = at[ti].pos.x - pt[i].pos.x
let dy = at[ti].pos.y - pt[i].pos.y
let dir = norm(v2(dx, dy))
# homing steer
pv[i].vel.x += dir.x * ProjHomingStrength * dt * ProjSpeed
pv[i].vel.y += dir.y * ProjHomingStrength * dt * ProjSpeed
let sp = norm(pv[i].vel)
pv[i].vel = v2(sp.x * ProjSpeed, sp.y * ProjSpeed)
# hit detection
if dx*dx + dy*dy < 100'f32:
ah[ti].hp -= AttackDamage * 0.5'f32
pl[i].lifetime = 0'f32
proc archCull(w: var ArchWorld) =
let bn = colLen(w, BuildingArch, ckTransform, TransformC)
let bt = colData(w, BuildingArch, ckTransform, TransformC)
let bs = colData(w, BuildingArch, ckSprite, SpriteCol)
for i in 0..<bn:
let p = bt[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += bs[i].sprite.size
let an = colLen(w, AgentArch, ckTransform, TransformC)
let at = colData(w, AgentArch, ckTransform, TransformC)
let aspr = colData(w, AgentArch, ckSprite, SpriteCol)
for i in 0..<an:
let p = at[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += aspr[i].sprite.size
proc archSpawn(w: var ArchWorld, n: int) =
for j in 0..<n:
col(w, ProjectileArch, ckTransform, TransformC).add(
TransformC(pos: v2(0'f32, 0'f32), worldPos: v2(0'f32, 0'f32)))
col(w, ProjectileArch, ckVelocity, VelocityC).add(
VelocityC(vel: v2(float32(rng() mod 10), float32(rng() mod 10))))
col(w, ProjectileArch, ckTarget, TargetC).add(
TargetC(targetIdx: int32(rng() mod NAgents)))
col(w, ProjectileArch, ckLifetime, LifetimeC).add(
LifetimeC(lifetime: ProjLifetime))
proc archDespawn(w: var ArchWorld, n: int) =
let pt = addr col(w, ProjectileArch, ckTransform, TransformC)
for j in 0..<n:
let len = pt[].len
if len > 0:
let idx = rng() mod len
let last = len - 1
if idx != last:
col(w, ProjectileArch, ckTransform, TransformC)[idx] = col(w, ProjectileArch, ckTransform, TransformC)[last]
col(w, ProjectileArch, ckVelocity, VelocityC)[idx] = col(w, ProjectileArch, ckVelocity, VelocityC)[last]
col(w, ProjectileArch, ckTarget, TargetC)[idx] = col(w, ProjectileArch, ckTarget, TargetC)[last]
col(w, ProjectileArch, ckLifetime, LifetimeC)[idx] = col(w, ProjectileArch, ckLifetime, LifetimeC)[last]
col(w, ProjectileArch, ckTransform, TransformC).setLen(last)
col(w, ProjectileArch, ckVelocity, VelocityC).setLen(last)
col(w, ProjectileArch, ckTarget, TargetC).setLen(last)
col(w, ProjectileArch, ckLifetime, LifetimeC).setLen(last)
# ================================================================
# 2. MI-AoS — per-archetype value arrays (composition)
# ================================================================
type
Building = object
pos: Vec2f
sprite: SpriteC
Agent = object
pos: Vec2f
vel: Vec2f
hp: float32
maxHp: float32
targetIdx: int32
parentIdx: int32
cooldown: float32
state: uint8
armor: float32
sprite: SpriteC
Projectile = object
pos: Vec2f
vel: Vec2f
lifetime: float32
targetIdx: int32
AoSWorld = object
buildings: seq[Building]
agents: seq[Agent]
projectiles: seq[Projectile]
proc populate(w: var AoSWorld) =
rngState = 42'u32
w.buildings = newSeq[Building](NBuildings)
for i in 0..<NBuildings:
w.buildings[i] = Building(
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
sprite: (0xFF0000FF'u32, 10'f32))
w.agents = newSeq[Agent](NAgents)
for i in 0..<NAgents:
w.agents[i] = Agent(
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
vel: v2(0'f32, 0'f32),
hp: 100'f32, maxHp: 100'f32,
targetIdx: int32(rng() mod NAgents),
parentIdx: if i < NAgents div 2: int32(rng() mod NBuildings) else: -1'i32,
cooldown: 0'f32, state: 1'u8,
armor: float32(rng() mod 5) * 0.1'f32,
sprite: (0x00FF00FF'u32, 20'f32))
w.projectiles = newSeq[Projectile](NProjectiles)
for i in 0..<NProjectiles:
w.projectiles[i] = Projectile(
pos: v2(0'f32, 0'f32),
vel: v2(float32(rng() mod 10), float32(rng() mod 10)),
lifetime: ProjLifetime,
targetIdx: int32(rng() mod NAgents))
proc aosAIUpdate(w: var AoSWorld, dt: float32) =
for i in 0..<w.agents.len:
if w.agents[i].hp <= 0'f32: continue
w.agents[i].cooldown -= dt
let hpRatio = w.agents[i].hp / w.agents[i].maxHp
if hpRatio < FleeHpThreshold:
w.agents[i].state = 3
let ti = w.agents[i].targetIdx
if ti >= 0 and w.agents[ti].hp > 0'f32:
let away = norm(v2(w.agents[i].pos.x - w.agents[ti].pos.x,
w.agents[i].pos.y - w.agents[ti].pos.y))
w.agents[i].vel = v2(away.x * FleeSpeed, away.y * FleeSpeed)
else:
let ti = w.agents[i].targetIdx
if ti >= 0 and w.agents[ti].hp > 0'f32:
let dx = w.agents[ti].pos.x - w.agents[i].pos.x
let dy = w.agents[ti].pos.y - w.agents[i].pos.y
let distSq = dx*dx + dy*dy
if distSq < AttackRangeSq:
w.agents[i].state = 2
w.agents[i].vel = v2(0'f32, 0'f32)
else:
w.agents[i].state = 1
let dir = norm(v2(dx, dy))
w.agents[i].vel = v2(dir.x * SeekSpeed, dir.y * SeekSpeed)
else:
w.agents[i].state = 0
w.agents[i].vel = v2(0'f32, 0'f32)
proc aosMove(w: var AoSWorld, dt: float32) =
for i in 0..<w.agents.len:
w.agents[i].pos.x += w.agents[i].vel.x * dt
w.agents[i].pos.y += w.agents[i].vel.y * dt
if w.agents[i].pos.x < 0'f32: w.agents[i].pos.x = 0'f32
elif w.agents[i].pos.x > ScreenW: w.agents[i].pos.x = ScreenW
if w.agents[i].pos.y < 0'f32: w.agents[i].pos.y = 0'f32
elif w.agents[i].pos.y > ScreenH: w.agents[i].pos.y = ScreenH
for i in 0..<w.projectiles.len:
w.projectiles[i].pos.x += w.projectiles[i].vel.x * dt
w.projectiles[i].pos.y += w.projectiles[i].vel.y * dt
proc aosCombat(w: var AoSWorld, dt: float32) =
for i in 0..<w.agents.len:
if w.agents[i].hp <= 0'f32 or w.agents[i].state != 2: continue
if w.agents[i].cooldown > 0'f32: continue
let ti = w.agents[i].targetIdx
if ti >= 0 and w.agents[ti].hp > 0'f32:
let dx = w.agents[i].pos.x - w.agents[ti].pos.x
let dy = w.agents[i].pos.y - w.agents[ti].pos.y
if dx*dx + dy*dy < AttackRangeSq:
let dmg = AttackDamage * (1'f32 - w.agents[ti].armor * ArmorReduction)
w.agents[ti].hp -= dmg
w.agents[i].cooldown = AttackCooldown
if w.agents[ti].hp < 0'f32: w.agents[ti].hp = 0'f32
proc aosProjectile(w: var AoSWorld, dt: float32) =
for i in 0..<w.projectiles.len:
w.projectiles[i].lifetime -= dt
let ti = w.projectiles[i].targetIdx
if ti >= 0 and w.agents[ti].hp > 0'f32:
let dx = w.agents[ti].pos.x - w.projectiles[i].pos.x
let dy = w.agents[ti].pos.y - w.projectiles[i].pos.y
let dir = norm(v2(dx, dy))
w.projectiles[i].vel.x += dir.x * ProjHomingStrength * dt * ProjSpeed
w.projectiles[i].vel.y += dir.y * ProjHomingStrength * dt * ProjSpeed
let sp = norm(w.projectiles[i].vel)
w.projectiles[i].vel = v2(sp.x * ProjSpeed, sp.y * ProjSpeed)
if dx*dx + dy*dy < 100'f32:
w.agents[ti].hp -= AttackDamage * 0.5'f32
w.projectiles[i].lifetime = 0'f32
proc aosCull(w: var AoSWorld) =
for i in 0..<w.buildings.len:
let p = w.buildings[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.buildings[i].sprite.size
for i in 0..<w.agents.len:
let p = w.agents[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.agents[i].sprite.size
proc aosSpawn(w: var AoSWorld, n: int) =
for j in 0..<n:
w.projectiles.add(Projectile(
pos: v2(0'f32, 0'f32),
vel: v2(float32(rng() mod 10), float32(rng() mod 10)),
lifetime: ProjLifetime,
targetIdx: int32(rng() mod NAgents)))
proc aosDespawn(w: var AoSWorld, n: int) =
for j in 0..<n:
if w.projectiles.len > 0:
let idx = rng() mod w.projectiles.len
w.projectiles[idx] = w.projectiles[w.projectiles.high]
w.projectiles.setLen(w.projectiles.high)
# ================================================================
# Benchmark harness
# ================================================================
template bench(title: string, body: untyped) =
for _ in 0..<10: body
let t0 = getMonoTime()
for _ in 0..<Frames: body
let t1 = getMonoTime()
let dur = t1 - t0
let ns = dur.inNanoseconds.float64 / Frames.float64 / N.float64
let ms = dur.inMilliseconds.float64 / Frames.float64
let pct = ms / 16.0'f64 * 100.0
echo title.align(14) & " " & ns.formatFloat(ffDecimal, 3).align(8) &
" ns/e " & ms.formatFloat(ffDecimal, 2).align(7) &
" ms/frame " & pct.formatFloat(ffDecimal, 1).align(5) & "% budget"
template benchLifeCycle(spawnBody, despawnBody: untyped) =
for _ in 0..<3:
spawnBody
despawnBody
var totalSpawn = 0'i64
var totalDespawn = 0'i64
for _ in 0..<SpawnIters:
let s0 = getMonoTime(); spawnBody; let s1 = getMonoTime()
totalSpawn += (s1 - s0).inNanoseconds
let d0 = getMonoTime(); despawnBody; let d1 = getMonoTime()
totalDespawn += (d1 - d0).inNanoseconds
let spMs = totalSpawn.float64 / SpawnIters.float64 / 1e6
let dpMs = totalDespawn.float64 / SpawnIters.float64 / 1e6
echo "spawn " & spMs.formatFloat(ffDecimal, 3).align(8) & " ms/batch"
echo "despawn " & dpMs.formatFloat(ffDecimal, 3).align(8) & " ms/batch"
when isMainModule:
echo "Archetype ECS vs MI-AoS — Realistic Game Benchmark"
echo "=================================================="
echo fmt"Buildings: {NBuildings} Agents: {NAgents} Projectiles: {NProjectiles} Total: {N}"
echo fmt"Frames: {Frames} dt: {Dt}s Frame budget: 16ms"
echo ""
# ---- ECS-Archetype ----
var arch = initArchWorld(NBuildings, NAgents, NProjectiles + SpawnBatch + 16)
arch.populate()
echo "--- ECS-Archetype (SoA columns per archetype) ---"
bench("ai_update", arch.archAIUpdate(Dt))
bench("move", arch.archMove(Dt))
bench("combat", arch.archCombat(Dt))
bench("projectile", arch.archProjectile(Dt))
bench("cull", arch.archCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(arch.archSpawn(SpawnBatch), arch.archDespawn(SpawnBatch))
echo ""
# ---- MI-AoS ----
var aos: AoSWorld
aos.projectiles = newSeqOfCap[Projectile](NProjectiles + SpawnBatch + 16)
aos.populate()
echo "--- MI-AoS (per-archetype value arrays) ---"
bench("ai_update", aos.aosAIUpdate(Dt))
bench("move", aos.aosMove(Dt))
bench("combat", aos.aosCombat(Dt))
bench("projectile", aos.aosProjectile(Dt))
bench("cull", aos.aosCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(aos.aosSpawn(SpawnBatch), aos.aosDespawn(SpawnBatch))
echo ""
# ---- Memory ----
echo "--- Memory footprint ---"
let archMem = NBuildings * (sizeof(TransformC) + sizeof(SpriteCol)) +
NAgents * (sizeof(TransformC) + sizeof(VelocityC) + sizeof(HealthC) +
sizeof(TargetC) + sizeof(SpriteCol) + sizeof(ParentC) + sizeof(AIC)) +
NProjectiles * (sizeof(TransformC) + sizeof(VelocityC) +
sizeof(TargetC) + sizeof(LifetimeC))
let aosMem = NBuildings * sizeof(Building) + NAgents * sizeof(Agent) +
NProjectiles * sizeof(Projectile)
echo fmt" ECS-Archetype: {archMem.float64 / 1e6:6.1f} MB"
echo fmt" MI-AoS: {aosMem.float64 / 1e6:6.1f} MB"
echo fmt" Agent struct: {sizeof(Agent)} bytes ({NAgents} agents = {NAgents * sizeof(Agent)} bytes)"
echo ""
echo fmt"sink: {sink}"

My recommendation: start with MI-AoS, reach for ECS only when profiling proves you need it.

Here's why, based on everything we measured and the realities of indie development:

The numbers don't justify ECS at indie scale

At 11k entities (already generous for most indie games), your entire simulation — AI, movement, combat, projectiles, culling — runs in 0.07 ms/frame. That's 0.4% of a 16ms budget. The difference between archetype ECS and MI-AoS across all systems combined is roughly 0.03 ms. You will never feel that.

The archetype ECS only pulls ahead meaningfully at 50k+ entities with narrow batch systems. Most indie games never hit that. And if they do, it's one or two systems (particle simulation, flocking) — not the whole game.

MI-AoS is dramatically simpler

ECS-Archetype infrastructure you must build and maintain:
  - type-erased column pointers + casts
  - manual =destroy/=wasMoved hooks per archetype
  - entity ID generation + version bits + slot recycling
  - column reservation, grow, swap-remove across N columns
  - archetype migration when components change
  - debug tooling that can't just print a struct

MI-AoS infrastructure:
  - type Agent = object
  - seq[Agent]

That simplicity compounds. Serialization is write the seq. Debugging is echo agents[i]. Adding a field is add a field. Refactoring is normal Nim. There's no indirection layer between you and your data.

When ECS actually earns its complexity

Use ECS when you hit these specific, measurable conditions:

  1. 10k+ homogeneous entities with narrow systems — particle systems, grass rendering, crowds. Profile first. If move or update is eating >2ms, archetype SoA will likely halve it.

  2. Dynamic component composition at runtime — entities that gain/lose components frequently (status effects, modular vehicles). The archetype migration machinery handles this; hand-rolling it in AoS is painful.

  3. Cross-cutting queries — "all entities with Health AND AI but NOT Player." This is ECS's killer feature. AoS forces you to maintain tag sets or iterate and filter.

The pragmatic hybrid

Most successful indie engines do this:

  • MI-AoS for game entities — agents, buildings, items, player. These have complex per-entity logic, fat structs, and low counts. AoS wins here and is trivial to maintain.
  • A tight SoA particle system — particles are the one case where you genuinely have 10k+ entities, one system (integrate + render), and 2-3 fields. Write that one system as SoA columns. Don't build a general ECS framework for it.
  • Concepts or tagged unions for polymorphism — use Nim's type system where you need runtime dispatch, not a vtable layer.
# The 90% solution: one file, no framework
type
  Agent = object
    pos, vel: Vec2f
    hp, maxHp: float32
    targetIdx: int32
    state: uint8
    # ... add fields freely

  World = object
    agents: seq[Agent]
    buildings: seq[Building]
    particles: seq[Particle]  # hot loop, maybe SoA later if needed

If profiling later shows agents iteration is a bottleneck, you split that one struct into hot/cold arrays. You don't rebuild your architecture. The benchmark proved that hot/cold splitting on a 48-byte struct barely helps — so you likely never need to.

Don't pay the complexity tax of ECS until the profiler hands you the bill.

Results

System ECS-Archetype MI-AoS Winner
ai_update (5 columns/entity) 2.08 ns/e 2.21 ns/e ~tied
move (2 fields) 0.75 ns/e 1.17 ns/e Arch 1.6x
combat (branch-heavy) 0.27 ns/e 0.44 ns/e Arch 1.6x
projectile (4 fields, writes 3) 1.11 ns/e 0.73 ns/e AoS 1.5x
cull (pos+sprite) 3.11 ns/e 0.55 ns/e AoS 5.6x
despawn 0.003 ms 0.000 ms AoS 3x

Where archetype loses

Cull (5.6x slower) is the biggest upset. The cull system needs pos + sprite per entity. In AoS, both live in the same 52-byte Agent struct — one cache line has everything. In archetype SoA, they're in separate heap allocations far apart in memory. Each entity requires two independent cache line fetches from two arrays. The prefetcher can only track ~10 streams efficiently; walking two parallel columns at 8000 agents overwhelms it.

Projectile (1.5x slower) is the crossover case I expected. The homing logic reads pos + vel + target + lifetime (4 columns) and writes pos + vel + lifetime (3 columns) — 7 column accesses per projectile. With AoS, the 16-byte Projectile is a single cache line: one fetch has everything, one write commits everything.

Despawn (3x slower) — 4 separate column swap-removes + 4 setLen calls vs 1 struct copy + 1 setLen. Structural cost scales linearly with column count.

AI update stayed tied (2.08 vs 2.21) — surprising. Despite touching 5 columns per agent, the archetype didn't lose. Why: at 8k agents (416KB), the entire dataset fits in L2 cache, so the gather overhead is hidden by cache residency. The AoS advantage only appears when the working set exceeds cache.

The pattern

The crossover is predictable: archetype SoA wins when a system touches 1-2 narrow fields across many entities (move, combat). AoS wins when a system touches 3+ fields per entity (projectile, cull) or does structural operations (despawn).

The deciding factor is cache lines per entity per system. If the system's fields fit in one cache line in AoS (≤64 bytes), AoS wins. If the system touches a narrow slice of a fat struct, SoA wins.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment