ZCS — An Entity Component System in Zig

April 11, 2025Mason Remaleytechziggamedev

I’m writing a new game engine in Zig, and I’m open sourcing each module as I write it.

Today I released the beta version of my entity component system ZCS, I’ll consider it a beta until I’ve shipped my next Steam game using it. You can find the documentation here.

I’ve written a number of ECSs in the past including the one used by Magic Poser. Going through this process a few times has given me a chance to hone in on what I want out of an ECS and I’m really happy with how this one turned out.

This post walks through some of the main ideas behind my implementation. If you enjoy this kind of writing, consider signing up for my newsletter.

What is an ECS?

An entity component system or “ECS” is a type of game object model, in other words it’s a way to manage game objects in a game engine. Under this terminology…

  • An entity is a game object, typically represented by an integer handle
  • A component is a piece of data that’s associated with an entity
  • A system is code that processes entities

Implementations strategies vary, but typical operations include:

  • Create an entity
  • Destroy an entity
  • Look up an entity’s component
  • Iterate over entities that have a given set of components

Implementations often resemble relational databases.

Side Note

This similarity with relational databases has lead some to suggest that game engines should just use existing databases as ECSs. After all, a lot of smart people have put a lot of work into these projects!

I found this perspective interesting, and attended Hytradboi this year in part to see what I could learn from folks who work on databases every day.

I concluded that this comparison is correct–an ECS is absolutely a database and there’s a lot game devs can learn from the database folks.

However, the bulk of the engineering work that goes into a typical database implementation is focused on tradeoffs related to networking and the file system that are not relevant to a typical game engine object model, so the actual implementations aren’t necessary transferable.

Why use an ECS?

Advocates for the pattern often focus on cache friendliness and maintainability when compared with OOP style deep class hierarchies.

I’m a fan of the pattern, but I find this comparison intellectually lazy.

This comparison was pertinent in 2007 when the pattern was first being evangelized, but it’s 2025–everything looks good when compared with deep class hierarchies!

There are plenty of other maintainable and cache friendly ways to architect a game object model. Furthermore, ECSs are cache friendly because it’s better than not being cache friendly, not because cache friendliness of game object data is actually the bottleneck for a typical game.

Instead, I think it’s more useful to compare an ECS to simpler approaches like a list of tagged unions, or a struct of arrays/MultiArrayList.

These are both incredibly simple ways to manage your game objects, and have the potential to be very performant.

A good argument for using an ECS should:

  1. Demonstrate that it has comparable performance to the simpler alternatives
  2. Demonstrate that it offers convenience to justify the extra complexity

There’s no reasonable argument against point one, so point two is more interesting.

Game engines are art tools, and as such, an engine that provides the designer with better convenience is a better engine.

I won’t try to convince you that an ECS is the right pattern for your game. Instead, this post focuses on the key features I implemented in ZCS that I find convenient as a game designer, you can decide for yourself whether or not you’d find value in them, and factor that into deciding whether or not to adopt the pattern or my implementation of it.

Quick Look

Here’s a quick look at what code using ZCS looks like:

// Reserve space for the game objects and for a command buffer.
// ZCS doesn't allocate any memory after initialization.
var es: Entities = try .init(.{ .gpa = gpa });
defer es.deinit(gpa);

var cb = try CmdBuf.init(.{
    .name = "cb",
    .gpa = gpa,
    .es = &es,
});
defer cb.deinit(gpa, &es);

// Create an entity and associate some component data with it.
// We could do this directly, but instead we're demonstrating the
// command buffer API.
const e: Entity = .reserve(&cb);
e.add(&cb, Transform, .{});
e.add(&cb, Node, .{});

// Execute the command buffer
// We're using a helper from the `transform` extension here instead of
// executing it directly. This is part of ZCS's support for command
// buffer extensions, we'll touch more on this later.
Transform.Exec.immediate(&es, &cb);

// Iterate over entities that contain both transform and node
var iter = es.iterator(struct {
    transform: *Transform,
    node: *Node,
});
while (iter.next(&es)) |vw| {
    // You can operate on `vw.transform.*` and `vw.node.*` here!
}

You can find the most recently built documentation here, or generate it yourself with zig build docs.

Persistent Keys

Games often feature objects whose lifetimes are not only dynamic, but depend on user input. ZCS provides persistent keys for entities, so they’re never dangling:

assert(laser.exists(es));
assert(laser.get(es, Sprite) != null);

laser.destroyImmediately(es);

assert(!laser.exists(es));
assert(laser.get(es, Sprite) == null);

This is achieved through a 32 bit generation counter on each entity slot. Slots are retired when their generations are saturated to prevent false negatives, see SlotMap for more info.

