An entity component system (ECS) written in Zig.
Heavily inspired by the rust ECS Bevy.
(Very similar API)
zig version: 0.15.1
- Bevy's super awesome plugin and systems API in Zig
- Lock free auto concurrency
- Queries, resources and system locals
- Component hooks and Bevy's 'Required Component' equivalent
- Event and State extension
- Archtype based storage
- Children Parent relations
- Manual control
- Can run inside a DLL (re-add plugins after reload)!. Enables hot reload!
Example:
pub const kn = @import("knoedel").Knoedel(.{
.thread_count = 8,
.max_frame_mem: usize = 128 * MB,
});
const Schedule = enum{
init,
pre_update,
update,
post_update,
pre_render,
render,
cleanup,
};
pub fn main() !void{
var app = try kn.App.init(std.c_allocator);
defer app.deinit();
// add your plugins
try app.addPlugin(@import("game_start.zig"));
// ...
// run the init schedule once
try app.run(Schedule.init);
app.flushCommands();
while (true) {
app.runPar(Schedule.pre_update);
app.flushCommands();
app.runPar(Schedule.update);
app.flushCommands();
app.runPar(Schedule.post_update);
app.flushCommands();
app.runPar(Schedule.pre_render);
app.flushCommands();
app.run(Schedule.render);
app.flushCommands();
app.runPar(Schedule.cleanup);
app.flushCommands();
app.update(); // reset frame arena, progress world ticks.
}
}All mentioned components are not part of Knödel. Only Parent and Children are provided.
// game_start.zig file
const m = @import("main.zig");
const kn = m.kn;
const Schedule = m.Schedule;
const Gamestate = m.Gamestate;
// ...
pub fn plugin(world: *kn.App) !void {
// conditional systems. Conditions just functions, you can write your own!
// there is kn.And(a,b) and kn.Or(a,b) for more complex conditionals.
try world.addSystemEx(Schedule.update, &spawn_player, kn.OnEnter(Gamestate.prepare));
// chaining systems will respect order, even on concurrent schedules
try world.addSystem(Schedule.update, kn.Chain(.{&prepare, &aim, &move}));
}
// declare a resource and add it to the world
const MyRes = struct {
value: usize = 0,
};
try app.addResource(MyRes{});
// A system can have unlimited args. All must implement `fromWorld(world: *kn.App)`.
// to harden any arg for lock free concurrency, look at the `addAccess` func on `ResMut` for example.
fn spawn_player(
// access the frame or world arena
alloc: kn.Alloc,
// any mutation of the world goes through commands.
cmd: kn.Commands,
// Mutable Resource, just like in Bevy, `Res(type)` for read access only.
game_state: kn.ResMut(kn.State(Gamestate)),
// Queries can have multiple filters. `With`, `Without`, `Added` and `Changed` provided by core
// Use `*T` for mutable access, `*const T` for read-only access
query: kn.QueryF(struct {
entity: kn.Entity,
enemy: *Enemy,
some: *const SomeComp,
}, .Added(Idle)),
// you can also query without filters
short_query: kn.Query(struct { t: *Transform, v: *const Visibility }),
// system local resources.
counter: kn.Local(MyRes),
// event writer and reader, just like in Bevy
) !void{
// spawn player
const player_entity = try cmd.spawn({
Transform{},
PlayerControlled{},
Sprite.load("player.png"),
.{ // tuples = spawn new entity as child
Transform{},
Sprite.load("gun.png"),
}
});
// mutate some system local state.
counter.inner.value += 1;
// add player as target for all enemies
var it = query.iter();
while(it.next()) |entry| {
entry.enemy.target = player_entity;
std.debug.print("updated entity {d}\n", .{entry.entity.id()});
}
var sit = short_query.iter();
while(sit.next()) |en|{
std.debug.print("transform: {any}\n", .{en.t.*});
}
// enter next state
try game_state.inner.set(Gamestate.ingame);
// allocate in the current frame arena, don't bother with deallocation.
// great for extracting and prepping render data for the current frame.
_ = try alloc.frame.alloc(u8, 1000);
}Any function with this signature can be a system filter:
*const fn (*World, *LocalRegistry) !bool
Any struct that implements these functions can be system parameter:
pub fn fromWorld(world: *World) !Self or pub fn fromWorld(world: *World, locals: *ResourceRegistry) !Self
To mark mutable access in a parallel world and let the system scheduler resolve it for your, implement this:
pub fn addAccess(access: *Access) void
Take a look at ResMut or any other baseline arg for further information.
// example: System on a timer using locals
pub fn OnTimer(comptime delta: f32) kn.App.SystemRegistry.ConditionFn {
return (struct {
fn on_timer(world: *kn.App, locals: *kn.ResouceRegistry) !bool {
// `SystemTimer` is a simple state struct with `tick` func
var timer = locals.get(SystemTimer) orelse blk: {
try locals.register(world.memtator.world(), SystemTimer{
.duration = delta,
});
break :blk locals.get(SystemTimer).?;
};
const time = try world.resource(Time);
if (timer.tick(time.delta)) return true;
return false;
}
}).on_timer;
}
pub fn plugin(world: *kn.App) !void {
// `And`, `Or` are also just functions taking 2 other functions following the signature
try app.addSystemEx(Schedule.update, &my_system, kn.And(kn.InState(Gamestate.menu), OnTimer(1/60)));
// use tuples to register multiple systems at once
try app.addSystem(Schedule.update, .{&asys, &bsys, &csys});
// chains respect order, tuples in chains run in parallel
try app.addSystem(Schedule.update, kn.Chain{&asys, .{&b1sys, &b2sys}, &csys});
}
// Here are all filters available in one query.
// !IMPORTANT! `Changed` only works when you mark components as changed. In Zig we can't simply have deref traits implement side effects.
// This can be done on the current iteration via `it.changed(T)`.
fn my_system(
query: kn.QueryF(struct {
entity: kn.Entity,
transform: *Transform,
sprite: *const Sprite,
}, .And3(.Changed(Transform), .Without(Hidden), .Added(GlobalTransform))),
time: kn.Res(Time),
some_mut_res: kn.ResMut(Something),
)!void{
// access a resource inner
std.debug.print("dt: {d}\n", .{time.inner.delta});
// mutate a resource
some_mut_res.inner.something = 42;
// use `*T` for mutable access, `*const T` for read-only
// `With(Sprite)` is implicit since `sprite` is in the query struct
var it = query.iter();
while(it.next()) |entry|{
// you need to manually mark a component as changed in the current iteration
it.changed(Transform);
}
...
}Following the provided plugin API you can build any Extension. Just like in Bevy, most features can be build with the plugin system.
The State and Event Extension act as an example and are separated from the core in under ~100 lines.
State management with system conditions
const Gamestate = enum {
loading,
menu,
game,
};
pub fn plugin(world: *kn.App) !void {
// add your state with the default and schedules where cleanup happens
try app.addPlugin(kn.StatePlugin(Gamestate.loading, Schedule.cleanup));
// States over multiple system conditions `OnEnter` `OnExit` and `InState`.
try app.addSystemEx(Schedule.update, &my_system, OnEnter(Gamestate.menu));
}
pub fn my_system(
cmd: kn.Commands,
) !void{
_ = try cmd.spawn(.{
// auto cleanup entities on state exit
kn.StateScoped(Gamestate){.state = menu},
..
});
}Event Reader and Writer, just like in bevy! Currently events use 2 swap buffers. Meaning your systems reads the event of the last frame.
pub const MyEvent = struct{
target: kn.Entity,
somevalue: f32,
};
pub fn plugin(world: *kn.App) !void {
// add the event plugin with a schedule on which events are processed
try app.addPlugin(kn.EventPlugin(MyEvent, Schedule.cleanup));
}
// send a event
pub fn send_event_system(
writer: kn.EventWriter(MyEvent),
) !void{
try writer.send(MyEvent{
.target = .placeholder,
.somevalue = 420,
});
}
// You can consume the same event on multiple systems.
pub fn read_event_system(
reader: kn.EventReader(MyEvent),
) !void{
for (reader.queue) |event|{
...
}
}Job pool Arg does what it sounds like. A simple pool to further balance load on your available threads.
pub fn propagate_global_transform(
jobs: kn.Jobs,
roots: kn.QueryF(struct { entity: kn.Entity, t: *const Transform }, .Without(kn.Parent)),
query: kn.Query(struct { t: *const Transform, gt: *GlobalTransform, children: ?*kn.Children }),
) !void{
var it = roots.iter();
var wg = std.Thread.WorkGroup{};
while(it.next()) |entity| try jobs.go(&wg, propgate_tree, .{entity, query});
wg.wait();
}
// (this is just an example, in a real app, you should batch work)Attach logic to component events. Perfect for hooking up external libs for example: Box2D physics. Hooks run in the command flushing process, meaning they run in sync. Keep it simple, don't add heavy logic.
pub fn plugin(world: *kn.App) !void {
try world.addOnAddHook(Body, &body_add_hook);
try world.addOnRemoveHook(Body, &body_remove_hook);
try world.addOnDespawnHook(Body, &body_remove_hook);
}
// all hooks share the same function signature.
fn body_remove_hook(comp: *Body, _: kn.Entity, _: *kn.App) !void {
if (comp.body_id) |id| box2d.b2DestroyBody(id);
}
// you can mutate added components
fn body_add_hook(comp: *Body, _: kn.Entity, world: *kn.App) !void {
const phys = try world.getResource(Physics);
comp.body_id = b2CreateBody(phys.world_id, &comp.body_def)
}
Required is a tuple with components (and defaults) that will be attached to the entity, if not present.
pub const Sprite = struct{
pub const Required = .{Transform{}, Visbility{}};
//...
};Use your preferred way (clone/copy/fetch)
zig fetch --save git+https://github.com/Lommix/knoedel
const knoedel_dep = b.dependency("knoedel", .{});
//..
your_game.root_module.addImport("knoedel", knoedel_dep.knoedel.module("knoedel"));