Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
Project F: ECS Game Engine Core (3/4)
What will I learn
- You will learn how systems work as functions that operate on component queries;
- You will learn the system function signature and how to register systems with the World;
- You will learn how to build a query API:
world.query(.{Position, Velocity})to match entities; - You will learn system scheduling: controlling run order and managing dependencies between systems;
- You will learn how to pass delta time to physics and animation systems;
- You will learn how to build a movement system:
position += velocity * dt; - You will learn how to build a collision system with simple AABB overlap detection;
- You will learn how to test systems by setting up entities, running systems, and verifying state changes.
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
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries (this post)
Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
Last episode we built the dynamic component storage layer -- type-erased ErasedStorage, registerComponent(T), getStorage(T), and a QueryIterator that matches entities across multiple component sets. The entity registry manages IDs with generation counters, the sparse sets store component data contiguously for cache-friendly access, and the World ties it all together. But we were still calling system logic as standalone functions scattered around main. There was no way to register systems with the engine, no way to control the order they run in, and no concept of delta time for frame-independent physics. Today we fix all of that.
This episode is about the S in ECS. Systems are the logic layer -- functions that query the World for entities matching certain component patterns and then do something with them. A movement system queries Position + Velocity entities and updates positions. A gravity system queries Velocity + Gravity entities and applies downward force. A collision system queries Position + Collider entities and detects overlaps. Each system is independent, each operates on its own slice of the data, and the engine controls when each one runs. By the end of this episode we'll have a system registration API, a scheduler that runs systems in order with delta time, and a working movement + collision simulation. Here we go!
Defining what a system is
In the simplest terms a system is a function. It takes the World (to query for entities and access components) and a delta time value (elapsed seconds since last frame), and it does its work. No return value, no stored state beyond what's in the components themselves. The function signature looks like this:
const std = @import("std");
// A system is just a function pointer with this signature
const SystemFn = *const fn (world: *World, dt: f32) void;
That's it. Every system in the engine has the same type: a pointer to a function that takes *World and f32. This uniformity is the key to making systems registrable and schedulable -- the engine doesn't need to know what a system does, only how to call it. We used function pointers like this back in episode 13 for type erasure and in episode 33 for state machine transition handlers. Same pattern, different context.
Having said that, why pass dt as a parameter instead of making each system read a global clock? Because systems become testable. In a test you can pass dt = 0.016 (one frame at 60 FPS) and verify exact state changes. With a global clock you'd have to mock time, which is messy. Explicit parameters make pure functions, and pure functions make reliable tests.
Some ECS frameworks store per-system state in "resources" -- singletons attached to the World that any system can read. We could add that, but for our engine the function-pointer approach is simpler and covers everything we need. A system that needs state can always close over it via a struct method, which we'll see in a moment.
The SystemRegistry: registering and scheduling systems
Now we need a place to store registered systems and run them in order. The SystemRegistry holds a list of system entries -- each with a name (for debugging), the function pointer, and a priority for ordering:
const SystemEntry = struct {
name: []const u8,
func: SystemFn,
priority: i32, // lower runs first
enabled: bool,
};
const SystemRegistry = struct {
systems: std.ArrayList(SystemEntry),
sorted: bool,
fn init(allocator: std.mem.Allocator) SystemRegistry {
return .{
.systems = std.ArrayList(SystemEntry).init(allocator),
.sorted = false,
};
}
fn deinit(self: *SystemRegistry) void {
self.systems.deinit();
}
fn addSystem(
self: *SystemRegistry,
name: []const u8,
func: SystemFn,
priority: i32,
) !void {
try self.systems.append(.{
.name = name,
.func = func,
.priority = priority,
.enabled = true,
});
self.sorted = false;
}
fn runAll(self: *SystemRegistry, world: *World, dt: f32) void {
if (!self.sorted) {
self.sortSystems();
}
for (self.systems.items) |entry| {
if (entry.enabled) {
entry.func(world, dt);
}
}
}
fn sortSystems(self: *SystemRegistry) void {
std.mem.sortUnstable(SystemEntry, self.systems.items, {}, struct {
fn lessThan(_: void, a: SystemEntry, b: SystemEntry) bool {
return a.priority < b.priority;
}
}.lessThan);
self.sorted = true;
}
fn setEnabled(self: *SystemRegistry, name: []const u8, enabled: bool) void {
for (self.systems.items) |*entry| {
if (std.mem.eql(u8, entry.name, name)) {
entry.enabled = enabled;
return;
}
}
}
fn count(self: *const SystemRegistry) usize {
return self.systems.items.len;
}
};
The priority field controls execution order: lower numbers run first. A gravity system at priority 100 runs before a movement system at priority 200, which runs before a collision system at priority 300. This is a simple approach -- production engines might use dependency graphs where you say "movement must run after gravity" explicitly, but numeric priorities are easy to understand and sufficient for our needs.
The sorted flag is an optimization. We only sort when a new system is added, not every frame. Once sorted, runAll just iterates the array in order. Sorting happens lazily on the first runAll call after an addSystem. We used std.mem.sortUnstable with an inline comparison function -- same pattern as sorting data in episode 22.
The enabled flag lets you turn systems on and off at runtime. Pausing the game? Disable the movement and AI systems, keep the rendering system running. Entering a cutscene? Disable player input but keep physics going. It's a simple boolean toggle per system, looked up by name. In a production engine you'd probably use a hash map for O(1) lookup, but linear search through a list of 10-20 systems is essentially free.
Integrating systems into the World
The World needs to own a SystemRegistry and expose methods for adding and running systems:
pub const World = struct {
registry: EntityRegistry,
storages: std.AutoHashMap(u64, ErasedStorage),
systems: SystemRegistry,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) World {
return .{
.registry = EntityRegistry.init(allocator),
.storages = std.AutoHashMap(u64, ErasedStorage).init(allocator),
.systems = SystemRegistry.init(allocator),
.allocator = allocator,
};
}
pub fn deinit(self: *World) void {
self.systems.deinit();
var it = self.storages.valueIterator();
while (it.next()) |storage| {
storage.deinit();
}
self.storages.deinit();
self.registry.deinit();
}
pub fn addSystem(
self: *World,
name: []const u8,
func: SystemFn,
priority: i32,
) !void {
try self.systems.addSystem(name, func, priority);
}
pub fn runSystems(self: *World, dt: f32) void {
self.systems.runAll(self, dt);
}
// ... registerComponent, getStorage, spawn, despawn from episode 56
};
Nothing fancy here. The World owns the SystemRegistry alongside the entity registry and component storages. addSystem delegates to the SystemRegistry, and runSystems calls runAll with the World pointer and delta time. The World passes itself as the first argument so systems can query components through it.
The important design decision is that systems don't own or store anything -- all game state lives in components. Systems read components, transform them, and write the results back. This means you can add, remove, reorder, or disable systems without worrying about orphaned state. The components are the single source of truth.
Building a query API
In episode 56 we had a QueryIterator that worked but was a bit clunky to use -- you had to define a struct type for every query. Let's improve the ergonomics. We want something like world.query(.{ Position, Velocity }) that returns an iterator yielding tuples of mutable component pointers:
pub fn query(self: *World, comptime types: anytype) QueryResult(@TypeOf(types)) {
return QueryResult(@TypeOf(types)).init(self);
}
fn QueryResult(comptime Tuple: type) type {
const fields = @typeInfo(Tuple).@"struct".fields;
// Build a result struct with mutable pointers to each component
const ResultFields = blk: {
var result_fields: [fields.len]std.builtin.Type.StructField = undefined;
inline for (fields, 0..) |field, i| {
const ComponentType = field.default_value_ptr.?;
const T = @as(*const type, @ptrCast(ComponentType)).*;
result_fields[i] = .{
.name = @typeName(T),
.type = *T,
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(*T),
};
}
break :blk result_fields;
};
_ = ResultFields;
// For simplicity, we return entity index + individual component pointers
return struct {
const Self = @This();
world: *World,
driver_entities: []const u32,
current_index: usize,
fn init(world: *World) Self {
// Find the smallest storage to drive iteration
var min_count: usize = std.math.maxInt(usize);
var driver_ents: []const u32 = &.{};
inline for (fields) |field| {
const T = field.default_value_ptr.?;
const ComponentType = @as(*const type, @ptrCast(T)).*;
const storage = world.getStorage(ComponentType);
if (storage.count() < min_count) {
min_count = storage.count();
driver_ents = storage.entities();
}
}
return .{
.world = world,
.driver_entities = driver_ents,
.current_index = 0,
};
}
fn next(self: *Self) ?u32 {
while (self.current_index < self.driver_entities.len) {
const entity_idx = self.driver_entities[self.current_index];
self.current_index += 1;
// Check that this entity exists in ALL queried storages
var all_present = true;
inline for (fields) |field| {
const T = field.default_value_ptr.?;
const ComponentType = @as(*const type, @ptrCast(T)).*;
if (!self.world.getStorage(ComponentType).has(entity_idx)) {
all_present = false;
}
}
if (all_present) return entity_idx;
}
return null;
}
};
}
That comptime tuple-based query is elegant but (I'll be honest) a bit too much metaprogramming magic for a tutorial. The struct-field reflection needed to extract types from .{ Position, Velocity } tuples involves casting default value pointers and gets into territory where the code is harder to understand than the concept it's teaching. So let's take a more practical approach instead and write query helpers that are explicit about what they do:
// query2: iterate all entities that have BOTH component A and component B
pub fn query2(
self: *World,
comptime A: type,
comptime B: type,
) Query2Iterator(A, B) {
return Query2Iterator(A, B).init(self);
}
fn Query2Iterator(comptime A: type, comptime B: type) type {
return struct {
const Self = @This();
storage_a: *SparseSet(A),
storage_b: *SparseSet(B),
driver_entities: []const u32,
current: usize,
fn init(world: *World) Self {
const sa = world.getStorage(A);
const sb = world.getStorage(B);
// Drive from the smaller set
if (sa.count() <= sb.count()) {
return .{
.storage_a = sa,
.storage_b = sb,
.driver_entities = sa.entities(),
.current = 0,
};
} else {
return .{
.storage_a = sa,
.storage_b = sb,
.driver_entities = sb.entities(),
.current = 0,
};
}
}
const Result = struct {
entity: u32,
a: *A,
b: *B,
};
fn next(self: *Self) ?Result {
while (self.current < self.driver_entities.len) {
const ent = self.driver_entities[self.current];
self.current += 1;
const ptr_a = self.storage_a.getMut(ent) orelse continue;
const ptr_b = self.storage_b.getMut(ent) orelse continue;
return .{
.entity = ent,
.a = ptr_a,
.b = ptr_b,
};
}
return null;
}
};
}
// query3: same pattern but for three components
fn Query3Iterator(comptime A: type, comptime B: type, comptime C: type) type {
return struct {
const Self = @This();
storage_a: *SparseSet(A),
storage_b: *SparseSet(B),
storage_c: *SparseSet(C),
driver_entities: []const u32,
current: usize,
fn init(world: *World) Self {
const sa = world.getStorage(A);
const sb = world.getStorage(B);
const sc = world.getStorage(C);
// Find smallest storage
var min_count = sa.count();
var driver = sa.entities();
if (sb.count() < min_count) {
min_count = sb.count();
driver = sb.entities();
}
if (sc.count() < min_count) {
driver = sc.entities();
}
return .{
.storage_a = sa,
.storage_b = sb,
.storage_c = sc,
.driver_entities = driver,
.current = 0,
};
}
const Result = struct {
entity: u32,
a: *A,
b: *B,
c: *C,
};
fn next(self: *Self) ?Result {
while (self.current < self.driver_entities.len) {
const ent = self.driver_entities[self.current];
self.current += 1;
const ptr_a = self.storage_a.getMut(ent) orelse continue;
const ptr_b = self.storage_b.getMut(ent) orelse continue;
const ptr_c = self.storage_c.getMut(ent) orelse continue;
return .{
.entity = ent,
.a = ptr_a,
.b = ptr_b,
.c = ptr_c,
};
}
return null;
}
};
}
Now query2(Position, Velocity) returns an iterator that yields Result{ .entity, .a = *Position, .b = *Velocity } for every entity that has both. The result gives you mutable pointers directly -- no extra lookups needed in the system body. And query3 extends it to three components for systems that need Position + Velocity + Gravity, for example.
Why separate query2 and query3 instead of a generic queryN? Because the comptime tuple approach requires some genuinely tricky metaprogramming to extract types from anonymous struct literals, and the explicit version is crystal clear about what's happening. In practice, most systems query 2-3 component types. If you need query4 or query5, the pattern is obvious -- just add more storage fields and null checks. Production ECS frameworks like Bevy use Rust's trait system and procedural macros to generate these automatically, but Zig's comptime can do it too if you want to invest in the metaprograming. For our engine, explicit is better.
Delta time: frame-independent physics
Games run at different frame rates on different hardware. If your movement system does position.x += velocity.dx every frame, an entity moves twice as fast at 120 FPS as it does at 60 FPS. The solution is delta time -- multiply everything by the elapsed time since the last frame:
const Timer = struct {
last_time: i64,
fn init() Timer {
return .{
.last_time = std.time.nanoTimestamp(),
};
}
fn tick(self: *Timer) f32 {
const now = std.time.nanoTimestamp();
const elapsed_ns = now - self.last_time;
self.last_time = now;
// Convert nanoseconds to seconds
const dt: f32 = @floatFromInt(elapsed_ns);
return dt / 1_000_000_000.0;
}
// Fixed dt for testing -- no real clock involved
fn tickFixed(_: *Timer, dt: f32) f32 {
return dt;
}
};
The Timer struct wraps std.time.nanoTimestamp() and computes the delta between consecutive tick() calls. The result is in seconds -- so at 60 FPS, dt is roughly 0.01667. At 30 FPS it's roughly 0.0333. The movement system multiplies velocity by dt, so entities move the same distance per second regardless of frame rate.
The tickFixed method is for testing -- it ignores the real clock and returns whatever dt you pass in. This lets you write deterministic tests: "with dt = 1.0, an entity at (0, 0) with velocity (5, 3) should end up at (5, 3)." No timing jitter, no flaky tests.
A subtlety worth mentioning: we're using variable time step here, where dt fluctuates frame to frame. For a physics simulation you might want a fixed time step where physics always advances by the same dt (e.g. 1/60 second) and you accumulate leftover time between frames. Fixed time step makes physics deterministic and prevents tunneling at low FPS. But for a terminal-rendered game at 30 FPS with simple AABB collisions, variable time step is perfectly fine. The concept is whats important -- you can swap the approach later without changing any system signatures.
The movement system
Now we get to write actual game logic. The movement system updates positions based on velocities, scaled by delta time:
fn movementSystem(world: *World, dt: f32) void {
var it = world.query2(Position, Velocity);
while (it.next()) |result| {
result.a.x += result.b.dx * dt;
result.a.y += result.b.dy * dt;
}
}
That's the entire movement system. Five lines of actual logic. The query2(Position, Velocity) handles finding matching entities, iterating the smallest set, checking for component presence, and returning mutable pointers. The system just does the math.
Compare this to what we had in episode 56 where we manually called getStorage, iterated entity indices, and did null checks inline. The query API wraps all of that into a clean iterator pattern. The system function doesn't need to know about sparse sets, dense arrays, or entity indices (though it gets the entity index in result.entity if it needs it for other lookups).
The gravity system
Gravity applies a downward force to velocity over time. Any entity with both Velocity and Gravity components gets affected:
fn gravitySystem(world: *World, dt: f32) void {
var it = world.query2(Velocity, Gravity);
while (it.next()) |result| {
result.a.dy += result.b.force * dt;
}
}
Again, trivially simple. The gravity system runs BEFORE the movement system (lower priority number), so gravity modifies velocity first, and then movement uses the updated velocity to move the position. Order matters -- if movement ran first, entities would always be one frame behind on gravity. This is exactly why we have the priority-based scheduler.
Notice that the gravity system doesn't touch Position at all. It only queries Velocity + Gravity. An entity with Position + Gravity but no Velocity won't be affected -- it's a static object that has gravitational properties but can't move. The component composition determines behavior, not any class hierarchy.
The collision system: AABB overlap detection
Collision detection gets more interesting. We need a new component for colliders -- an axis-aligned bounding box (AABB) defined by width and height, centered on the entity's position:
const Collider = struct {
width: f32,
height: f32,
};
const CollisionEvent = struct {
entity_a: u32,
entity_b: u32,
};
The CollisionEvent struct records which two entities collided. We'll store detected collisions in an array that other systems can read -- a damage system might check for collisions between bullets and enemies, a pickup system might check for collisions between the player and item entities.
const MAX_COLLISIONS: usize = 256;
fn collisionSystem(world: *World, _: f32) void {
const positions = world.getStorage(Position);
const colliders = world.getStorage(Collider);
// Collect all entities that have both Position and Collider
var entities_buf: [512]u32 = undefined;
var pos_buf: [512]Position = undefined;
var col_buf: [512]Collider = undefined;
var entity_count: usize = 0;
const coll_ents = colliders.entities();
const coll_data = colliders.data();
for (coll_ents, coll_data) |ent_idx, col| {
if (positions.get(ent_idx)) |pos| {
if (entity_count < 512) {
entities_buf[entity_count] = ent_idx;
pos_buf[entity_count] = pos.*;
col_buf[entity_count] = col;
entity_count += 1;
}
}
}
// Clear previous collisions
collision_count = 0;
// O(n^2) brute force -- fine for small entity counts
var i: usize = 0;
while (i < entity_count) : (i += 1) {
var j: usize = i + 1;
while (j < entity_count) : (j += 1) {
if (aabbOverlap(
pos_buf[i],
col_buf[i],
pos_buf[j],
col_buf[j],
)) {
if (collision_count < MAX_COLLISIONS) {
collisions[collision_count] = .{
.entity_a = entities_buf[i],
.entity_b = entities_buf[j],
};
collision_count += 1;
}
}
}
}
}
var collisions: [MAX_COLLISIONS]CollisionEvent = undefined;
var collision_count: usize = 0;
fn aabbOverlap(
pos_a: Position,
col_a: Collider,
pos_b: Position,
col_b: Collider,
) bool {
// AABB centers are at position, half-extents from width/height
const half_a_w = col_a.width / 2.0;
const half_a_h = col_a.height / 2.0;
const half_b_w = col_b.width / 2.0;
const half_b_h = col_b.height / 2.0;
const a_left = pos_a.x - half_a_w;
const a_right = pos_a.x + half_a_w;
const a_top = pos_a.y - half_a_h;
const a_bottom = pos_a.y + half_a_h;
const b_left = pos_b.x - half_b_w;
const b_right = pos_b.x + half_b_w;
const b_top = pos_b.y - half_b_h;
const b_bottom = pos_b.y + half_b_h;
// Two AABBs overlap if they overlap on BOTH axes
return a_left < b_right and a_right > b_left and
a_top < b_bottom and a_bottom > b_top;
}
The collision system uses global variables for the collision buffer -- not beautiful, but practical. In a production engine you'd store this in a "resource" on the World, but globals work fine for our purposes. The system collects all entities with Position + Collider into stack-allocated buffers (avoiding heap allocation during the game loop), then does an O(n^2) pairwise check. For a terminal game with maybe 50-100 entities, n^2 is completely irrelevant. For 10,000 entities you'd want spatial partitioning (grid, quadtree), but that's a whole episode on its own.
The aabbOverlap function implements the standard separating axis test for AABBs: two rectangles overlap if and only if they overlap on both the X axis and the Y axis. If there's any axis where they don't overlap, they're separated. This is one of those algorithms that's beautifully simple once you see it -- four comparisons, that's all.
Notice the collision system ignores dt. It doesn't need delta time because it's not modifying positions -- it's just detecting overlaps at the current instant. The _: f32 parameter accepts and discards it, keeping the system signature consistent.
A damage system that responds to collisions
To show how systems compose, here's a damage system that reads the collision buffer and reduces health for entities involved in collisions:
fn damageSystem(world: *World, _: f32) void {
const healths = world.getStorage(Health);
var i: usize = 0;
while (i < collision_count) : (i += 1) {
const event = collisions[i];
// Both entities in a collision take 1 damage
if (healths.getMut(event.entity_a)) |hp| {
hp.current -= 1;
if (hp.current < 0) hp.current = 0;
}
if (healths.getMut(event.entity_b)) |hp| {
hp.current -= 1;
if (hp.current < 0) hp.current = 0;
}
}
}
The damage system runs AFTER the collision system (higher priority number). It reads the global collision buffer and applies damage to any colliding entity that has a Health component. Entities without Health (like walls or particles) silently get skipped by the getMut null check. This is the ECS composability in action -- the damage system doesn't need to know what entities can collide. It just reads the collision events and damages anything with health.
System scheduling: run order and priorities
Let's put all the systems together with proper ordering:
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();
// Register component types
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Health);
try world.registerComponent(Sprite);
try world.registerComponent(Gravity);
try world.registerComponent(Collider);
// Register systems with priority ordering
// Lower priority = runs first
try world.addSystem("gravity", &gravitySystem, 100);
try world.addSystem("movement", &movementSystem, 200);
try world.addSystem("collision", &collisionSystem, 300);
try world.addSystem("damage", &damageSystem, 400);
// Spawn a player
const player = try world.spawn();
try world.getStorage(Position).set(player.index, .{ .x = 10.0, .y = 10.0 });
try world.getStorage(Velocity).set(player.index, .{ .dx = 2.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 });
try world.getStorage(Collider).set(player.index, .{ .width = 1.0, .height = 1.0 });
// Spawn an enemy in the player's path
const enemy = try world.spawn();
try world.getStorage(Position).set(enemy.index, .{ .x = 14.0, .y = 10.0 });
try world.getStorage(Velocity).set(enemy.index, .{ .dx = -1.0, .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 });
try world.getStorage(Collider).set(enemy.index, .{ .width = 1.0, .height = 1.0 });
// Spawn a falling projectile with gravity
const bullet = try world.spawn();
try world.getStorage(Position).set(bullet.index, .{ .x = 10.0, .y = 5.0 });
try world.getStorage(Velocity).set(bullet.index, .{ .dx = 1.0, .dy = 0.0 });
try world.getStorage(Sprite).set(bullet.index, .{ .char = '*', .color = 3 });
try world.getStorage(Gravity).set(bullet.index, .{ .force = 3.0 });
// No health, no collider -- it's a visual particle
const stdout = std.io.getStdOut().writer();
// Simulate 5 frames at fixed dt
const dt: f32 = 0.5; // half-second steps for visible results
var frame: u32 = 0;
while (frame < 5) : (frame += 1) {
world.runSystems(dt);
try stdout.print("--- Frame {d} (dt={d:.1}s) ---\n", .{ frame, dt });
// Print entity states
const positions = world.getStorage(Position);
const sprites = world.getStorage(Sprite);
const healths = world.getStorage(Health);
const pos_ents = positions.entities();
const pos_data = positions.data();
for (pos_ents, pos_data) |ent_idx, pos| {
const char: u8 = if (sprites.get(ent_idx)) |s| s.char else '?';
const hp_str: i32 = if (healths.get(ent_idx)) |h| h.current else -1;
try stdout.print(" [{c}] pos=({d:.1},{d:.1})", .{ char, pos.x, pos.y });
if (hp_str >= 0) {
try stdout.print(" hp={d}", .{hp_str});
}
try stdout.print("\n", .{});
}
if (collision_count > 0) {
try stdout.print(" Collisions: {d}\n", .{collision_count});
}
}
}
The execution order each frame is: gravity (100) -> movement (200) -> collision (300) -> damage (400). This order matters:
- Gravity modifies velocities based on gravitational force
- Movement updates positions using the (now gravity-affected) velocities
- Collision checks for overlaps at the new positions
- Damage applies damage based on detected collisions
If you swapped collision and movement, you'd be checking collisions at the OLD positions and then moving entities -- the collision detection would always be one frame behind. If you swapped gravity and movement, entities would move before gravity pulls them down, causing a one-frame lag in gravitational effects. The priority system makes these dependencies explicit and enforceable.
Testing systems
Testing ECS systems is straightforward: set up a World with known entities, run systems with a known dt, and verify the expected state changes. No mocks, no stubs, no dependency injection -- just data in, function call, data out:
test "movement system updates positions" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
const e = try world.spawn();
try world.getStorage(Position).set(e.index, .{ .x = 0.0, .y = 0.0 });
try world.getStorage(Velocity).set(e.index, .{ .dx = 10.0, .dy = 5.0 });
// Run movement with dt = 1.0 (one full second)
movementSystem(&world, 1.0);
const pos = world.getStorage(Position).get(e.index).?;
try std.testing.expectApproxEqAbs(pos.x, 10.0, 0.001);
try std.testing.expectApproxEqAbs(pos.y, 5.0, 0.001);
}
test "movement system with fractional dt" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
const e = try world.spawn();
try world.getStorage(Position).set(e.index, .{ .x = 100.0, .y = 50.0 });
try world.getStorage(Velocity).set(e.index, .{ .dx = -20.0, .dy = 10.0 });
// Half a second
movementSystem(&world, 0.5);
const pos = world.getStorage(Position).get(e.index).?;
try std.testing.expectApproxEqAbs(pos.x, 90.0, 0.001); // 100 + (-20 * 0.5)
try std.testing.expectApproxEqAbs(pos.y, 55.0, 0.001); // 50 + (10 * 0.5)
}
test "gravity system affects velocity" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Velocity);
try world.registerComponent(Gravity);
const e = try world.spawn();
try world.getStorage(Velocity).set(e.index, .{ .dx = 0.0, .dy = 0.0 });
try world.getStorage(Gravity).set(e.index, .{ .force = 9.8 });
gravitySystem(&world, 1.0);
const vel = world.getStorage(Velocity).get(e.index).?;
try std.testing.expectApproxEqAbs(vel.dx, 0.0, 0.001); // unchanged
try std.testing.expectApproxEqAbs(vel.dy, 9.8, 0.001); // gravity applied
}
test "gravity then movement produces correct arc" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Gravity);
const e = try world.spawn();
try world.getStorage(Position).set(e.index, .{ .x = 0.0, .y = 0.0 });
try world.getStorage(Velocity).set(e.index, .{ .dx = 5.0, .dy = 0.0 });
try world.getStorage(Gravity).set(e.index, .{ .force = 10.0 });
// Frame 1: gravity adds to dy, then movement applies
gravitySystem(&world, 1.0);
movementSystem(&world, 1.0);
const pos1 = world.getStorage(Position).get(e.index).?;
try std.testing.expectApproxEqAbs(pos1.x, 5.0, 0.001);
try std.testing.expectApproxEqAbs(pos1.y, 10.0, 0.001);
// Frame 2: gravity adds again, velocity is now (5, 20)
gravitySystem(&world, 1.0);
movementSystem(&world, 1.0);
const pos2 = world.getStorage(Position).get(e.index).?;
try std.testing.expectApproxEqAbs(pos2.x, 10.0, 0.001); // 5 + 5
try std.testing.expectApproxEqAbs(pos2.y, 30.0, 0.001); // 10 + 20
}
test "collision detection between overlapping entities" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Collider);
const e1 = try world.spawn();
try world.getStorage(Position).set(e1.index, .{ .x = 5.0, .y = 5.0 });
try world.getStorage(Collider).set(e1.index, .{ .width = 2.0, .height = 2.0 });
const e2 = try world.spawn();
try world.getStorage(Position).set(e2.index, .{ .x = 6.0, .y = 5.0 });
try world.getStorage(Collider).set(e2.index, .{ .width = 2.0, .height = 2.0 });
collision_count = 0;
collisionSystem(&world, 0.0);
try std.testing.expect(collision_count == 1);
try std.testing.expect(
(collisions[0].entity_a == e1.index and collisions[0].entity_b == e2.index) or
(collisions[0].entity_a == e2.index and collisions[0].entity_b == e1.index),
);
}
test "no collision between separated entities" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Collider);
const e1 = try world.spawn();
try world.getStorage(Position).set(e1.index, .{ .x = 0.0, .y = 0.0 });
try world.getStorage(Collider).set(e1.index, .{ .width = 1.0, .height = 1.0 });
const e2 = try world.spawn();
try world.getStorage(Position).set(e2.index, .{ .x = 10.0, .y = 10.0 });
try world.getStorage(Collider).set(e2.index, .{ .width = 1.0, .height = 1.0 });
collision_count = 0;
collisionSystem(&world, 0.0);
try std.testing.expect(collision_count == 0);
}
test "damage system reduces health on collision" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Collider);
try world.registerComponent(Health);
const e1 = try world.spawn();
try world.getStorage(Position).set(e1.index, .{ .x = 5.0, .y = 5.0 });
try world.getStorage(Collider).set(e1.index, .{ .width = 2.0, .height = 2.0 });
try world.getStorage(Health).set(e1.index, .{ .current = 10, .max = 10 });
const e2 = try world.spawn();
try world.getStorage(Position).set(e2.index, .{ .x = 6.0, .y = 5.0 });
try world.getStorage(Collider).set(e2.index, .{ .width = 2.0, .height = 2.0 });
try world.getStorage(Health).set(e2.index, .{ .current = 5, .max = 5 });
// Detect collision then apply damage
collision_count = 0;
collisionSystem(&world, 0.0);
damageSystem(&world, 0.0);
const hp1 = world.getStorage(Health).get(e1.index).?;
const hp2 = world.getStorage(Health).get(e2.index).?;
try std.testing.expect(hp1.current == 9);
try std.testing.expect(hp2.current == 4);
}
test "system registry runs in priority order" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Gravity);
const e = try world.spawn();
try world.getStorage(Position).set(e.index, .{ .x = 0.0, .y = 0.0 });
try world.getStorage(Velocity).set(e.index, .{ .dx = 0.0, .dy = 0.0 });
try world.getStorage(Gravity).set(e.index, .{ .force = 10.0 });
// Add systems: gravity before movement
try world.addSystem("gravity", &gravitySystem, 100);
try world.addSystem("movement", &movementSystem, 200);
// Run one frame
world.runSystems(1.0);
// Gravity should have added 10.0 to dy, then movement should
// have moved position by that velocity
const pos = world.getStorage(Position).get(e.index).?;
try std.testing.expectApproxEqAbs(pos.x, 0.0, 0.001);
try std.testing.expectApproxEqAbs(pos.y, 10.0, 0.001);
}
test "disabling a system skips it" {
const allocator = std.testing.allocator;
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
const e = try world.spawn();
try world.getStorage(Position).set(e.index, .{ .x = 0.0, .y = 0.0 });
try world.getStorage(Velocity).set(e.index, .{ .dx = 5.0, .dy = 0.0 });
try world.addSystem("movement", &movementSystem, 200);
// Disable movement
world.systems.setEnabled("movement", false);
world.runSystems(1.0);
// Position should be unchanged
const pos = world.getStorage(Position).get(e.index).?;
try std.testing.expectApproxEqAbs(pos.x, 0.0, 0.001);
// Re-enable and run again
world.systems.setEnabled("movement", true);
world.runSystems(1.0);
const pos2 = world.getStorage(Position).get(e.index).?;
try std.testing.expectApproxEqAbs(pos2.x, 5.0, 0.001);
}
Each test follows the same pattern: create a World, register components, spawn entities with known data, call the system function directly with a known dt, and assert the result. The tests are deterministic -- no timing dependencies, no randomness, no external state. This is one of the biggest advantages of the fn(world, dt) system signature: every system is a pure function of its inputs (the World state and dt), making it trivially testable.
The "gravity then movement" test is particularly instructive. It verifies the correct ordering: after one frame with dt = 1.0, gravity adds 10.0 to dy, then movement moves the position by the updated velocity. After two frames, the velocity has accumulated to dy = 20.0 and the position reflects the sum of both frame's movements. This is basic kinematics -- y = 0.5 * g * t^2 -- and the test proves our system order gives the correct physics.
The registry order test verifies the same thing through the scheduling API: register gravity at priority 100 and movement at 200, call runSystems, and verify the position reflects both systems running in the correct order.
Putting it all together
Here's the complete program that ties everything from this episode together -- entity setup, system registration, and a simulation loop with collision detection:
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();
// Register all component types
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Health);
try world.registerComponent(Sprite);
try world.registerComponent(Gravity);
try world.registerComponent(Collider);
// Register systems in execution order
try world.addSystem("gravity", &gravitySystem, 100);
try world.addSystem("movement", &movementSystem, 200);
try world.addSystem("collision", &collisionSystem, 300);
try world.addSystem("damage", &damageSystem, 400);
// Spawn player (moving right)
const player = try world.spawn();
try world.getStorage(Position).set(player.index, .{ .x = 5.0, .y = 10.0 });
try world.getStorage(Velocity).set(player.index, .{ .dx = 3.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 });
try world.getStorage(Collider).set(player.index, .{ .width = 1.0, .height = 1.0 });
// Spawn enemy (moving left, will collide with player)
const enemy = try world.spawn();
try world.getStorage(Position).set(enemy.index, .{ .x = 15.0, .y = 10.0 });
try world.getStorage(Velocity).set(enemy.index, .{ .dx = -2.0, .dy = 0.0 });
try world.getStorage(Health).set(enemy.index, .{ .current = 20, .max = 20 });
try world.getStorage(Sprite).set(enemy.index, .{ .char = 'E', .color = 1 });
try world.getStorage(Collider).set(enemy.index, .{ .width = 1.5, .height = 1.5 });
// Spawn a falling particle (gravity, no collision, no health)
const particle = try world.spawn();
try world.getStorage(Position).set(particle.index, .{ .x = 10.0, .y = 0.0 });
try world.getStorage(Velocity).set(particle.index, .{ .dx = 0.5, .dy = 0.0 });
try world.getStorage(Sprite).set(particle.index, .{ .char = '.', .color = 3 });
try world.getStorage(Gravity).set(particle.index, .{ .force = 5.0 });
const stdout = std.io.getStdOut().writer();
try stdout.print("=== ECS Systems Demo ===\n", .{});
try stdout.print("Systems: gravity(100) -> movement(200) -> collision(300) -> damage(400)\n\n", .{});
// Run simulation
const dt: f32 = 0.5;
var frame: u32 = 0;
while (frame < 8) : (frame += 1) {
world.runSystems(dt);
try stdout.print("Frame {d}:\n", .{frame});
const positions = world.getStorage(Position);
const sprites = world.getStorage(Sprite);
const healths = world.getStorage(Health);
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})", .{ char, pos.x, pos.y });
if (healths.get(ent_idx)) |hp| {
try stdout.print(" hp={d}/{d}", .{ hp.current, hp.max });
}
try stdout.print("\n", .{});
}
if (collision_count > 0) {
try stdout.print(" ** {d} collision(s) detected **\n", .{collision_count});
}
try stdout.print("\n", .{});
}
}
Running this you'll see the player (@) moving right and the enemy (E) moving left. After a few frames they'll collide -- the collision system detects the AABB overlap, and the damage system chips away at both entities' health each frame they remain overlapping. The particle (.) falls downward due to gravity while drifting right, completely unaffected by the collision system because it has no Collider component. Three different entity behaviors, zero inheritance, zero special casing -- just component composition and independent systems.
Wat we geleerd hebben
- Systems are functions with the signature
fn(world: *World, dt: f32) void-- they take the world and delta time, query for components, and transform game state - The SystemRegistry stores system entries with names, function pointers, and priority values, sorting them lazily so lower priority numbers run first
- The World integrates the SystemRegistry alongside entity and component storage, providing
addSystemandrunSystemsas the public API - Query helpers (
query2,query3) return iterators that drive from the smallest matching sparse set and yield mutable component pointers for every entity that has all required components - Delta time makes physics frame-rate independent:
position += velocity * dtmoves entities the same distance per second regardless of whether the game runs at 30 or 120 FPS - The movement system is just 5 lines: iterate the query, multiply velocity by dt, add to position
- AABB collision detection checks two rectangles for overlap on both axes -- four comparisons per pair, O(n^2) brute force for the full check, which is plenty fast for small entity counts
- System ordering matters: gravity modifies velocity before movement uses it, collision detects overlaps before damage reads the collision buffer
- Systems are independently testable: set up entities with known data, call the system with a known dt, assert exact results -- no mocks, no timing dependencies
- The enabled flag on systems lets you toggle logic at runtime without removing or re-registering system functions
Next episode we're building the terminal renderer -- the final piece that lets us actually see our game entities on screen. We'll handle screen buffers, sprite drawing, a camera system, and a proper game loop with frame timing. The ECS engine goes from data structures to an actual visible simulation ;-)
Bedankt en tot de volgende keer!