This strategy allows you to safely and easily store entity handles across frames or in component data.

For all allowed operations on an entity handle, see Entity.

Archetype Based Iteration

Gameplay systems often end up coupled not due to bad coding practice, but because these interdependencies often lead to dynamic and interesting gameplay.

Archetype based iteration via Entities.iterator allows you to efficiently query for entities with a given set of components. This can be a convenient way to express this kind of coupling:

var iter = es.iterator(struct {
    mesh: *const Mesh,
    transform: *const Transform,
    effect: ?*const Effect,
});
while (iter.next()) |vw| {
    vw.mesh.render(vw.transform, vw.effect);
}

If you prefer, forEach syntax sugar is also provided. The string argument is only used if Tracy is enabled:

fn updateMeshWithEffect(
    ctx: void,
    mesh: *const Mesh,
    transform: *const Transform,
    effect: ?*const Effect,
) void {
    // ...
}

es.forEach("updateMeshWithEffect", updateMeshWithEffect, {});

Entities.chunkIterator is also provided for iterating over contiguous chunks of component data instead of individual entities. This can be useful e.g. to optimize your systems with SIMD.

Optional Thread Pool Integration

If you’re already making use of std.Thread.Pool, you can operate on your chunks in parallel with forEachThreaded.

Have your own job system? No problem. forEachThreaded is implemented on top of ZCS’s public interface, wiring it up to your own threading model won’t require a fork.

Command Buffers

Games often want to make destructive changes to the game state while processing a frame.

Command buffers allow you to make destructive changes without invalidating iterators, including in a multithreaded context.

// Allocate a command buffer
var cb: CmdBuf = try .init(.{ .gpa = gpa, .es = &es });
defer cb.deinit(allocator, &es);

// Get the next reserved entity. By reserving entities up front, the
// command buffer allows you to create entities from background threads
// without contention.
const e = Entity.reserve(&cb);

// Schedule an archetype change for the reserved entity, this will
// assign it storage when the command buffer executes. If the component
// is comptime known and larger than pointer sized, it will
// automatically be stored by pointer instead of by value.
e.add(&cb, RigidBody, .{ .mass = 20 });
e.add(&cb, Sprite, .{ .index = .cat });

// Execute the command buffer, and then clear it for reuse. This would
// be done from the main thread.
CmdBuf.Exec.immediate(&es, &cb);

For more information, see CmdBuf.

When working with multiple threads, you’ll likely want to use CmdPool to manage your command buffer allocations instead of creating them directly. This will allocate a large number of smaller command buffers, and hand them out on a per chunk basis.

This saves you from needing to adjust the number of command buffers you allocate or their capacities based on core count or workload distribution.

If you need to bypass the command buffer system and make changes directly, you can. Invalidating an iterator while it’s in use due to having bypassed the command buffer system is safety checked illegal behavior.

Command Buffer Extensions

Entities often have relationships to one another. As such, operations like destroying an entity may have side effects on other entities–for example destroying an entity that has “children” should destroy its children too.

In a naive implementation, this can be difficult to manage.

ZCS solves this problem with command buffer extensions. I’m not sure if I invented this pattern–but I haven’t seen it done before. The key idea is that external code can add extension commands with arbitrary payloads to the command buffer, and then later iterate the command buffer to execute those commands or react to the standard ones.

This allows extending the behavior of the command buffer executor without callbacks. This is important because the order of operation between various extensions and the default behavior is often important and very difficult to manage in a callback based system.

To avoid iterating the same command buffer multiple times–and to allow extension commands to change the behavior of the built in commands–you’re expected to compose extension code with the default execution functions provided under CmdBuf.Exec.

As an example of this pattern, zcs.ext provides a number of useful components and command buffer extensions that rely only on ZCS’s public API…

Node

The Node component allows for linking objects to other objects in parent child relationships. You can modify these relationships directly, or via command buffers:

cb.ext(Node.SetParent, .{
	.child = thruster,
	.parent = ship.toOptional(),
});

Helper methods are provided to query parents, iterate children, etc:

var children = ship.get(Node).?.childIterator();
while (children.next()) |child| {
    // Do something with `child`
}

if (thruster.get(Node).?.parent.get(&es)) |parent| {
    // Do something with `parent`
}

The full list of supported features can be found in the docs.

Node doesn’t have a maximum child count, and adding children does not allocate an array. This is possible because each node has the following fields:

  • parent
  • first_child
  • prev_sib
  • next_sib

Deletion of child objects, cycle prevention, etc are all handled for you. You just need to use the provided helpers or command buffer extension command for setting the parent, and to call into Node.Exec.immediate to execute your command buffer:

