Learn Zig Series (#55) - ECS Game Engine: Architecture
Learn Zig Series (#55) - ECS Game Engine: Architecture

Project F: ECS Game Engine Core (1/4)
What will I learn
- You will learn what Entity-Component-System architecture is and why games use it;
- You will learn that entities are just IDs, components are data, and systems are logic;
- You will learn the problem with inheritance hierarchies in game development;
- You will learn how to design the Entity as a simple integer ID with a generation counter;
- You will learn how to build the World struct: entity registry, component storage, and system list;
- You will learn how to create and destroy entities with ID recycling;
- You will learn the archetype vs sparse-set storage tradeoff (we'll use sparse sets);
- You will learn testing: creating entities, verifying IDs, destroying and verifying recycling.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture (this post)
Learn Zig Series (#55) - ECS Game Engine: Architecture
We just wrapped up a four-episode HTTP server project, and now it's time for something completely different. We're building a game engine. Well -- the core of one, anyway. Over the next four episodes we'll implement an Entity-Component-System (ECS) framework in Zig, complete with component storage, queries, systems, and a terminal renderer to actually see things move on screen. This first episode is about the architecture: what ECS is, why it exists, and building the foundation -- entities, the World struct, and sparse set storage.
If you've ever tried to build a game in an object-oriented language, you've probably hit the inheritance wall. Your Player extends Character extends Entity, and then you need a FlyingEnemy that shares movement code with Player but also needs Enemy behavior and suddenly you're stuck in a diamond inheritance problem that makes you question your career choices. ECS solves this entirely, and Zig's data-oriented design philosophy makes it a particularly good fit. Here we go!
What ECS is and why games use it
The Entity-Component-System pattern splits game objects into three separate concepts:
- Entity: just a number. An ID. It has no data and no behavior by itself. Think of it like a row number in a database.
- Component: a bag of data attached to an entity. Position, velocity, health, sprite, hitbox -- each one is a separate component. Components have NO logic.
- System: a function that operates on entities that have specific components. A movement system queries all entities with Position and Velocity components, and updates their positions. A rendering system queries all entities with Position and Sprite components, and draws them.
The key insight is composition over inheritance. Instead of defining what an object IS (a Player, an Enemy, a Bullet), you define what an object HAS. A player entity has Position + Velocity + Health + PlayerInput components. An enemy entity has Position + Velocity + Health + AIBehavior components. A bullet entity has Position + Velocity + Damage components. The movement system doesn't care whether something is a player, enemy, or bullet -- it just moves anything that has Position and Velocity.
const std = @import("std");
// Components are just data structs. Nothing more.
const Position = struct {
x: f32,
y: f32,
};
const Velocity = struct {
dx: f32,
dy: f32,
};
const Health = struct {
current: i32,
max: i32,
};
const Sprite = struct {
char: u8,
color: u8,
};
const Gravity = struct {
force: f32,
};
That's all a component is. A struct with fields. No methods, no inheritance, no vtables. The simplicity is the point. Data lives in components. Logic lives in systems. Identity lives in entities. This three-way separation is what makes ECS so powerful and flexible.
The problem with inheritance hierarchies in games
To understand why ECS exists, you need to understand what it replaced. Traditional game engines use deep class hierarchies. Here's the kind of thing you'd see in a typical C++ or Java game engine:
// THE OLD WAY (illustrative pseudo-code -- don't do this)
//
// GameObject
// +-- Character
// | +-- Player
// | +-- Enemy
// | +-- FlyingEnemy
// | +-- GroundEnemy
// +-- Projectile
// | +-- Bullet
// | +-- Missile (has health... wait, that's a Character thing)
// +-- Pickup
// +-- HealthPack
// +-- AmmoBox (needs physics... like a Projectile?)
// The problems multiply fast:
// 1. FlyingEnemy needs movement code from Character AND flying physics
// 2. Missile needs both Projectile trajectory AND can be destroyed (Health)
// 3. HealthPack needs collision detection -- same as Projectile?
// 4. Adding a new feature (e.g. "burnable") means touching the ENTIRE tree
// 5. Changing the base class breaks everything downstream
Every time a game designer says "what if this enemy could also fly?" or "what if the player could pick up the missile and throw it?", the inheritance tree needs surgery. You end up with diamond inheritance, god objects, or massive if/else chains checking type at runtime. The class hierarchy becomes a prison where adding one feature requires modifying six files.
With ECS, the same scenarios are trivial:
// THE ECS WAY
// Want a flying enemy? Give it: Position, Velocity, Health, AIBehavior, Flying
// Want a destructible missile? Give it: Position, Velocity, Damage, Health
// Want a pickup with physics? Give it: Position, Velocity, Pickup, Collider
// Want something burnable? Just add a Burnable component. Done.
//
// No class changes. No hierarchy. Just data.
const Flying = struct {
altitude: f32,
max_altitude: f32,
};
const Burnable = struct {
ignite_threshold: f32,
burn_rate: f32,
is_burning: bool,
};
const Collider = struct {
width: f32,
height: f32,
};
The second major advantage is cache performance. In an inheritance-based system, a Player object contains ALL its data in one allocation: position, velocity, health, inventory, input state, animation state, audio state -- hundreds of bytes. When the physics system iterates over all game objects to update positions, it loads the entire object into cache just to read 8 bytes of position data. Most of the cache line is wasted on data the physics system doesn't need.
In an ECS, all Position components live in a contiguous array. When the movement system iterates positions, it gets pure sequential memory access -- every byte in the cache line is useful data. This is the same data-oriented design philosophy we saw in Zig's standard library (remember how std.ArrayList stores all elements contiguously in episode 5?). On modern CPUs, cache-friendly access patterns can mean 10-50x performance differences for hot loops.
Designing the Entity: a simple integer ID with generation counter
An entity is an ID. But not just any integer -- we need to handle the case where entities get destroyed and their IDs get recycled. If entity 42 dies and later entity 42 is reused for a completely different object, we don't want stale references to the old entity 42 accidentally accessing the new one. This is the ABA problem, and the solution is a generation counter.
pub const Entity = struct {
index: u32,
generation: u32,
pub const INVALID = Entity{ .index = std.math.maxInt(u32), .generation = 0 };
pub fn eql(self: Entity, other: Entity) bool {
return self.index == other.index and self.generation == other.generation;
}
pub fn isValid(self: Entity) bool {
return self.index != std.math.maxInt(u32);
}
pub fn format(
self: Entity,
comptime _: []const u8,
_: std.fmt.FormatOptions,
writer: anytype,
) !void {
try writer.print("Entity({d}v{d})", .{ self.index, self.generation });
}
};
The entity has two fields: an index (which slot in the entity array this is) and a generation (how many times this slot has been recycled). When you create entity slot 42 for the first time, it's Entity{.index = 42, .generation = 0}. When that entity is destroyed and the slot gets reused, the new entity is Entity{.index = 42, .generation = 1}. Any code still holding the old Entity{.index = 42, .generation = 0} can check the generation and see that it's stale.
This is the exact same concept as a generational index that Rust game engines use (the generational-arena crate, for example). The two 32-bit fields pack into a single 64-bit value, which is cheap to copy and compare. We could use packed structs (episode 17) to squeeze both into a single u64, but keeping them as separate fields is clearer for a learning project.
The INVALID sentinel uses maxInt(u32) as the index -- that's 4 billion, and no sane game will have that many entities. The format function lets us print entities nicely with std.debug.print("{}", .{entity}) which outputs something like Entity(42v1) -- index 42, generation (version) 1. We implemented custom formatters before in episode 24, same pattern here.
Entity registry: creating and destroying entities
Now we need something to manage entities -- create new ones, destroy old ones, and recycle IDs. This is the entity registry:
const EntityEntry = struct {
generation: u32,
alive: bool,
};
pub const EntityRegistry = struct {
entries: std.ArrayList(EntityEntry),
free_indices: std.ArrayList(u32),
alive_count: u32,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) EntityRegistry {
return .{
.entries = std.ArrayList(EntityEntry).init(allocator),
.free_indices = std.ArrayList(u32).init(allocator),
.alive_count = 0,
.allocator = allocator,
};
}
pub fn deinit(self: *EntityRegistry) void {
self.entries.deinit();
self.free_indices.deinit();
}
pub fn create(self: *EntityRegistry) !Entity {
if (self.free_indices.items.len > 0) {
// Recycle a previously destroyed slot
const index = self.free_indices.pop();
var entry = &self.entries.items[index];
entry.alive = true;
self.alive_count += 1;
return Entity{
.index = index,
.generation = entry.generation,
};
}
// No free slots -- allocate a new one
const index: u32 = @intCast(self.entries.items.len);
try self.entries.append(.{
.generation = 0,
.alive = true,
});
self.alive_count += 1;
return Entity{
.index = index,
.generation = 0,
};
}
pub fn destroy(self: *EntityRegistry, entity: Entity) !void {
if (entity.index >= self.entries.items.len) return;
var entry = &self.entries.items[entity.index];
// Check generation matches -- don't destroy an already-recycled slot
if (entry.generation != entity.generation) return;
if (!entry.alive) return;
entry.alive = false;
entry.generation += 1; // Bump generation so old references become stale
self.alive_count -= 1;
try self.free_indices.append(entity.index);
}
pub fn isAlive(self: *const EntityRegistry, entity: Entity) bool {
if (entity.index >= self.entries.items.len) return false;
const entry = self.entries.items[entity.index];
return entry.alive and entry.generation == entity.generation;
}
};
The create function checks the free list first. If there's a recycled slot available, it reuses it (with the bumped generation from when it was destroyed). If not, it appends a new entry. The destroy function marks the slot as dead, bumps the generation, and pushes the index onto the free list for future reuse.
Having said that, the generation check in destroy is important. If someone holds a stale reference to an entity that was already destroyed and recycled, calling destroy with the old generation won't do anything -- the generation won't match, so the currently alive entity in that slot is safe. This is defensive programming at its finest.
The free list approach means entity creation is O(1) in both cases -- either pop from the free list or append to the entries array. Destruction is also O(1) -- mark dead and push to free list. This is fast enough for creating and destroying thousands of entities per frame, which is exactly what a particle system or bullet hell game needs.
The sparse set: how we store components
Now the interesting part. We know entities are just IDs. We know components are just data. The question is: how do we associate components with entities efficiently? We need to answer three questions fast:
- Does entity N have component type T? (existence check)
- Give me the T component for entity N. (random access)
- Give me ALL entities that have component T. (iteration)
There are two main approaches in the ECS world: archetypes and sparse sets. We're going with sparse sets because they're simpler to implement and better for dynamic component addition/removal.
pub fn SparseSet(comptime T: type) type {
return struct {
const Self = @This();
// Sparse array: indexed by entity index, contains index into dense array
// null_sentinel means "this entity doesn't have this component"
sparse: std.ArrayList(u32),
// Dense array: packed component data
dense_entities: std.ArrayList(u32),
dense_data: std.ArrayList(T),
allocator: std.mem.Allocator,
const null_sentinel: u32 = std.math.maxInt(u32);
pub fn init(allocator: std.mem.Allocator) Self {
return .{
.sparse = std.ArrayList(u32).init(allocator),
.dense_entities = std.ArrayList(u32).init(allocator),
.dense_data = std.ArrayList(T).init(allocator),
.allocator = allocator,
};
}
pub fn deinit(self: *Self) void {
self.sparse.deinit();
self.dense_entities.deinit();
self.dense_data.deinit();
}
pub fn set(self: *Self, entity_index: u32, component: T) !void {
// Grow sparse array if needed
while (self.sparse.items.len <= entity_index) {
try self.sparse.append(null_sentinel);
}
if (self.sparse.items[entity_index] != null_sentinel) {
// Entity already has this component -- update in place
const dense_idx = self.sparse.items[entity_index];
self.dense_data.items[dense_idx] = component;
return;
}
// New component: add to dense arrays
const dense_idx: u32 = @intCast(self.dense_data.items.len);
try self.dense_entities.append(entity_index);
try self.dense_data.append(component);
self.sparse.items[entity_index] = dense_idx;
}
pub fn get(self: *const Self, entity_index: u32) ?*const T {
if (entity_index >= self.sparse.items.len) return null;
const dense_idx = self.sparse.items[entity_index];
if (dense_idx == null_sentinel) return null;
return &self.dense_data.items[dense_idx];
}
pub fn getMut(self: *Self, entity_index: u32) ?*T {
if (entity_index >= self.sparse.items.len) return null;
const dense_idx = self.sparse.items[entity_index];
if (dense_idx == null_sentinel) return null;
return &self.dense_data.items[dense_idx];
}
pub fn has(self: *const Self, entity_index: u32) bool {
if (entity_index >= self.sparse.items.len) return false;
return self.sparse.items[entity_index] != null_sentinel;
}
pub fn remove(self: *Self, entity_index: u32) void {
if (entity_index >= self.sparse.items.len) return;
const dense_idx = self.sparse.items[entity_index];
if (dense_idx == null_sentinel) return;
// Swap-remove: move last element into the removed slot
const last_idx: u32 = @intCast(self.dense_data.items.len - 1);
if (dense_idx != last_idx) {
const last_entity = self.dense_entities.items[last_idx];
self.dense_data.items[dense_idx] = self.dense_data.items[last_idx];
self.dense_entities.items[dense_idx] = last_entity;
// Update the sparse entry for the moved entity
self.sparse.items[last_entity] = dense_idx;
}
_ = self.dense_data.pop();
_ = self.dense_entities.pop();
self.sparse.items[entity_index] = null_sentinel;
}
pub fn count(self: *const Self) usize {
return self.dense_data.items.len;
}
// Iteration: return the dense data array directly
pub fn data(self: *Self) []T {
return self.dense_data.items;
}
pub fn entities(self: *const Self) []const u32 {
return self.dense_entities.items;
}
};
}
Here's how it works. The sparse array is indexed by entity index. If sparse[42] is 7, that means entity 42's component data lives at index 7 in the dense arrays. If sparse[42] is null_sentinel, entity 42 doesn't have this component.
The dense arrays store the actual component data and the corresponding entity indices, packed contiguously with no gaps. When we iterate all Position components, we iterate the dense data array -- pure sequential memory access, every element is a Position that belongs to a living entity. No gaps, no null checks during iteration.
The remove operation uses swap-remove: instead of shifting all elements after the removed one (which would be O(n) and invalidate all sparse indices), we copy the last element into the gap and update the sparse entry for the moved element. This is O(1) and keeps the dense array packed. We've seen this pattern before with std.ArrayList.swapRemove in episode 22.
The generic SparseSet(comptime T: type) uses Zig's comptime generics (episode 14) so we get a separate, fully typed sparse set for each component type. SparseSet(Position) stores positions. SparseSet(Velocity) stores velocities. Each is independent, each has its own dense array for iteration. The compiler generates specialized code for each -- no runtime type dispatch, no boxing, no heap allocation for individual components.
Archetype vs sparse set: the tradeoff
Before we move on, let me explain why I picked sparse sets over archetypes, since it's a legitimate design decision.
Archetypes group entities by their exact set of components. All entities with (Position, Velocity) live together in one table. All entities with (Position, Velocity, Health) live in a different table. Iteration is blazing fast because you iterate entire tables with perfect cache locality. But adding or removing a component means moving the entity from one archetype table to another -- which involves copying all its component data.
Sparse sets store each component type independently. Adding or removing a component is just an insert/delete in one sparse set -- O(1), no data copying. But iterating entities that have BOTH Position and Velocity requires checking two sparse sets, which is slower than archetype iteration.
For our learning project, sparse sets win because:
- Simpler to implement. Archetype tables need dynamic column layouts, entity migration logic, and complex query planning. Sparse sets are one generic data structure.
- Dynamic composition. Adding/removing components at runtime is cheap. Games do this constantly -- picking up a power-up adds a component, dropping a shield removes one.
- Good enough performance. For a terminal-rendered game running at 30-60 FPS with hundreds of entities, the iteration overhead is irrelevant. Archetype advantages only matter at thousands of entities with heavy queries.
Production ECS frameworks like Bevy (Rust) use archetypes. Entt (C++) uses sparse sets. Both work. For learning the concepts, sparse sets are the better starting point because you understand exactly what's happening with no hidden complexity.
The World struct: putting it all together
The World is the top-level container that owns the entity registry and all component storage. It's the central hub that everything else talks to:
pub const World = struct {
registry: EntityRegistry,
positions: SparseSet(Position),
velocities: SparseSet(Velocity),
healths: SparseSet(Health),
sprites: SparseSet(Sprite),
gravities: SparseSet(Gravity),
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) World {
return .{
.registry = EntityRegistry.init(allocator),
.positions = SparseSet(Position).init(allocator),
.velocities = SparseSet(Velocity).init(allocator),
.healths = SparseSet(Health).init(allocator),
.sprites = SparseSet(Sprite).init(allocator),
.gravities = SparseSet(Gravity).init(allocator),
.allocator = allocator,
};
}
pub fn deinit(self: *World) void {
self.gravities.deinit();
self.sprites.deinit();
self.healths.deinit();
self.velocities.deinit();
self.positions.deinit();
self.registry.deinit();
}
pub fn spawn(self: *World) !Entity {
return self.registry.create();
}
pub fn despawn(self: *World, entity: Entity) !void {
if (!self.registry.isAlive(entity)) return;
// Remove all components for this entity
self.positions.remove(entity.index);
self.velocities.remove(entity.index);
self.healths.remove(entity.index);
self.sprites.remove(entity.index);
self.gravities.remove(entity.index);
try self.registry.destroy(entity);
}
pub fn isAlive(self: *const World, entity: Entity) bool {
return self.registry.isAlive(entity);
}
// Convenience: spawn with initial components
pub fn spawnWith(
self: *World,
pos: ?Position,
vel: ?Velocity,
hp: ?Health,
sprite: ?Sprite,
grav: ?Gravity,
) !Entity {
const entity = try self.spawn();
if (pos) |p| try self.positions.set(entity.index, p);
if (vel) |v| try self.velocities.set(entity.index, v);
if (hp) |h| try self.healths.set(entity.index, h);
if (sprite) |s| try self.sprites.set(entity.index, s);
if (grav) |g| try self.gravities.set(entity.index, g);
return entity;
}
};
Yes, the component storage is hardcoded -- we have explicit fields for Position, Velocity, Health, Sprite, and Gravity. A production ECS would use type-erased storage (we covered type erasure in episode 13) combined with comptime reflection (episode 32) to register arbitrary component types at compile time. That's beautiful but adds quite some complexity. For our game engine, we know exactly which components we need, and listing them explicitly is clearer.
The despawn function removes all components before destroying the entity. This is critical -- if we only destroyed the entity ID without cleaning up components, the sparse sets would still contain orphaned data for a dead entity. When the slot gets recycled, the new entity would inherit the old one's components. Nasty bug to track down.
The spawnWith convenience function lets you create an entity with its initial components in one call. Using optionals (?Position) means you pass null for components you don't need. So spawning a particle might look like world.spawnWith(.{.x = 10, .y = 20}, .{.dx = 1, .dy = -2}, null, .{.char = '*', .color = 3}, .{.force = 0.5}) -- position, velocity, no health, a sprite, and gravity. Compact and readable.
Testing: create entities, verify IDs, destroy and verify recycling
Let's make sure all of this actually works. Testing an ECS means verifying entity lifecycle, component storage, and the interaction between the two:
test "entity creation returns unique IDs" {
const allocator = std.testing.allocator;
var registry = EntityRegistry.init(allocator);
defer registry.deinit();
const e1 = try registry.create();
const e2 = try registry.create();
const e3 = try registry.create();
try std.testing.expect(e1.index == 0);
try std.testing.expect(e2.index == 1);
try std.testing.expect(e3.index == 2);
// All should be generation 0 (first use)
try std.testing.expect(e1.generation == 0);
try std.testing.expect(e2.generation == 0);
try std.testing.expect(e3.generation == 0);
try std.testing.expect(registry.alive_count == 3);
}
test "entity destruction and recycling" {
const allocator = std.testing.allocator;
var registry = EntityRegistry.init(allocator);
defer registry.deinit();
const e1 = try registry.create(); // index 0, gen 0
const e2 = try registry.create(); // index 1, gen 0
_ = try registry.create(); // index 2, gen 0
try std.testing.expect(registry.alive_count == 3);
// Destroy entity 1
try registry.destroy(e2);
try std.testing.expect(registry.alive_count == 2);
try std.testing.expect(!registry.isAlive(e2));
// Creating a new entity should recycle index 1 with bumped generation
const e4 = try registry.create();
try std.testing.expect(e4.index == 1); // recycled slot
try std.testing.expect(e4.generation == 1); // bumped generation
// Old reference to e2 should be stale
try std.testing.expect(!registry.isAlive(e2));
try std.testing.expect(registry.isAlive(e4));
// They have the same index but different generations
try std.testing.expect(e2.index == e4.index);
try std.testing.expect(!e2.eql(e4));
// e1 should still be alive and unaffected
try std.testing.expect(registry.isAlive(e1));
}
test "sparse set basic operations" {
const allocator = std.testing.allocator;
var positions = SparseSet(Position).init(allocator);
defer positions.deinit();
// Set components for some entities
try positions.set(0, .{ .x = 10.0, .y = 20.0 });
try positions.set(5, .{ .x = 50.0, .y = 60.0 });
try positions.set(2, .{ .x = 30.0, .y = 40.0 });
try std.testing.expect(positions.count() == 3);
// Check existence
try std.testing.expect(positions.has(0));
try std.testing.expect(positions.has(5));
try std.testing.expect(positions.has(2));
try std.testing.expect(!positions.has(1));
try std.testing.expect(!positions.has(99));
// Read data
const p0 = positions.get(0).?;
try std.testing.expectApproxEqAbs(p0.x, 10.0, 0.001);
try std.testing.expectApproxEqAbs(p0.y, 20.0, 0.001);
// Update existing component
try positions.set(0, .{ .x = 100.0, .y = 200.0 });
const p0_updated = positions.get(0).?;
try std.testing.expectApproxEqAbs(p0_updated.x, 100.0, 0.001);
try std.testing.expect(positions.count() == 3); // count unchanged
}
test "sparse set removal with swap" {
const allocator = std.testing.allocator;
var positions = SparseSet(Position).init(allocator);
defer positions.deinit();
try positions.set(0, .{ .x = 1.0, .y = 1.0 });
try positions.set(1, .{ .x = 2.0, .y = 2.0 });
try positions.set(2, .{ .x = 3.0, .y = 3.0 });
// Remove entity 0 -- last element (entity 2) should fill the gap
positions.remove(0);
try std.testing.expect(positions.count() == 2);
try std.testing.expect(!positions.has(0));
try std.testing.expect(positions.has(1));
try std.testing.expect(positions.has(2));
// Entity 2's data should still be correct after the swap
const p2 = positions.get(2).?;
try std.testing.expectApproxEqAbs(p2.x, 3.0, 0.001);
}
test "sparse set iteration" {
const allocator = std.testing.allocator;
var positions = SparseSet(Position).init(allocator);
defer positions.deinit();
try positions.set(10, .{ .x = 1.0, .y = 0.0 });
try positions.set(20, .{ .x = 2.0, .y = 0.0 });
try positions.set(30, .{ .x = 3.0, .y = 0.0 });
// Iterate and sum all x values
var sum: f32 = 0;
for (positions.data()) |pos| {
sum += pos.x;
}
try std.testing.expectApproxEqAbs(sum, 6.0, 0.001);
// Verify entity indices match
const ents = positions.entities();
try std.testing.expect(ents.len == 3);
}
test "world spawn and despawn" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
// Spawn an entity with position and velocity
const player = try world.spawnWith(
.{ .x = 0.0, .y = 0.0 },
.{ .dx = 1.0, .dy = 0.0 },
.{ .current = 100, .max = 100 },
.{ .char = '@', .color = 2 },
null,
);
try std.testing.expect(world.isAlive(player));
try std.testing.expect(world.positions.has(player.index));
try std.testing.expect(world.velocities.has(player.index));
try std.testing.expect(world.healths.has(player.index));
try std.testing.expect(world.sprites.has(player.index));
try std.testing.expect(!world.gravities.has(player.index)); // we passed null
// Despawn should clean up everything
try world.despawn(player);
try std.testing.expect(!world.isAlive(player));
try std.testing.expect(!world.positions.has(player.index));
try std.testing.expect(!world.velocities.has(player.index));
try std.testing.expect(!world.healths.has(player.index));
try std.testing.expect(!world.sprites.has(player.index));
}
test "world entity recycling with components" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
// Spawn and give a position
const e1 = try world.spawn();
try world.positions.set(e1.index, .{ .x = 42.0, .y = 99.0 });
// Destroy it
try world.despawn(e1);
// Spawn a new entity -- should recycle the slot
const e2 = try world.spawn();
try std.testing.expect(e2.index == e1.index);
try std.testing.expect(e2.generation == 1);
// The recycled entity should NOT have the old position
try std.testing.expect(!world.positions.has(e2.index));
}
The last test is particularly important -- it verifies that despawning properly cleans up components before recycling the entity slot. Without that cleanup, e2 would inherit e1's position, which would be a silent, hard-to-debug correctness bug. The kind of thing that makes your game characters teleport randmoly across the screen and you spend three hours wondering why ;-)
Putting it all together: a quick demo
Let's spawn some game entities to see the whole system working end-to-end:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const check = gpa.deinit();
if (check == .leak) std.debug.print("WARNING: memory leak detected\n", .{});
}
const allocator = gpa.allocator();
var world = World.init(allocator);
defer world.deinit();
// Spawn a player
const player = try world.spawnWith(
.{ .x = 40.0, .y = 12.0 },
.{ .dx = 0.0, .dy = 0.0 },
.{ .current = 100, .max = 100 },
.{ .char = '@', .color = 2 },
null,
);
// Spawn some enemies
const enemy1 = try world.spawnWith(
.{ .x = 10.0, .y = 5.0 },
.{ .dx = 0.5, .dy = 0.0 },
.{ .current = 50, .max = 50 },
.{ .char = 'E', .color = 1 },
null,
);
const enemy2 = try world.spawnWith(
.{ .x = 70.0, .y = 8.0 },
.{ .dx = -0.3, .dy = 0.1 },
.{ .current = 30, .max = 30 },
.{ .char = 'e', .color = 1 },
null,
);
// Spawn a projectile (no health, has gravity)
const bullet = try world.spawnWith(
.{ .x = 40.0, .y = 11.0 },
.{ .dx = 0.0, .dy = -2.0 },
null,
.{ .char = '|', .color = 3 },
.{ .force = 0.1 },
);
// Print what we've got
const stdout = std.io.getStdOut().writer();
try stdout.print("Spawned entities:\n", .{});
try stdout.print(" Player: {}\n", .{player});
try stdout.print(" Enemy1: {}\n", .{enemy1});
try stdout.print(" Enemy2: {}\n", .{enemy2});
try stdout.print(" Bullet: {}\n", .{bullet});
try stdout.print("\nPositions ({d} entities):\n", .{world.positions.count()});
const pos_ents = world.positions.entities();
const pos_data = world.positions.data();
for (pos_ents, pos_data) |ent_idx, pos| {
try stdout.print(" entity {d}: ({d:.1}, {d:.1})\n", .{ ent_idx, pos.x, pos.y });
}
try stdout.print("\nEntities with gravity: {d}\n", .{world.gravities.count()});
try stdout.print("Entities with health: {d}\n", .{world.healths.count()});
// Kill enemy2
try stdout.print("\nDestroying enemy2...\n", .{});
try world.despawn(enemy2);
try stdout.print("Positions after despawn: {d}\n", .{world.positions.count()});
try stdout.print("Is enemy2 alive? {}\n", .{world.isAlive(enemy2)});
// Spawn a new entity -- should recycle enemy2's slot
const pickup = try world.spawnWith(
.{ .x = 70.0, .y = 8.0 },
null,
null,
.{ .char = '+', .color = 4 },
null,
);
try stdout.print("New pickup: {} (recycled slot)\n", .{pickup});
}
Running this prints:
$ zig build-exe ecs.zig && ./ecs
Spawned entities:
Player: Entity(0v0)
Enemy1: Entity(1v0)
Enemy2: Entity(2v0)
Bullet: Entity(3v0)
Positions (4 entities):
entity 0: (40.0, 12.0)
entity 1: (10.0, 5.0)
entity 2: (70.0, 8.0)
entity 3: (40.0, 11.0)
Entities with gravity: 1
Entities with health: 3
Destroying enemy2...
Positions after despawn: 3
Is enemy2 alive? false
New pickup: Entity(2v1) (recycled slot)
Entity 2 got recycled with generation bumped to 1. The old enemy2 reference (Entity(2v0)) is stale. The new pickup has Position and Sprite but no Velocity, Health, or Gravity -- it's a completely different entity that happens to reuse the same index. This is ECS working exactly as designed.
Wat we geleerd hebben
- Entity-Component-System architecture separates game objects into IDs (entities), data (components), and logic (systems), enabling composition over inheritance
- The inheritance hierarchy problem in games: adding features requires surgery on the class tree, deep hierarchies create coupling, and the diamond problem makes multiple inheritance a nightmare
- Entities are simple integer IDs with a generation counter that prevents the ABA problem when slots get recycled
- The entity registry manages creation (append or recycle from free list) and destruction (mark dead, bump generation, push to free list), both in O(1)
- Sparse sets store components using a sparse-to-dense indirection: the sparse array maps entity indices to dense array positions, and the dense array stores packed component data for cache-friendly iteration
- Swap-remove keeps the dense array packed when removing components -- O(1) removal by swapping the last element into the gap
- The archetype vs sparse set tradeoff: archetypes give faster iteration but expensive component add/remove; sparse sets give O(1) add/remove but require cross-set iteration
- The World struct owns the entity registry and all component storage, with
despawncleaning up all components before recycling the entity slot - Comptime generics (
SparseSet(comptime T: type)) generate specialized storage for each component type with zero runtime overhead
De groeten!
looks interesting :D at the moment i am new with c++ :D