Idiomatic Zig 0.16 bindings for the flecs
entity-component-system (flecs v4.1.1, fetched via build.zig.zon).
constPosition=struct { x: f32, y: f32 };
constVelocity=struct { x: f32, y: f32 };
fnmove(dt: flecs.Delta, pos: *Position, vel: *constVelocity) void {
pos.x+=vel.x*dt.s;
pos.y+=vel.y*dt.s;
}
varworld=tryflecs.World(.{}).init(.{});
deferworld.deinit();
_=world.spawn(.{ Position{ .x=0, .y=0 }, Velocity{ .x=1, .y=2 } });
_=world.system(.on_update, move);
while (world.progress(1.0/60.0)) {}One rule: the binding translates declarations into flecs calls; it never invents runtime behavior. Everything is a plain Zig container, and everything comptime can derive is derived:
- Components are plain structs; tags are zero-size structs.
- A system is a function whose signature is its query.
- A query is a struct whose fields are its terms (named rows).
- Configuration is a declaration on the type -
pub const flecs_traits,pub const sort,pub fn onAdd, … - never a parallel descriptor bag. - Modules and extensions are Zig containers.
Pointer-ness encodes access (*T read-write, *const T read, ?*const T
optional); wrapper types encode operators (With, Up, Pair, …).
zig build test# run the test suite (the executable spec)
zig build examples # build every example
zig build run-hello # run one exampleFetch it into your project (records the URL + hash in build.zig.zon):
zig fetch --save git+https://github.com/zigsel/flecsThen wire the flecs module into your build.zig:
constflecs=b.dependency("flecs", .{
.target=target,
.optimize=optimize,
});
exe.root_module.addImport("flecs", flecs.module("flecs"));That's all — the dependency pulls the flecs amalgamation from its own upstream
release, compiles it, and links libc for you. In code: const flecs = @import("flecs");.
Building this package itself extracts the flecs source into a project-local
zig-pkg/(a build cache artifact — gitignored, regenerated on demand).
constPosition=struct { x: f32, y: f32 }; // plain dataconstActive=struct {}; // zero-size -> tagconstGravity=struct { // singletong: f32=9.81,
pubconstflecs_traits: flecs.Traits= .{ .singleton=true };
};
constMesh=struct {
handle: Handle,
pubfninit(self: *Mesh) void { self.handle=.invalid; } // ctor: fresh storagepubfndeinit(self: *Mesh) void { self.handle.release(); } // dtor: free ownedpubfnonAdd(self: *Mesh, e: flecs.Entity) void { register(e); } // event: added to an entitypubfnonSet(self: *Mesh) void { self.handle.upload(); } // event: value was setpubfnonRemove(self: *Mesh, e: flecs.Entity) void { notify(e); } // event: left an entity
};Two kinds of hook, don't confuse them:
init/deinitare the constructor / destructor (storage lifetime).init(*T)runs on fresh, zeroed storage whenever the component is added without a value (add,ensure) - the place to apply defaults;deinitruns once when the instance's memory is reclaimed (remove,destroy,clear, andworld.deinit()) - the leak-safe place to release resources.onAdd/onSet/onRemoveare event hooks (entity transitions). They run with entity context, for reactive logic - not storage cleanup. Releasing the same resource in bothonRemoveanddeinitdouble-frees it; pick one.
initonly matchespub fn init(*T) void; a value-returninginit(the common Zig convenience constructor) is left alone. Note flecs ignores Zig field defaults on a bareadd/ensure- useinitif you need them applied.
Configure traits on the type, or explicitly - every flecs trait is a field
(sparse, exclusive, transitive, cleanup, one_of, with, …):
_=world.component(Likes, .{ .storage=.sparse, .exclusive=true });conste=world.spawn(.{
Position{ .x=1, .y=2 }, // component valueActive, // tag typeflecs.childOf(parent), // relationships, all in one callflecs.pair(Likes, bob),
});
e.set(Position{ .x=9, .y=9 }); // add-or-write_=e.get(Position); // ?*const Te.ensure(Velocity).x=5; // get-mut, add if missing_=e.getMut(Velocity); // ?*T, null if absent (never adds)e.emplace(Velocity).*= .{...}; // get raw storage, construct in placee.toggle(Velocity, false); // disable without removingvarr=e.ref(Position); // cached ref: fast repeated r.get()_=e.clone(true); e.clear(); e.destroy();
// reusable bundles + bulk SoA spawnconstEnemy=flecs.bundle(.{ Health{ .hp=100 }, Collider{}, Active });
_=world.spawn(Enemy++ .{Position{ .x=0, .y=0 }});
_=world.spawnMany(1000, .{ .pos=positions_slice, .vel=Velocity{} });The row struct is the query - named, typed fields, declared inline:
varq=tryworld.query(struct {
e: flecs.Entity,
pos: *Position, // read-writevel: *constVelocity, // readboost: ?*constBoost, // optional_: flecs.Without(Dead), // filterpubconstsort=flecs.ascBy(Position, .y); // config as decls
});
deferq.deinit();
varit=q.iter();
deferit.deinit(); // idempotent; early break is safewhile (it.next()) |row|row.pos.x+=row.vel.x;
vart=q.tableIter(); // per-table SoA sliceswhile (t.next()) |tab|for (tab.pos, tab.vel) |*p, v|p.x+=v.x;Full operator set as wrapper fields: With · Without · Or ·
AndFrom/OrFrom/NotFrom · Up/Cascade (traversal) · Singleton ·
From("Entity", ...) (fixed source) · Scope(.not, .{...}) · Pair(R, ...)
(wildcard + matched target). Plus pub const group, pub const cache, the
tuple form world.query(.{ *Position, *const Velocity }), change detection
(q.changed()), introspection (q.count(), q.isTrue()), JSON (q.toJson),
and world.queryExpr("...") (DSL hatch) — which also carries named query
variables: q.findVar("friend") + it.setVar(v, entity) to constrain a
$var before iterating.
Iteration has more modes: q.pageIter(offset, limit) (a windowed slice),
q.workerIter(index, n) (manual sharding across threads), and on a grouped
query it.setGroup(id) / it.skip() / q.groupInfo(id).
fnmove(dt: flecs.Delta, pos: *Position, vel: *constVelocity) void { ... }
_=world.system(.on_update, move); // bare functionconstGravity=struct { // struct form + configpubconstphase=.on_update;
pubfneach(p: *Position, v: *Velocity, dt: flecs.Delta) void { ... }
};
_=world.add(Gravity);
constSpawner=struct { // stateful (closure-like)elapsed: f32=0,
pubfneach(self: *@This(), dt: flecs.Delta, _: *Position) void { self.elapsed+=dt.s; }
};
_=world.add(Spawner{}); // instance persistsRun-systems drive their own iteration with Query/Stage/Res/ResMut
params - multi-query, nested loops, deferred structural change:
fncollide(
ships: flecs.Query(struct { e: flecs.Entity, pos: *constPosition, _: flecs.With(Ship) }),
rocks: flecs.Query(struct { pos: *constPosition, _: flecs.With(Asteroid) }),
score: flecs.ResMut(Score),
stage: flecs.Stage,
) void {
varsi=ships.iter(); defersi.deinit();
while (si.next()) |s| { ...stage.destroy(s.e); score.v.points+=10; }
}fnonSpawn(_: flecs.OnSet(Position), e: flecs.Entity, p: *constPosition) void { ... }
_=world.observe(onSpawn);
// custom events with a payload (the *const E param is the event data)constDamage=struct { amount: u32, crit: bool };
fnonHit(_: flecs.OnEvent(Damage), hp: *Health, d: *constDamage) void { hp.hp-|=d.amount; }
_=world.observe(onHit);
world.emit(Damage{ .amount=10, .crit=true }, .{ .target=e }); // or stage.enqueue// monitors fire on enter/exit a query; observeOpts also has yield_existing_=world.observeOpts(onAlive, .{ .monitor=true });alice.addPair(Likes, bob);
alice.setPair(Owes{ .gold=30 }, bob); // pair with dataalice.removePair(Likes, bob); // (also ensurePair for get-mut)varts=alice.targets(Likes); // iterate all targetsalice.addEnum(Team.red); // enum components -> (Team, .case)constFighter=world.prefab(.{ .name="Fighter", .with= .{ Health{ .hp=100 }, flecs.autoOverride(Position) } });
constgrunt=world.spawn(.{ flecs.isA(Fighter), Position{ .x=5, .y=5 } });
constai=world.phase(.pre_update); // custom phases (dependency chain)constmovement=world.phaseAfter(ai);
_=world.systemIn(movement, moveSys);
consttimer=world.timer(0.5); // shared tick sourceworld.setTickSource(aiSystem, timer);constAgent=struct {
name: [:0]constu8, speed: f32, heading: Dir, waypoints: [3]f32,
pubconstflecs_units= .{ .speed=flecs.units.MetersPerSecond };
pubconstflecs_ranges= .{ .speed= .{ .min=0, .max=300 } };
};
_=world.reflect(Agent);
constjson=tryworld.toJson(Agent{ ... }, allocator); // also entity/world JSONtryworld.script(
\\Turret { Position: {10, 20} Velocity: {1, 0} }
); // + managed loadScripttryworld.scriptWith( // bind Zig values as $vars\\Spawn { Position: {$x, $y} }
, .{ .x=@as(f32, 42), .y=@as(f32, 7) });
varcur=world.metaCursor(Agent, &value); // field-by-field at runtimetrycur.push(); trycur.member("speed"); trycur.set(@as(f32, 5)); trycur.pop();Plus the Explorer: world.enableRest(.{}), world.importStats(),
entity.setDoc(.{ .brief = ... }), world.alert(.{...}), world.metric(...).
constphysics=struct {
pubconstGravity=struct { g: f32=9.81, pubconstflecs_traits: flecs.Traits= .{ .singleton=true } };
pubfnintegrate(dt: flecs.Delta, p: *Position, v: *Velocity, g: flecs.Singleton(*constGravity)) void { ... }
pubfninit(world: anytype) !void { world.set(Gravity{}); _=world.system(.on_update, integrate); }
};
tryworld.import(physics);
// extensions graft a typed namespace onto the world typevarworld=tryflecs.World(.{ .ext= .{spatial} }).initExt(.{}, .{spatial.Options{ .cell=32 }});
constcs=world.ext(spatial).cellSize();flecs.runtime(...) is process-global; call it once at startup, before any world.
// built-in worker threadsvarworld=tryflecs.World(.{}).init(.{ .threads=4 });
_=world.systemOpts(.on_update, move, .{ .multi_threaded=true });
// route all allocations through a Zig allocatorflecs.runtime(.{ .allocator=my_gpa });
// pure-std platform layer (no libc/pthread threading dependency)flecs.runtime(.{ .threading=.std });
varworld=tryflecs.World(.{}).init(.{ .task_threads=4 });
// run flecs' parallel pipeline on a std.Io executorflecs.runtime(.{ .io=threaded.io() });
varworld=tryflecs.World(.{}).init(.{ .task_threads=4 });
// manual staging: record structural changes off-thread, then mergeworld.setStageCount(n);
_=world.readonlyBegin(true);
// worker i drives world.stage(i) (itself a world handle) ...world.readonlyEnd(); // merges every stageOther knobs: world.setTimeScale (slow-mo/pause), resetClock, dim
(preallocate), measureFrameTime; entity-id management for networking
(world.makeAlive, getAlive, e.exists(), e.isValid()); and
flecs.log.setLevel(...) / enableColors(...) for flecs' log output.
For systems work that needs to reach under the abstraction:
// async stage: queue commands off-thread, merge when readyvars=world.asyncStage();
flecs.Entity.init(s.raw, e.id).set(Position{ ... });
s.merge(); world.freeStage(s);
// exclusive access: assert single-thread ownership (debugging cross-thread bugs)world.exclusiveAccessBegin("main"); deferworld.exclusiveAccessEnd(false);
// fast locked single-entity access (skips per-component lookups)varw=e.write().?; w.get(Position).?.x+=1; w.end();
// archetype inspection: the table (SoA storage) an entity lives inconstt=e.table().?;
for (t.column(Position).?) |*p|p.x=0; // whole column at once// maintenance / introspectionworld.setWith(Tag); // auto-add Tag to new entities (scoped)_=world.deleteEmptyTables(.{ .delete_generation=5 });
world.shrink(); // return reserved memory to the OSconstall=world.entities(); // snapshot of every id// archetype graph + bulk placement, dynamic value constructionconstt2=e.table().?.with(Tag); // archetype reachable by adding Tag_=world.spawnInTable(t2); // create straight into an archetypee.moveTo(t2); // move an entity between archetypesworld.valueInit(T, &scratch); // run T's ctor on raw storageThe flecs feature set is wrapped end-to-end; the raw C bindings are an internal detail and are not re-exported, so application code never drops to C.
zig build run-<name> - each is self-contained and prints a verifiable result.
hello | entity | component |
query_basics | query_advanced | relationship |
system_each | system_run | system_threaded |
observer_events | prefab | bundle |
pipeline | module | reflect_json |
script | explorer | |
runtime_allocator | runtime_std_threads | runtime_io |
The binding wraps the full flecs 4.1.1 feature set - core ECS, queries, systems/observers, relationships, prefabs, pipelines, reflection/JSON/Script, staging, tables, and the addons - behind idiomatic Zig. The raw C API is not exposed; everything is reachable through the typed surface above.
Known edges:
- Named query variables live on the
queryExprDSL path (findVar+it.setVar); a variable binds across terms, so it has no place in a single comptime struct field of a typed query. - String opaque serializes to JSON but doesn't deserialize (ownership).
- TreeSpawner has no public C API in flecs 4.1.1 - nothing to bind.
- Multi-world is supported sequentially; concurrent worlds sharing the static
component-id cache are not. The
flecs.runtimeknobs are one-shot by design. - Requires Zig 0.16.
build.zig module + flecs C compilation + tests/examples
build.zig.zon flecs v4.1.1 pulled as a package dependency
src/
root.zig public API surface
world.zig World(cfg) builder + the world API
entity.zig Entity handle
meta.zig component registration, traits, enum/id helpers
terms.zig query term wrappers + sort specs
bundle.zig spawn-bundle items
compile.zig shared term compiler (queries + systems)
query.zig typed Query(Row), iteration, untyped Expr
system.zig each-/run-/stateful-system & observer trampolines
stage.zig deferred command target
events.zig custom event emission
reflect.zig @typeInfo -> EcsStruct meta, JSON
meta_cursor.zig runtime field-by-field reflection cursor
units.zig typed unit-entity accessors
log.zig flecs logging controls
addons.zig doc / json / app / units / bitmask / alerts / metrics
runtime.zig allocator + pure-std / std.Io platform injection
tests.zig executable spec
examples/ 20 self-contained, runnable examples
The binding follows the flecs license (MIT). flecs © Sander Mertens.