Node.Exec.immediate(es, cb);

Keep in mind that this will call the default exec behavior as well as implement the extended behavior provided by Node. If you’re also integrating other unrelated extensions, a lower level composable API is provided in Node.Exec for building your own executor.

Transform2D

The Transform2D component represents the position and orientation of an entity in 2D space. If an entity also has a Node and relative is true, its local space is relative to that of its parent.

vw.transform.move(es, vw.rb.vel.scaled(delta_s));
vw.transform.rotate(es, .fromAngle(vw.rb.rotation_vel * delta_s));

Transform children are immediately synchronized by these helpers, but you can defer synchronization until a later point by bypassing the helpers and then later calling transform.sync(es).

Transform2D depends on geom for math.

It’s possible to implement a multithreaded transform sync in ZCS, in fact early prototypes worked this way. However, for typical usage, it’s easily 100x more costly to read this data into the cache on the background thread than it is to just do the matrix multiply immediately.

ZoneCmd

Deferred work can be hard to profile. As such, ZCS provides an extension ZoneCmd that allows you to start and end Tracy zones from within a command buffer:

const exec_zone = ZoneCmd.begin(&cb, .{
    .src = @src(),
    .name = "zombie pathfinding",
});
defer exec_zone.end(&cb);

See Tracy Integration for more info on Tracy support.

Tracy Integration

screenshot of the Tracy profiler

ZCS integrates with Tracy via tracy_zig. ZCS shouldn’t be your bottleneck, but with this integration you can be sure of it–and you can track down where the bottleneck is.

In particular, ZCS…

  • Emits its own Tracy Zones
  • Supports attaching zones to sections of command buffers via the ZoneCmd extension
  • Emits plots to Tracy, including information on command buffer utilization

Generics

horrible C++ template errors
friends don’t let friends write c++

Most ECS implementations use some form of generics to provide a friendly interface. ZCS is no exception, and Zig makes this easier than ever.

However, when important types become generic, it infects the whole code base–everything that needs to interact with the ECS also needs to become generic, or at least depend on an instantiation of a generic type. This makes it hard to write modular/library code, and presumably will hurt incremental compile times in the near future.

As such, while ZCS uses generic methods where it’s convenient, types at API boundaries are typically not generic. For example, Entities which stores all the ECS data is not a generic type, and libraries are free to add new component types to entities without an explicit registration step.

Performance / Memory Layout

the inside of a computer

ZCS is archetype based.

An “archetype” is a unique set of component types–for example, all entities that have both a RigidBody and a Mesh component share an archetype, whereas an entity that contains a RigidBody a Mesh and a MonsterAi has a different archetype.

Archetypes are packed tightly in memory into chunks with the following layout:

  1. Chunk header
  2. Entity indices
  3. Component data

Component data is laid out in AAABBBCCC order within the chunk, sorted from greatest to least alignment requirements to minimize padding. Chunks size is configurable but must be a power of two, in practice this results in chunk sizes that are a multiple of the cache line size which prevents false sharing when operating on chunks in parallel.

A simple acceleration structure is provided to make finding all chunks compatible with a given archetype efficient.

Comparing performance with something like MultiArrayList:

  • Iterating over all data results in nearly identical performance
  • Iterating over only data that contains supersets of a given archetype is nearly identical to if the MultiArrayList was somehow preprocessed to remove all the undesired results and then tightly packed before starting the timer
  • Inserting and removing entities is O(1), but more expensive than appending/popping from a MultiArrayList or leaving a hole in it since more bookkeeping is involved for the aforementioned acceleration and persistent handles
  • Random access is O(1), but more expensive than random access to a MultiArrayList as the persistent handles introduce a layer of indirection

No dynamic allocation is done after initialization.

Quick Tip

I recommend using std.process.cleanExit to avoid unnecessary clean up in release mode. Freeing all your stuff in debug builds is a good way to check for leaks, but you shouldn’t make end users wait on exit while you free allocations one at a time that the OS will take care of for you!

Next Steps

Now that I’m happy with where ZCS is at, I’m going to shift focus to my GPU API abstraction and renderer which will also be open sourced.

If you think ZCS would be useful in your project, feel free to read over the docs and give it a try. I won’t have good sample code in the repo until my renderer is done, but you can take a look at 2Pew for a quick example.

(Keep in mind that 2Pew is a side project that I don’t have a lot of time for right now, it’s not my main game and it’s missing a lot.)

If you find ZCS useful, contributions are welcome! If you’d like to add a major feature, please file a proposal or leave a comment on the relevant issue first.

Thanks for reading! If you want to stay up to date on my game engine work in Zig, consider signing up for my newsletter.