Build an Entity Component System (ECS) in Zig – From Scratch
I’ve been exploring Zig recently, and it quickly caught my attention as a language that feels like C—but with modern improvements.
I like Rust for large-scale, team-based projects, but for personal projects and rapid prototyping, it can sometimes feel restrictive. Zig, on the other hand, offers a balance: low-level control without getting in your way.
So I decided to build something practical with it: an Entity Component System (ECS).
Before jumping into the implementation, let’s understand what an ECS is—and why it exists.
A small sidenote: I will use raylib as the rendering backend and for some types (like
Vector2 or Color), but just like the general idea applies to any other programming
language, you can also swap out raylib for any other way of drawing graphics to the screen.
The Problems With the Usual Paradigm
ECS stands for Entity Component System, and it is a way to structure your software. According to Wikipedia, it’s a software architectural pattern.
It’s commonly used in game development, since it allows you to model objects in a game in a rather intuitive way, though it can be used for other things.
This post is not supposed to be a comprehensive guide on entity component systems as a whole, so I would like to avoid going into detail too much, but here is a brief introduction.
The usual - and for most people, most intuitive - way of creating a game is to create “arrays of things”.
So let’s say you have enemies in your game. Well, you create a class or struct called “Enemy”, and you put all of the data associated with that “thing” inside of that class or struct (I will just use struct from now on to stay closer to the low-level programming of C and Zig).
So, in Zig, your (oversimplified) Enemy struct could look like this:
const Enemy = struct {
position: Vector2,
velocity: Vector2,
texture: Texture,
equipped_weapon: Weapon,
// ...
};
This is great, it’s really easy to reason about. We have an enemy,
and if we want to spawn multiple enemies, we just create an ArrayList
of these enemies (or whatever other data structure you need).
There is one problem though: for one, we can’t really reuse our logic well.
Let’s say I have a move function. I now have to implement a move function
for each and every single entity in my game (like enemies, players, NPCs,
etc.), which are supposed to move.
So then I have Enemy.move(), Player.move(), NPC.move(), and so on.
With Object Oriented Programming patterns it’s possible to improve this,
but I believe it’s still less flexible than the ECS solution (I’ll get
into it in a second). Inheritance quickly becomes awkward and very rigid.
Ever heard of the phrase “prefer composition over inheritance”?
That’s literally what an ECS does.
Another issue with this way of thinking (a “list of things”), is that you sacrifice cache-efficiency.
You see, your computer is really slow at getting data from memory, like, really slow. That’s why CPU manufacturers tried to optimize your PC through a CPU-level cache. It’s basically a very small amount of memory that basically lives really close to your CPU. It holds small amounts of data which - according to certain algorithms - the CPU believes you will probably need to access soon. This is good, because the CPU cache is really fast compared to your RAM.
Think of your RAM as a really long tape which stores 1s and 0s (bits). Generally, if you access some data in your RAM, your CPU doesn’t just get that data, it gets the neighboring data as well (the data which is close in terms of location), and puts everything in the cache.
For example, arrays of integers are really cache efficient, because if you
want to loop over all the integers, you basically load the first integer
from memory, and your CPU believes that you will probably load
many of the subsequent elements of the array next. So it loads that
into the cache. Then, once your for loop advances to the next element,
your data is already there, available in the CPU cache.
For now, this is overly simplified, but what matters right now is intuition.
Now, in the struct above, when you want to move all enemies, you would
probably loop over some list of these enemies, and call a .move()
function on each of them.
This seems fine, but really, when moving, you only care about the
enemy’s position and velocity fields. Why would you care about
the texture field when moving your enemy?
But now we have a problem, when you loop over your enemies, your CPU loads everything related to that enemy in the cache.
It loads the enemy’s weapon and texture, even though we don’t care about it. That is wasted cache space which could be occupied by the position of subsequent enemies instead. Because of this, you have to get data from RAM way more frequently - which remember, is really, really slow - and it makes your whole program run slower.
So we have poor code reusability and terrible use of CPU cache. Here’s how we fix both issues at once.
How ECSs Solve These Issues
Through an ECS, you’re shifting your perspective. You don’t have “lists of things”, you have “collections of data”. You don’t have a “list of enemies”, or a “list of walls”, or “players”, or whatever.
You have a “list of positions”, or a “list of velocities”, or a “list of textures”.
This is much better, because not only is it more cache-friendly, because you just loop over all positions, which loads exactly what you want in the CPU cache, it’s also - in my opinion - more scalable.
Want some entity in your game to move? Well, just give it a velocity.
Want some entity in your game to have a mass? Just give it one.
Don’t worry about the actual implementation details for now, I will get into it soon.
Where the Name of an ECS Comes From
So why is it called “entity component system”?
Well, a component is what we just looked at. It’s a Position struct, or a Velocity
struct. These components carry data which is related and likely to be accessed together.
For example, Position holds both the x and y components (in my case in the form
of a Vector2, but don’t have to use that).
A component can be associated with an entity. An entity is usually just an id in the
form of an unsigned int (or in Zig, a u32).
This identifier is only needed to remember which component is associated with that entity.
You basically have a list of Position structs, and then you could have some
Enemy ids, like 2, 4, 8, 9, 10.
We know that these are enemies, because we put them in some collection (like an
ArrayList of type Enemy). You can add a position to them (we will see how
to do this in practice later).
We then have access to an entity_to_indices hashmap, which tells us
the index in the ArrayList of Position structs, which points to
the Position component associated with that Entity.
To access the Position struct of some Enemy, we can do this (this is pseudo-code similar
to Zig, I don’t guarantee that this is valid Zig):
for (enemies) |enemy: Entity| {
const pos_index = positions.entities_to_indices.get(enemy);
const vel_index = velocities.entities_to_indices.get(enemy);
const position = &positions.components[pos_index];
const velocity = &velocities.components[vel_index];
position.x += velocity.x;
position.y += velocity.y;
}
Ok, so we know what an entity is (basically an ID), and what a component is (the data associated with an entity). But what is a system?
A system is basically something that acts on some component to modify it.
For example, I might want to loop over all Position components and update
it with the respective velocity (similar to what I did above, but instead
of looping over only enemies, I could loop over all Position components).
This is where the real power of an ECS comes into play: the cache efficiency. Accessing this data is incredibly fast, because of the reasons explained above.
Basically, entities are just IDs, components are just data, and systems are just logic.
This is the general idea.
I will now implement a full-on implementation of a simple ECS in Zig.
Implementation
I will build a very simple baseline from which you can create your own abstractions and additions.
Note that this approach favors simplicity over maximum performance. At the very end, I will add some remarks to clarify what I mean.
Components
I will first create a file called components.zig. This file will contain
all possible components. In reality, you might want to organize the components into multiple
files if you have a lot of them. For now, I will stick to the basics.
Let’s create some structs which will represent the basic components which could be found in a game.
As I said, I will be using raylib, so inside of components.zig I import some types.
const Vector2 = @import("raylib").Vector2;
const Color = @import("raylib").Color;
We then create some basic components.
pub const Position = Vector2;
pub const Velocity = struct {
dx: f32,
dy: f32,
};
pub const Health = struct {
hp: u32,
max_hp: u32,
};
Let’s create something a bit more complicated. I will use a tagged union, which is a feature I really like about Zig. In C, you would have to keep track of the “tag” part manually.
For example, I want to represent the idea of some entity being “drawable” to the screen. I could want this to be a sprite coming from some file, but it may also simply be a rectangle or circle. This is how this is expressed in Zig.
pub const Drawable = union(enum) {
rectangle: struct {
size: Vector2,
color: Color,
},
circle: struct {
radius: f32,
color: Color,
},
sprite: struct {},
};
A similar idea holds for the Collider component, which expresses the idea of
an entity being able to collide with other entities.
The shape of the collider (basically, the “hit-box”) of the entity depends on how it’s drawn to the screen. In some cases, a rectangle may be more appropriate, other entities might need a circle. It could be even more complex, but let’s stick to these two for now. Again, we use a tagged union.
pub const RectangleCollider = struct {
size: Vector2,
};
pub const CircleCollider = struct {
radius: f32,
};
pub const Collider = union(enum) {
rectangle: RectangleCollider,
circle: CircleCollider,
};
The reason I decided to separate the variants of the union into dedicated named structs is
because this allows for more flexibility down the line. For example, we may want to create
a function which accepts a parameter which is the rectangle variant of the Collider union.
With this way of writing it, the type of the parameter would just be RectangleCollider.
Now, the following idea can be expressed in different ways. I wanted to have a way of making entities “controllable”. If something is controllable, it can move either up, down, left, or right, or a combination of any of these, based on the user input.
I store which direction is currently triggered in a struct.
pub const Controllable = struct {
up: bool = false,
down: bool = false,
left: bool = false,
right: bool = false,
};
We now have all the components that we need for this simple example.
Component Store
I will now create a generic struct which will store all the necessary information to pair components with the associated entities. It will also contain functions to associate the component with any entity, and same for removing it.
We create a file called component_store.zig.
Generic structs in Zig are implemented by creating a function which returns the type that we want (since in Zig, types are values and as such, can be passed to functions).
So we create the generic function ComponentStore which returns our struct.
pub fn ComponentStore(comptime T: type) type {
return struct {
const Self = @This();
data: std.ArrayList(T),
entities: std.ArrayList(Entity),
entity_to_index: std.AutoHashMap(Entity, u32),
allocator: std.mem.Allocator,
};
}
Basically, the data list contains a list of the same component. For example,
if we create a ComponentStore with the type T of Position, the data
list will contain all the Position instances present in our game or application.
The entities list contains all the entities which have a component associated with them
in the data list.
The entity_to_index hash map maps each entity to its index in the data list.
This is nice, because it allows us to look up the component associated with any entity
with a time complexity of , whereas otherwise we would need to traverse the list
of entities to find the index, which takes time.
I should mention that we always want to keep the order of the data and entities
lists in sync. That means that the first entity in the entities list is always
associated with the first entry of the data list. Same for the second, and so on.
Before we proceed with further steps, let us define an initializer.
pub fn init(allocator: std.mem.Allocator) !Self {
return .{
.data = try std.ArrayList(T).initCapacity(allocator, 64),
.entities = try std.ArrayList(Entity).initCapacity(allocator, 64),
.entity_to_index = std.AutoHashMap(Entity, u32).init(allocator),
.allocator = allocator,
};
}
We now add the functions that I mentioned before to the struct.
Because we need some way of adding entities to the collection of entities
(conceptually in the ECS this means that we want to add a component to an entity),
we create an add function.
pub fn add(self: *Self, entity: Entity, component: T) !void {
const index: u32 = @intCast(self.data.items.len);
try self.data.append(self.allocator, component);
try self.entities.append(self.allocator, entity);
try self.entity_to_index.put(entity, index);
}
The code should be self-explanatory.
Similarly, we want a way of removing a component from an entity.
This is where the remove function comes in.
pub fn remove(self: *Self, entity: Entity) void {
const index = self.entity_to_index.get(entity) orelse return;
const last_index = self.entities.items.len - 1;
_ = self.entities.swapRemove(index);
_ = self.data.swapRemove(index);
_ = self.entity_to_index.remove(entity);
if (index != last_index) {
self.entity_to_index.put(self.entities.items[index], index) catch unreachable;
}
}
This is not as straight-forward.
We want the ArrayLists to be tightly-packed.
That means that there should be no gaps in the list.
Therefore, we can’t just remove an element from the list.
Zig offers a convenient function of ArrayLists, which does exactly what we need.
It swaps the element we want to remove with the last element, then it removes the
element we wanted to remove (which is now at the end of the ArrayList).
This way, no gaps form.
An important thing is that if the element we removed was not the last element, we have
changed the order (and therefore the indices) of the entities and components.
The change must be reflected in the entity_to_index hash map, which is what the
if-statement is for.
A small convenience function is the get function.
pub fn get(self: *Self, entity: Entity) ?*T {
const index = self.entity_to_index.get(entity) orelse return null;
return &self.data.items[index];
}
This just wraps something that we would often be doing anyway in a function.
We give it an entity, and thanks to the entity_to_index hash map (which has a
lookup time of ), it finds the index of the associated component in the
data list, and returns the entry at that index.
Basically, we give it an Entity, and it spits out its component.
Finally, we create a deinit function which deallocates all our allocated
ArrayLists and our HashMap.
pub fn deinit(self: *Self) void {
self.data.deinit(self.allocator);
self.entities.deinit(self.allocator);
self.entity_to_index.deinit();
}
We are done with the ComponentStore and can move on to the EntityManager.
Entity Manager
We need a central way of managing the creation of new entities, because we cannot allow there to be duplicate entity IDs. For this, I will create a class to manage the creation and deletion of entities.
If you view it from an object oriented perspective, this would be a singleton struct, because we only ever create one instance of it and it will be passed around through the entirety of our program.
So, we create a file called entity.zig, and inside we start by importing the standard
library and defining some types.
const std = @import("std");
pub const Entity = u32;
pub const INVALID_ENTITY: Entity = std.math.maxInt(u32);
The INVALID_IDENTITY constant can come in handy since we can’t use the usual -1 value
as an invalid value, since we’re using unsigned integers (we get double the amount of
possible values for the same space compared to an i32).
The type Entity is just used so that we can quickly identify when something is an entity
as opposed to any other unsigned integer in our program. It’s just an alias, but
trust me, it makes everything much nicer.
Now we can create the entity manager.
pub const EntityManager = struct {
next_id: Entity = 0,
free_list: std.ArrayList(Entity),
allocator: std.mem.Allocator,
};
The next_id value will be used to keep track of the current ID we got to.
This is to make sure that any ID we give out to represent some entity
doesn’t conflict with a previously used ID.
The free_list, on the other hand, allows us to reuse IDs when we
destroy an entity. For example, in a hypothetical game, when you create some
enemies that could die, you don’t care about keeping the IDs of the
dead enemies occupied. If an enemy dies, you can add it to the
free_list, and the next time you need an ID, instead of incrementing the
value of next_id, you can just pick any ID from the free_list.
We now add some functions to this struct. We are going to need an initializer first.
It just initializes the free_list as an empty ArrayList.
pub fn init(allocator: std.mem.Allocator) !EntityManager {
return .{
.free_list = try std.ArrayList(Entity).initCapacity(allocator, 128),
.allocator = allocator,
};
}
We then define the create and destroy function, which are used to give out
and free IDs respectively.
pub fn create(self: *EntityManager) Entity {
if (self.free_list.items.len > 0)
return self.free_list.pop() orelse unreachable;
defer self.next_id += 1;
return self.next_id;
}
pub fn destroy(self: *EntityManager, entity: Entity) !void {
try self.free_list.append(self.allocator, entity);
}
The create function basically checks if there are any free IDs, and if there
are, it picks the last one. Otherwise, it increments next_id and returns the
previous value.
You could rewrite this by first incrementing next_id and then returning
next_id - 1, but the defer keyword in Zig allows for this neat trick
where next_id is incremented after the function returns. For some of you
this may be obvious, but as somebody coming from C and otherwise having used
many high-level languages without having much experience in Go, this felt
pretty remarkable.
Events
Also, we need events. The reason is that it’s generally not good practice
to call systems from other systems. So imagine this scenario:
you have a player which collides in an enemy. The movement is handled
by the movement system. That’s great, but we want to check for a
collision. That is handled by the collide system. If there is a
collision, we want to subtract some health from the player. This
is handled by the damage system. But how does the damage system
know if the player collided with anything? We can’t call damage
from collide, as I said, that’s bad practice. This is where events come in.
We will keep track of lists of events. We create a file called events.zig.
The scenario above can be solved by introducing a Collision event.
It looks like this:
const Entity = @import("entity.zig").Entity;
pub const Collision = struct {
a: Entity,
b: Entity,
};
It just tells us which two entities collided.
World
TODO: change this to reflect new World struct
We now create a World struct, which in OOP terminology would be another
singleton. We use this struct to group all of our collections of data
in one place. Remember that our collections of data are each represented by
a generic ComponentStore (which he have created above).
Our World will also contain lists of events. For this simple example,
we only have two types of events: Collision and Death.
The instance of this World struct will be passed around as a pointer
to all of our systems (remember - functions which act on our components),
which will make interacting with our final API much easier.
We define the World struct with the following fields.
pub const World = struct {
const Self = @This();
allocator: std.mem.Allocator,
entities: EntityManager,
components: struct {
positions: ComponentStore(components.Position),
velocities: ComponentStore(components.Velocity),
healths: ComponentStore(components.Health),
drawables: ComponentStore(components.Drawable),
controllables: ComponentStore(components.Controllable),
colliders: ComponentStore(components.Collider),
},
events: struct {
collisions: std.ArrayList(events.Collision),
deaths: std.ArrayList(events.Death),
},
};
I basically created a ComponentStore for each component that we defined
in components.zig.
The entities field is our EntityManager which will allow us to get
a valid ID for creating new entities.
We create an init function which just initializes all the fields with their own
respective init function. Now, this is really cool. We can use Zig’s powerful
meta-programming features to loop over the fields of our World struct and
initialize them all. I will show you the code and then go through it
step-by-step.
pub fn init(allocator: std.mem.Allocator) !World {
var components_init: @TypeOf(@as(World, undefined).components) = undefined;
inline for (std.meta.fields(@TypeOf(components_init))) |component_field| {
@field(components_init, component_field.name) =
try component_field.type.init(allocator);
}
var events_init: @TypeOf(@as(World, undefined).events) = undefined;
inline for (std.meta.fields(@TypeOf(events_init))) |event_field| {
@field(events_init, event_field.name) =
try event_field.type.initCapacity(allocator, 64);
}
return .{
.allocator = allocator,
.entities = try EntityManager.init(allocator),
.components = components_init,
.events = events_init,
};
}
Look at the first part, the one about the components field. We create a variable
called components_init and it has the type of our components field.
In Zig, undefined can be casted to any type. So we initialize this variable
with undefined.
Then, we have to use an inline for. What we’re doing must all be known at compile time.
This is very important, hence the inline for. If you don’t know what inline for is,
there is pretty good documentation about it online.
Inside of our inline for loop, we loop over all fields of components_init, which we
get through std.meta.fields. This basically returns a list of sturcts which contain
the name of each field as a []u8 and the type. The type of all our fields of components
is ArrayList, so what we’re doing inside the loop is calling the
.initCapacity function of ArrayLists. With @field we can access the
field of a struct (in this case components_init) by giving it the name of a one of its
fields as a string ([]u8). This is convenient, because we have access to the name of each
field.
A similar thing applies to the events field of our World struct.
The reason I decided to do it like this is because, if I wanted to add a new component to our
World, I can just add it to the World struct, and I don’t have to update any of the
functions which act on all components. Same goes for events.
We create a resetEvents function, which will be called at the end of each frame (since
events only live until the end of each frame).
Similarly to what we did in the init function, we loop over all our events
with std.meta.fields.
pub fn resetEvents(self: *Self) void {
inline for (std.meta.fields(@TypeOf(self.events))) |event_field| {
@field(self.events, event_field.name).clearRetainingCapacity();
}
}
We also want a way of destroying an entity when we don’t need it anymore.
That involves removing all components from that entity and freeing the
ID with the EntityManager.
pub fn destroyEntity(self: *Self, entity: Entity) !void {
inline for (std.meta.fields(@TypeOf(self.components))) |component_field| {
@field(self.components, component_field.name).remove(entity);
}
try self.entities.destroy(entity);
}
Even if we call .remove on a component that does not include that entity, that’s fine.
Finally, we create a deinit function to free all the memory our World uses.
pub fn deinit(self: *Self) void {
inline for (std.meta.fields(@TypeOf(self.components))) |component_field| {
@field(self.components, component_field.name).deinit();
}
inline for (std.meta.fields(@TypeOf(self.events))) |event_field| {
@field(self.events, event_field.name).deinit(self.allocator);
}
self.entities.free_list.deinit(self.allocator);
}
Systems
We are really close to finishing now. All that’s left for our basic ECS implementation are some systems which act on our components. Then we can finally start doing something with this.
We first handle some imports at the top of systems.zig.
const raylib = @import("raylib");
const Vector2 = raylib.Vector2;
const std = @import("std");
const World = @import("world.zig").World;
const components = @import("components.zig");
const Collider = components.Collider;
const RectangleCollider = components.RectangleCollider;
const CircleCollider = components.CircleCollider;
I first define a system which handles movement. Basically all systems will take a pointer to
our World as an argument and some - if they need it - will take dtime which
is the current duration of one frame.
In this case, dtime is used for consistent movement speed across different FPS ranges.
pub fn movement(world: *World, dtime: f32) void {
for (world.components.velocities.entities.items) |entity| {
const velocity = world.components.velocities.get(entity) orelse continue;
if (world.components.controllables.get(entity)) |controllable| {
velocity.dx = if (controllable.right)
200
else if (controllable.left)
-200
else
0;
velocity.dy = if (controllable.down)
200
else if (controllable.up)
-200
else
0;
} else {
velocity.dx += 50 * dtime;
}
if (world.components.positions.get(entity)) |position| {
position.x += velocity.dx * dtime;
position.y += velocity.dy * dtime;
}
}
}
We create a render function, which basically just draws everything to the screen.
I will leave out sprite rendering for now and only draw rectangles and circles.
pub fn render(world: *World) void {
for (world.components.drawables.entities.items) |entity| {
const drawable = world.components.drawables.get(entity) orelse continue;
if (world.components.positions.get(entity)) |position| {
switch (drawable.*) {
.rectangle => |rectangle| {
raylib.drawRectangle(
@intFromFloat(position.x),
@intFromFloat(position.y),
@intFromFloat(rectangle.size.x),
@intFromFloat(rectangle.size.y),
rectangle.color,
);
},
.circle => |circle| {
raylib.drawCircle(
@intFromFloat(position.x),
@intFromFloat(position.y),
circle.radius,
circle.color,
);
},
.sprite => |_| {
return;
},
}
}
}
}
We create a system for assigning the correct input to each Controllable.
pub fn assignInputs(world: *World) void {
for (world.components.controllables.data.items) |*controllable| {
controllable.up = raylib.isKeyDown(.up);
controllable.down = raylib.isKeyDown(.down);
controllable.left = raylib.isKeyDown(.left);
controllable.right = raylib.isKeyDown(.right);
}
}
You can also swap these out for the W, A, S and D keys.
Now we need a system to handle the creation of Collision events based
on the size and position of the colliders of all the entities.
Now, the collision between circles and rectangles is rather unusual in game engine tutorials you find online, so I will explain the idea in more detail.
Basically, your goal is to find the point on the rectangle closest to the center of the circle. Then it’s easy; you check whether the distance between that point on the rectangle and the center of the circle is smaller than the radius of the circle.
Also, of course, you want to make sure that the center of the circle is not inside of the rectangle, because that would automatically imply a collision.
We basically want to cover all combinations of the colliders that our game supports. For now, we only have rectangle and circle colliders. Therefore, we have four cases to cover. To avoid duplicate code, we extract the code for rectangle-and-circle collision in a function.
To avoid duplicate code, I create a helper function:
fn rectangleCircleCollision(
rectSize: Vector2,
rectPos: Vector2,
circlePos: Vector2,
circleRadius: f32,
) bool {
const cx = circlePos.x + circleRadius;
const cy = circlePos.y + circleRadius;
const closest_x = @max(
rectPos.x,
@min(
cx,
rectPos.x + rectSize.x,
),
);
const closest_y = @max(rectPos.y, @min(
cy,
rectPos.y + rectSize.y,
));
const dx = cx - closest_x;
const dy = cy - closest_y;
return dx * dx + dy * dy < circleRadius * circleRadius;
}
We can then create our collide system. In fact, the code for this is quite long.
Using the helper funciton rectangleCircleCollision defined above, you can even try
creating a suitable system for collision detection yourself!
Anyhow, for those interested, here is the whole code for the collision detection logic.
pub fn collide(world: *World) !void {
for (world.components.colliders.entities.items) |entity_a| {
for (world.components.colliders.entities.items) |entity_b| {
if (entity_a == entity_b) continue;
const position_a = (world.components.positions.get(entity_a) orelse continue).*;
const position_b = (world.components.positions.get(entity_b) orelse continue).*;
const collider_a = (world.components.colliders.get(entity_a) orelse continue).*;
const collider_b = (world.components.colliders.get(entity_b) orelse continue).*;
const hit = switch (collider_a) {
.rectangle => |rectangle_a| switch (collider_b) {
.rectangle => |rectangle_b| blk: {
const ax1 = position_a.x;
const ax2 = position_a.x + rectangle_a.size.x;
const ay1 = position_a.y;
const ay2 = position_a.y + rectangle_a.size.y;
const bx1 = position_b.x;
const bx2 = position_b.x + rectangle_b.size.x;
const by1 = position_b.y;
const by2 = position_b.y + rectangle_b.size.y;
break :blk ax1 < bx2 and ax2 > bx1 and ay1 < by2 and ay2 > by1;
},
.circle => |circle_b| blk: {
break :blk rectangleCircleCollision(
rectangle_a.size,
position_a,
position_b,
circle_b.radius,
);
},
},
.circle => |circle_a| switch (collider_b) {
.rectangle => |rectangle_b| blk: {
break :blk rectangleCircleCollision(
rectangle_b.size,
position_b,
position_a,
circle_a.radius,
);
},
.circle => |circle_b| blk: {
const dx =
(position_b.x + circle_b.radius) -
(position_a.x + circle_a.radius);
const dy =
(position_b.y + circle_b.radius) -
(position_a.y + circle_a.radius);
const radii_sum = circle_a.radius + circle_b.radius;
break :blk dx * dx + dy * dy < radii_sum * radii_sum;
},
},
};
if (hit) {
std.debug.print("COLLISION!\n", .{});
try world.events.collisions.append(world.allocator, .{
.a = entity_a,
.b = entity_b,
});
}
}
}
}
As you can see, we cover all combinations of colliders.
Finally, we create a (comparatively) simple system for killing entities.
pub fn kill(world: *World) !void {
for (world.components.healths.entities.items) |entity| {
const health_index =
world.components.healths.entity_to_index.get(entity) orelse continue;
const health = world.components.healths.data.items[health_index];
std.log.debug("health: {}", .{health.hp});
if (health.hp <= 0) {
try world.destroyEntity(entity);
}
}
}
It just checks all Healths and if it’s <= 0, it removes the associated entity.
Putting It All Together
We are done with our ECS implementation, we can now create a very simple game with this. I will create a very basic game just to show you how something like this would be used in practice.
In our main.zig file, we insert this at the top.
const std = @import("std");
const raylib = @import("raylib");
const entity = @import("entity.zig");
const systems = @import("systems.zig");
const World = @import("world.zig").World;
const SCREEN_WIDTH = 800;
const SCREEN_HEIGHT = 600;
const TARGET_FPS = 60;
pub fn main() !void {
// game logic
}
Then, inside our main function, we initialize Raylib.
raylib.initWindow(SCREEN_WIDTH, SCREEN_HEIGHT, "My Game");
raylib.setTargetFPS(TARGET_FPS);
defer raylib.closeWindow();
An allocator is needed to initialize our World. So let us do that now.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var world = try World.init(allocator);
defer world.deinit();
I create a Player and add some components to it.
const player = world.entities.create();
// position
try world
.components
.positions
.add(player, .{
.x = 400,
.y = 400,
});
// velocity
try world
.components
.velocities
.add(player, .{ .dx = 0, .dy = 0 });
// drawable
try world
.components
.drawables
.add(player, .{
.rectangle = .{
.size = .{
.x = 50,
.y = 50,
},
.color = .blue,
},
});
// controllable
try world
.components
.controllables
.add(player, .{});
// health
try world
.components
.healths
.add(player, .{
.hp = 1000,
.max_hp = 1000,
});
// rectangle collider
try world
.components
.colliders
.add(player, .{
.rectangle = .{
.size = .{
.x = 50,
.y = 50,
},
},
});
This works, we now create some enemies in a similar way.
for (0..10) |i| {
const enemy = world.entities.create();
// position
try world
.components
.positions
.add(enemy, .{
.x = @floatFromInt(0),
.y = @floatFromInt(i * 60),
});
// velocity
try world
.components
.velocities
.add(enemy, .{
.dx = 50,
.dy = 0,
});
// drawable
try world
.components
.drawables
.add(enemy, .{
.rectangle = .{
.size = .{
.x = 50,
.y = 50,
},
.color = .red,
},
});
// health
try world
.components
.healths
.add(enemy, .{ .hp = 10, .max_hp = 10 });
// rectangle collider
try world
.components
.colliders
.add(enemy, .{
.rectangle = .{
.size = .{
.x = 50,
.y = 50,
},
},
});
}
And finally, our game loop looks like this.
while (!raylib.windowShouldClose()) {
const dtime = raylib.getFrameTime();
std.log.debug("dtime: {}", .{dtime});
systems.assignInputs(&world);
systems.movement(&world, dtime);
try systems.collide(&world);
systems.damage(&world, dtime);
try systems.kill(&world);
world.resetEvents();
raylib.beginDrawing();
defer raylib.endDrawing();
systems.render(&world);
raylib.clearBackground(.black);
}
Result
If everything went well, a window should open and you should see a bunch of red squares (the enemies) accelerate from left to right towards the player (the blue square).
When an enemy collides with the player, it dies almost instantly (since an enemy only has 10 health points). What might not immediately be as obvious is that the player also loses some health. If you give the player less starting health - say, 50 - you can also see how the player dies after colliding with too many enemies.
Conclusion
I found this way of thinking very interesting and, since an ECS has real-world advantages in terms of performance, I think this is all very good to know. This is a simple example which is meant to show you the general idea, but this simple idea can be expanded further to make very complex games.
Note that this implementation does not fully optimize for cache-efficiency. For maximum performance, you would use something called Archetypes. I encourage you to look it up, this is how popular game engines like Unity implement their ECS. This is amazing for cache-efficiency, but also quite a bit more complex to implement, and this article is already insanely long.
I might write a part two to this article where I explain how to build an ECS with Archetypes in Zig, where I assume some knowledge about the language and ECSs in general.
With that, thank you for getting to the end of this article.
Hopefully you found this useful.