Learn Zig Series (#56) - ECS Game Engine: Component Storage
Project F: ECS Game Engine Core (2/4)
What will I learn
- You will learn how to build a type-erased component storage system using Zig's comptime and @typeInfo;
- You will learn how to register component types dynamically with
world.registerComponent(Position); - You will learn how adding and removing components works through the sparse set API;
- You will learn how to iterate all entities with a specific component efficiently;
- You will learn how to query entities that have multiple components simultaneously;
- You will learn why contiguous memory layout matters for cache performance;
- You will learn how to test component storage: add, query, remove, verify iteration correctness.
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
- Learn Zig Series (#56) - ECS Game Engine: Component Storage (this post)
Learn Zig Series (#56) - ECS Game Engine: Component Storage
Last episode we built the foundation of our ECS game engine: the Entity type with generation counters, the EntityRegistry for creating and destroying entities with ID recycling, and the SparseSet(T) generic container for O(1) component operations. But there was a problem I left dangling on purpose -- our World struct had every component type hardcoded as an explicit field. Adding a new component meant editing the World struct, adding init/deinit calls, and updating despawn to clean up the new type. That's fine for a tutorial example, but it doesn't scale. Today we fix that.
This episode is about making component storage dynamic. We'll build a type-erased storage layer so the World doesn't need to know about component types at compile time (well -- it does, because this is Zig and we love comptime, but it won't need manual struct field declarations). We'll implement proper component registration, build multi-component queries that let systems find entities matching specific component combinations, and dig into why the memory layout we chose actually matters for performance.
From hardcoded fields to type-erased storage
In episode 55 the World struct looked like this:
// Episode 55's World -- every component is a named field
pub const World = struct {
registry: EntityRegistry,
positions: SparseSet(Position),
velocities: SparseSet(Velocity),
healths: SparseSet(Health),
sprites: SparseSet(Sprite),
gravities: SparseSet(Gravity),
// ... add more fields for every new component type
};
This works, but imagine a game with 30 component types. The World struct would have 30 sparse set fields, deinit would have 30 cleanup calls, and despawn would need 30 remove calls. Every time a designer adds a new component you're editing the core engine. Not great.
The solution is type erasure -- storing component sparse sets behind a uniform interface that the World can manage generically. We covered type erasure in episode 13, and now we get to apply it in a real system. The idea: create a ComponentStorage interface that wraps any SparseSet(T), and store those in a hash map keyed by a type identifier.
First we need a way to uniquely identify component types. Zig gives us exactly what we need through @typeName:
const std = @import("std");
fn typeId(comptime T: type) u64 {
const name = @typeName(T);
return std.hash.Wyhash.hash(0, name);
}
// Each type gets a unique ID computed at compile time:
// typeId(Position) -> some u64
// typeId(Velocity) -> different u64
// typeId(Health) -> yet another u64
The typeId function hashes the type's name to produce a u64. Because @typeName returns a fully qualified name (including the module path), there's no collision risk between game.Position and physics.Position if you happened to have two. The hash is computed at comptime so there's zero runtime cost -- it's just a constant.
The ErasedStorage interface
Now we need a type-erased wrapper around SparseSet(T) that lets us call remove and deinit without knowing what T is:
const ErasedStorage = struct {
ptr: *anyopaque,
remove_fn: *const fn (*anyopaque, u32) void,
has_fn: *const fn (*anyopaque, u32) bool,
count_fn: *const fn (*anyopaque) usize,
deinit_fn: *const fn (*anyopaque) void,
fn initFor(comptime T: type, allocator: std.mem.Allocator) !ErasedStorage {
const storage = try allocator.create(SparseSet(T));
storage.* = SparseSet(T).init(allocator);
return .{
.ptr = @ptrCast(storage),
.remove_fn = &struct {
fn remove(erased: *anyopaque, entity_index: u32) void {
const s: *SparseSet(T) = @ptrCast(@alignCast(erased));
s.remove(entity_index);
}
}.remove,
.has_fn = &struct {
fn has(erased: *anyopaque, entity_index: u32) bool {
const s: *SparseSet(T) = @ptrCast(@alignCast(erased));
return s.has(entity_index);
}
}.has,
.count_fn = &struct {
fn count(erased: *anyopaque) usize {
const s: *SparseSet(T) = @ptrCast(@alignCast(erased));
return s.count();
}
}.count,
.deinit_fn = &struct {
fn deinit(erased: *anyopaque) void {
const s: *SparseSet(T) = @ptrCast(@alignCast(erased));
s.deinit();
}
}.deinit,
};
}
fn remove(self: *const ErasedStorage, entity_index: u32) void {
self.remove_fn(self.ptr, entity_index);
}
fn has(self: *const ErasedStorage, entity_index: u32) bool {
return self.has_fn(self.ptr, entity_index);
}
fn count(self: *const ErasedStorage) usize {
return self.count_fn(self.ptr);
}
fn deinit(self: *const ErasedStorage) void {
self.deinit_fn(self.ptr);
}
};
This is the exact pattern from episode 13: store a *anyopaque pointer alongside function pointers that know how to cast it back to the concrete type. The initFor function takes a comptime type parameter, creates a heap-allocated SparseSet(T), and wraps it with the appropriate function pointers. The anonymous struct trick (&struct { fn ... }.func) creates these function pointers inline -- each one captures the comptime T in its closure, so when called at runtime it can safely cast the *anyopaque back to *SparseSet(T).
Having said that, notice we only expose remove, has, count, and deinit on the erased interface. We intentionally do NOT expose get, set, or data through erasure, because those need the concrete type T for type safety. You don't want a function that returns *anyopaque component data -- that throws away all the type information Zig gives you and invites unsafe casts everywhere. We'll access typed data through a different path.
Registering components with the World
Now the World can store component types in a hash map:
pub const World = struct {
registry: EntityRegistry,
storages: std.AutoHashMap(u64, ErasedStorage),
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) World {
return .{
.registry = EntityRegistry.init(allocator),
.storages = std.AutoHashMap(u64, ErasedStorage).init(allocator),
.allocator = allocator,
};
}
pub fn deinit(self: *World) void {
var it = self.storages.valueIterator();
while (it.next()) |storage| {
storage.deinit();
}
self.storages.deinit();
self.registry.deinit();
}
pub fn registerComponent(self: *World, comptime T: type) !void {
const id = comptime typeId(T);
if (self.storages.contains(id)) return; // already registered
const storage = try ErasedStorage.initFor(T, self.allocator);
try self.storages.put(id, storage);
}
pub fn getStorage(self: *World, comptime T: type) *SparseSet(T) {
const id = comptime typeId(T);
const erased = self.storages.getPtr(id) orelse
@panic("Component type not registered");
return @ptrCast(@alignCast(erased.ptr));
}
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 from ALL registered storages -- no hardcoded list needed
var it = self.storages.valueIterator();
while (it.next()) |storage| {
storage.remove(entity.index);
}
try self.registry.destroy(entity);
}
pub fn isAlive(self: *const World, entity: Entity) bool {
return self.registry.isAlive(entity);
}
};
Look at that despawn function. Instead of listing every component type manually, it iterates all registered storages and removes the entity from each one. Add 50 new component types and despawn still works without changes. That's the payoff of type erasure.
The registerComponent function is called once per component type during setup. It creates a heap-allocated sparse set, wraps it in an ErasedStorage, and stores it in the hash map. Registration is idempotent -- calling it twice for the same type is a no-op.
The getStorage function is where typed access happens. Given a comptime type T, it looks up the erased storage by type ID and casts the *anyopaque back to *SparseSet(T). This cast is safe because initFor(T) created it as a SparseSet(T) and the type ID guarantees we're looking up the right one. If you try to get a storage for an unregistered type, it panics -- that's a programming error, not a runtime condition to handle gracefully.
Adding and removing components
With the registration system in place, adding and removing components works through getStorage:
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();
// Register all component types upfront
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Health);
try world.registerComponent(Sprite);
try world.registerComponent(Gravity);
// Spawn a player
const player = try world.spawn();
try world.getStorage(Position).set(player.index, .{ .x = 40.0, .y = 12.0 });
try world.getStorage(Velocity).set(player.index, .{ .dx = 0.0, .dy = 0.0 });
try world.getStorage(Health).set(player.index, .{ .current = 100, .max = 100 });
try world.getStorage(Sprite).set(player.index, .{ .char = '@', .color = 2 });
// Spawn an enemy -- different component composition
const enemy = try world.spawn();
try world.getStorage(Position).set(enemy.index, .{ .x = 10.0, .y = 5.0 });
try world.getStorage(Velocity).set(enemy.index, .{ .dx = 0.5, .dy = 0.0 });
try world.getStorage(Health).set(enemy.index, .{ .current = 30, .max = 30 });
try world.getStorage(Sprite).set(enemy.index, .{ .char = 'E', .color = 1 });
// Spawn a particle -- position, velocity, sprite, gravity (no health)
const particle = try world.spawn();
try world.getStorage(Position).set(particle.index, .{ .x = 40.0, .y = 11.0 });
try world.getStorage(Velocity).set(particle.index, .{ .dx = 0.2, .dy = -1.5 });
try world.getStorage(Sprite).set(particle.index, .{ .char = '.', .color = 3 });
try world.getStorage(Gravity).set(particle.index, .{ .force = 0.3 });
const stdout = std.io.getStdOut().writer();
try stdout.print("Entities with Position: {d}\n", .{world.getStorage(Position).count()});
try stdout.print("Entities with Health: {d}\n", .{world.getStorage(Health).count()});
try stdout.print("Entities with Gravity: {d}\n", .{world.getStorage(Gravity).count()});
// Remove a component at runtime -- particle loses gravity
world.getStorage(Gravity).remove(particle.index);
try stdout.print("Entities with Gravity after removal: {d}\n", .{world.getStorage(Gravity).count()});
}
The API is straightforward: getStorage(T) returns a *SparseSet(T) and you call .set(), .get(), .remove(), .has() on it directly. The type system ensures you can only put Position data into the Position storage -- no accidental type mismatches. And because getStorage returns a pointer to the actual sparse set (not a copy), mutations go through to the real data.
Removing a component at runtime is just .remove(entity_index). No need to touch any other component -- the entity keeps all its other components. This is one of the key ECS advantages: component composition is fully dynamic. A power-up system might add a Shield component when the player picks up a shield and remove it when the shield breaks. An equipment system might add/remove WeaponDamage components as the player swaps weapons. All cheap O(1) operations thanks to the sparse set's swap-remove from last episode.
Iterating entities with a specific component
Systems in an ECS need to iterate all entities that have a certain component. A movement system wants all entities with Position AND Velocity. A rendering system wants all entities with Position AND Sprite. This is where the sparse set's dense array pays off.
For single-component iteration, it's trivial -- just iterate the dense data array:
// A gravity system: apply gravity to all entities that have Velocity + Gravity
fn gravitySystem(world: *World) void {
const gravities = world.getStorage(Gravity);
const velocities = world.getStorage(Velocity);
const grav_ents = gravities.entities();
const grav_data = gravities.data();
for (grav_ents, grav_data) |entity_idx, grav| {
// Only affect entities that also have velocity
if (velocities.getMut(entity_idx)) |vel| {
vel.dy += grav.force;
}
}
}
// A movement system: update positions based on velocity
fn movementSystem(world: *World) void {
const positions = world.getStorage(Position);
const velocities = world.getStorage(Velocity);
const vel_ents = velocities.entities();
const vel_data = velocities.data();
for (vel_ents, vel_data) |entity_idx, vel| {
if (positions.getMut(entity_idx)) |pos| {
pos.x += vel.dx;
pos.y += vel.dy;
}
}
}
The pattern is consistent: iterate the dense array of one component type, then check if each entity also has the other component(s) you need. You iterate the smaller set and do lookups into the larger ones. The getMut call returns ?*T -- a mutable pointer if the entity has that component, or null if it doesn't. This is an O(1) lookup per entity (sparse array random access), so the total cost is O(n) where n is the number of entities with the iterated component.
Multi-component queries
The manual "iterate one, check the others" approach works, but it's verbose and error-prone. What if a system needs Position AND Velocity AND Sprite AND Health? That's a lot of nested null checks. Let's build a proper query mechanism.
pub fn QueryIterator(comptime Components: type) type {
const fields = @typeInfo(Components).@"struct".fields;
return struct {
const Self = @This();
// Store pointers to each sparse set we're querying
storages: [fields.len]*anyopaque,
// The "driving" sparse set -- we iterate its entities
driver_entities: []const u32,
current_index: usize,
pub fn init(world: *World) Self {
var storages: [fields.len]*anyopaque = undefined;
var min_count: usize = std.math.maxInt(usize);
var driver_idx: usize = 0;
inline for (fields, 0..) |field, i| {
const storage = world.getStorage(field.type);
storages[i] = @ptrCast(storage);
if (storage.count() < min_count) {
min_count = storage.count();
driver_idx = i;
}
}
// Get entities from the smallest set (most selective)
const driver_storage = storages[driver_idx];
const driver_ents = blk: {
inline for (fields, 0..) |field, i| {
if (i == driver_idx) {
const typed: *SparseSet(field.type) = @ptrCast(@alignCast(driver_storage));
break :blk typed.entities();
}
}
unreachable;
};
return .{
.storages = storages,
.driver_entities = driver_ents,
.current_index = 0,
};
}
pub fn next(self: *Self) ?Components {
outer: while (self.current_index < self.driver_entities.len) {
const entity_idx = self.driver_entities[self.current_index];
self.current_index += 1;
// Check ALL component storages for this entity
var result: Components = undefined;
inline for (fields, 0..) |field, i| {
const storage: *SparseSet(field.type) = @ptrCast(@alignCast(self.storages[i]));
if (storage.get(entity_idx)) |component| {
@field(result, field.name) = component.*;
} else {
continue :outer;
}
}
return result;
}
return null;
}
};
}
Now this is where Zig's comptime really shines. The QueryIterator takes a struct type that describes what components the query wants. Each field in the struct corresponds to a component type. The iterator finds the smallest sparse set (the "driver") and iterates that, checking each entity against all other required components. If any component is missing, it skips to the next entity.
Using it looks like this:
const MovementQuery = struct {
pos: Position,
vel: Velocity,
};
fn movementSystemV2(world: *World) void {
var query = QueryIterator(MovementQuery).init(world);
while (query.next()) |components| {
// components.pos and components.vel are both guaranteed to exist
_ = components;
// In a real system, you'd update position here
}
}
The struct acts as a query specification -- "give me all entities that have Position AND Velocity." The inline for over the struct fields means the compiler unrolls the loop at compile time, so there's no dynamic dispatch or runtime field lookup. For MovementQuery with two fields, the compiler generates code that checks exactly two sparse sets. No generics overhead, no vtables, no boxing.
The driver selection optimization is worth explaining. If you have 1000 entities with Position but only 50 with Gravity, and you query for (Position, Gravity), you want to iterate the 50 gravity entities and check if they have position -- NOT iterate 1000 position entities checking for gravity. The smallest set is the most selective filter. This simple heuristic keeps query performance proportional to the result set size rather than the largest component pool.
Why contiguous memory layout matters
I've mentioned cache performance a few times. Let's make this concrete with some numbers.
On a modern CPU, a cache line is typically 64 bytes. When the CPU reads from memory, it doesn't read one byte -- it reads an entire 64-byte cache line. If your data is contiguous in memory, one cache line fetch gives you multiple useful data items. If your data is scattered, each item requires a seperate cache line fetch.
Consider Position (two f32 values = 8 bytes). Our sparse set stores all Position components in a contiguous std.ArrayList(Position):
// Dense data layout in memory:
// [Position][Position][Position][Position][Position][Position][Position][Position]
// |<------- 64 bytes (one cache line) = 8 positions ----->|
//
// When the movement system iterates positions:
// - First access loads one cache line
// - Next 7 positions are already in cache (FREE)
// - Cache hit rate: ~87.5% for sequential access
// Compare with inheritance-based layout:
// [Player: 256 bytes][Enemy: 192 bytes][Bullet: 128 bytes]
// |<-- cache line -->|<-- cache line -->|<-- cache line -->|
//
// The movement system only needs 8 bytes of position data from each object
// but loads 64-256 bytes per entity. Most of the cache line is wasted.
With our sparse set approach, iterating 10,000 Position components touches about 80 KB of memory (10,000 * 8 bytes). With an inheritance-based approach where each game object is 256 bytes, iterating the same 10,000 entities touches 2.5 MB -- and most of it is irrelevant data polluting the cache. On modern hardware the L1 cache is typically 32-64 KB, so the sparse set approach fits entirely in L1 while the inheritance approach blows past L2 into L3.
Here's a quick benchmark you can run to see the difference:
fn benchmarkIteration(world: *World, iterations: u32) !void {
const timer = std.time.Timer.start() catch unreachable;
var i: u32 = 0;
while (i < iterations) : (i += 1) {
const positions = world.getStorage(Position);
const velocities = world.getStorage(Velocity);
const pos_data = positions.dataMut();
const pos_ents = positions.entities();
for (pos_ents, pos_data) |ent_idx, *pos| {
if (velocities.get(ent_idx)) |vel| {
pos.x += vel.dx;
pos.y += vel.dy;
}
}
}
const elapsed_ns = timer.read();
const elapsed_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0;
std.debug.print("Movement system: {d} iterations in {d:.2}ms\n", .{ iterations, elapsed_ms });
}
The performance characteristics scale linearly with entity count because the iteration is purely sequential. No pointer chasing, no vtable lookups, no polymorphic dispatch. The CPU's hardware prefetcher can predict the access pattern and start fetching cache lines before the program needs them. This is data-oriented design at its core, and it's one of the reasons Zig (with its explicit memory layout control) is such a natural fit for game engines.
Testing component storage
Let's write tests that verify the registration, storage, and query systems work correctly:
test "component registration and access" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
const entity = try world.spawn();
// Add components through getStorage
try world.getStorage(Position).set(entity.index, .{ .x = 10.0, .y = 20.0 });
try world.getStorage(Velocity).set(entity.index, .{ .dx = 1.0, .dy = -1.0 });
// Read them back
const pos = world.getStorage(Position).get(entity.index).?;
try std.testing.expectApproxEqAbs(pos.x, 10.0, 0.001);
try std.testing.expectApproxEqAbs(pos.y, 20.0, 0.001);
const vel = world.getStorage(Velocity).get(entity.index).?;
try std.testing.expectApproxEqAbs(vel.dx, 1.0, 0.001);
try std.testing.expectApproxEqAbs(vel.dy, -1.0, 0.001);
}
test "despawn removes from all registered storages" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Health);
const entity = try world.spawn();
try world.getStorage(Position).set(entity.index, .{ .x = 0, .y = 0 });
try world.getStorage(Velocity).set(entity.index, .{ .dx = 0, .dy = 0 });
try world.getStorage(Health).set(entity.index, .{ .current = 100, .max = 100 });
try std.testing.expect(world.getStorage(Position).has(entity.index));
try std.testing.expect(world.getStorage(Velocity).has(entity.index));
try std.testing.expect(world.getStorage(Health).has(entity.index));
try world.despawn(entity);
// All components should be gone
try std.testing.expect(!world.getStorage(Position).has(entity.index));
try std.testing.expect(!world.getStorage(Velocity).has(entity.index));
try std.testing.expect(!world.getStorage(Health).has(entity.index));
}
test "component removal preserves other components" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Health);
const entity = try world.spawn();
try world.getStorage(Position).set(entity.index, .{ .x = 5.0, .y = 10.0 });
try world.getStorage(Velocity).set(entity.index, .{ .dx = 1.0, .dy = 2.0 });
try world.getStorage(Health).set(entity.index, .{ .current = 50, .max = 100 });
// Remove just velocity
world.getStorage(Velocity).remove(entity.index);
// Position and Health should be untouched
try std.testing.expect(world.getStorage(Position).has(entity.index));
try std.testing.expect(!world.getStorage(Velocity).has(entity.index));
try std.testing.expect(world.getStorage(Health).has(entity.index));
const pos = world.getStorage(Position).get(entity.index).?;
try std.testing.expectApproxEqAbs(pos.x, 5.0, 0.001);
}
test "iteration over single component" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
// Spawn 5 entities with positions
var i: u32 = 0;
while (i < 5) : (i += 1) {
const e = try world.spawn();
try world.getStorage(Position).set(e.index, .{
.x = @floatFromInt(i * 10),
.y = @floatFromInt(i * 20),
});
}
try std.testing.expect(world.getStorage(Position).count() == 5);
// Sum all x values
var sum: f32 = 0;
for (world.getStorage(Position).data()) |pos| {
sum += pos.x;
}
// 0 + 10 + 20 + 30 + 40 = 100
try std.testing.expectApproxEqAbs(sum, 100.0, 0.001);
}
test "removing entity preserves iteration of remaining" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
const e1 = try world.spawn();
const e2 = try world.spawn();
const e3 = try world.spawn();
try world.getStorage(Position).set(e1.index, .{ .x = 1.0, .y = 0 });
try world.getStorage(Position).set(e2.index, .{ .x = 2.0, .y = 0 });
try world.getStorage(Position).set(e3.index, .{ .x = 3.0, .y = 0 });
// Remove the middle entity's position
world.getStorage(Position).remove(e2.index);
try std.testing.expect(world.getStorage(Position).count() == 2);
// Remaining positions should sum to 4.0 (1 + 3)
var sum: f32 = 0;
for (world.getStorage(Position).data()) |pos| {
sum += pos.x;
}
try std.testing.expectApproxEqAbs(sum, 4.0, 0.001);
}
These tests verify the core behaviors: registration creates accessible storage, despawn cleans up across all registered types, individual removal doesn't affect other components, and iteration produces correct results after removals (the swap-remove doesn't corrupt data). That last point is subtl -- the swap-remove changes the order of elements in the dense array, so we can't test for specific ordering, but we CAN test that the sum of all values is correct, which proves no data was lost or duplicated.
Putting it all together
Here's a complete demo that shows entities being created with different component combinations, a system running over them, and entities being despawned mid-simulation:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const check = gpa.deinit();
if (check == .leak) std.debug.print("WARNING: memory leak\n", .{});
}
const allocator = gpa.allocator();
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Health);
try world.registerComponent(Sprite);
try world.registerComponent(Gravity);
// Spawn game entities
const player = try world.spawn();
try world.getStorage(Position).set(player.index, .{ .x = 40.0, .y = 12.0 });
try world.getStorage(Velocity).set(player.index, .{ .dx = 0.0, .dy = 0.0 });
try world.getStorage(Health).set(player.index, .{ .current = 100, .max = 100 });
try world.getStorage(Sprite).set(player.index, .{ .char = '@', .color = 2 });
const bullet = try world.spawn();
try world.getStorage(Position).set(bullet.index, .{ .x = 40.0, .y = 11.0 });
try world.getStorage(Velocity).set(bullet.index, .{ .dx = 0.0, .dy = -2.0 });
try world.getStorage(Sprite).set(bullet.index, .{ .char = '|', .color = 3 });
try world.getStorage(Gravity).set(bullet.index, .{ .force = 0.05 });
const stdout = std.io.getStdOut().writer();
// Run 3 simulation steps
var step: u32 = 0;
while (step < 3) : (step += 1) {
// Gravity system
gravitySystem(&world);
// Movement system
movementSystem(&world);
// Print positions
try stdout.print("Step {d}:\n", .{step});
const positions = world.getStorage(Position);
const sprites = world.getStorage(Sprite);
for (positions.entities(), positions.data()) |ent_idx, pos| {
const char: u8 = if (sprites.get(ent_idx)) |s| s.char else '?';
try stdout.print(" [{c}] ({d:.1}, {d:.1})\n", .{ char, pos.x, pos.y });
}
}
// Despawn the bullet
try stdout.print("\nDespawning bullet...\n", .{});
try world.despawn(bullet);
try stdout.print("Entities with Position: {d}\n", .{world.getStorage(Position).count()});
try stdout.print("Entities with Gravity: {d}\n", .{world.getStorage(Gravity).count()});
}
The output shows the bullet accelerating downward due to gravity while the player stays stationary (zero velocity). After despawning the bullet, both the Position and Gravity counts drop by one -- confirming despawn cleaned up all component storages properly.
Wat we geleerd hebben
- Type erasure via
ErasedStoragewraps anySparseSet(T)behind a uniform interface, letting the World manage arbitrary component types without hardcoded fields - Component registration with
world.registerComponent(Position)creates type-specific storage at runtime, while keeping typed access throughgetStorage(T)that returns*SparseSet(T)-- type safe and zero-cost at the point of use - Adding and removing components is O(1) through the sparse set's append and swap-remove operations, enabling fully dynamic entity composition at runtime
- Single-component iteration runs over the dense array directly -- contiguous memory, sequential access, hardware-prefetcher friendly
- Multi-component queries iterate the smallest matching set (most selective filter) and check other sets via O(1) sparse lookups, keeping total cost proportional to the result set size
- Contiguous component storage means 8 Position values fit in one 64-byte cache line versus 1 game object in inheritance-based layout -- 8x less memory traffic for the same iteration
despawniterates all registered storages generically (through the erased interface) so adding new component types never requires modifying the World's cleanup code- Comptime
inline forover struct fields generates unrolled, type-specific code with zero runtime dispatch overhead
Next time we're adding systems and queries -- the "S" in ECS -- so all these component lookups get wrapped in a nice, reusable system registration API. We'll also look at system ordering and dependency tracking, because when you have 15 systems running each frame, the order they execute in starts to matter a LOT ;-)
Thanks for reading!