Bevy-inspired ergonomics for Ark ECS: codegen, staged scheduling, and fast typed events.
- Simple runtime:
App+ staged scheduler - Intelligent parallel scheduler with dependency ordering and access conflict detection
- Per-type, frame-based, high-performance events with cancellation and completion handles
- Code generator that wires your systems together from doc comments and function signatures
Add the runtime to your module:
go get github.com/oriumgames/bevi@v0.2.2Optionally install the generator:
# As a binary you can call directly (name depends on your shell/OS, shown here via go run)
go install github.com/oriumgames/bevi/cmd/gen@v0.2.2You can also run the generator without installing:
# From inside this repository or when vendored
go run ./cmd/gen -root .
# From another module (using the latest published version)
go run github.com/oriumgames/bevi/cmd/gen@v0.2.2 -root .- Define components and annotate your systems:
type Position struct{ X, Y float64 }
type Velocity struct{ X, Y float64 }
//bevi:system Startup
func Spawn(mapper *bevi.Map2[Position, Velocity]) {
mapper.NewEntity(&Position{X: 0, Y: 0}, &Velocity{X: 1, Y: 0.5})
}
//bevi:system Update Every=16ms
func Move(q *bevi.Query2[Position, Velocity]) {
for q.Next() {
p, v := q.Get()
p.X += v.X
p.Y += v.Y
}
}
//bevi:system Update After={"Move"} Every=1s
func PrintCount(q bevi.Query1[Position]) {
n := 0
for q.Next() {
_, n = q.Get(), n+1
}
fmt.Println("entities:", n)
}- Generate glue code:
go run github.com/oriumgames/bevi/cmd/gen@v0.2.2 -root . -writeThis writes bevi_gen.go next to your files and creates a function:
func Systems(app *bevi.App)that registers all your annotated systems.
- Boot your app:
func main() {
bevi.NewApp().
AddSystems(Systems). // from bevi_gen.go
Run()
}That’s it. Your app now runs the staged pipeline; systems are ordered, batched for parallelism, throttled by Every, and integrated with typed events.
Bevi uses a single doc-comment line to declare scheduling metadata:
//bevi:system <Stage> [Key=Value ...]Supported keys:
- Stage: one of PreStartup, Startup, PostStartup, PreUpdate, Update, PostUpdate
- Every: Go duration (e.g.,
500ms,1s) to throttle execution - Set: string set/group name (used for Before/After targets as well)
- After: names or set names the system must run after, e.g.,
After={"A","B","physics"} - Before: names or set names the system must run before
- Reads: component types read (overrides inference)
- Writes: component types written (overrides inference)
- ResReads: resource types read
- ResWrites: resource types written
The generator also infers access from parameters:
context.Context-> passed through*bevi.Worldorbevi.World-> passed through*bevi.MapN[T...]-> component WRITE access on T...bevi.QueryN[T...]-> READ access by default, WRITE access if you accept a pointer*bevi.QueryN[...](write intent marker)*bevi.FilterN[T...]-> no direct access (it is a builder used to produce queries)bevi.Resource[T]-> READ access by default, WRITE access if you accept a pointer*bevi.Resource[T](write intent marker)bevi.EventWriter[E]-> event WRITE access for Ebevi.EventReader[E]-> event READ access for E
The generator synthesizes helpers once per package (mappers, filters, resources, event readers/writers), wires everything in a single Systems(app *bevi.App) function. It does not auto-close queries; only call Close() yourself when you exit iteration early.
You can refine bevi.FilterN (and filters used to spawn queries) via extra doc lines:
//bevi:filter <paramName | Qk | Fk> [+Type | -Type | !exclusive | !register]...+Typeincludes a component type-Typeexcludes a component type!exclusiveapplies Ark’s.Exclusive()!registerapplies Ark’s.Register()- Use
Q0,Q1orF0,F1to refer to positional query/filter parameters if no name is used - Qualified types may use import aliases; the generator normalizes them
Example:
//bevi:system Update
//bevi:filter q +pkg.Position -pkg.Hidden !exclusive
func Move(q *bevi.Query2[pkg.Position, pkg.Velocity]) { ... }Usage:
gen [flags]
Flags:
-root string root directory to scan (module/package root) (default ".")
-write write generated files (bevi_gen.go); if false, print to stdout (default true)
-v verbose logging to stderr
-pkg string only process packages whose name contains this substring
-include-tests include _test.go files during scanning
Notes:
- The generator writes one
bevi_gen.goper package that has at least one//bevi:systemfunction. - It skips
bevi_gen.goitself to avoid feedback loops. - You can run the generator at any time; it is deterministic and safe to re-run.
bevi.App orchestrates Ark’s bevi.World, the scheduler, and the event bus:
- Stages:
- PreStartup, Startup, PostStartup (run once at boot)
- PreUpdate, Update, PostUpdate (run every frame)
- Between stages, the app completes events for frames with no readers and advances the event bus:
events.CompleteNoReader()thenevents.Advance()
Typical boot:
app := bevi.NewApp().
AddSystems(Systems). // from bevi_gen.go
SetDiagnostics(bevi.NewLogDiagnostics(log.Default()))
app.Run() // blocks until SIGINT/SIGTERMManual registration (without the generator) is also supported:
acc := bevi.NewAccess()
bevi.AccessWrite[MyComponent](&acc)
meta := bevi.SystemMeta{
Access: acc,
After: []string{"OtherSystem"},
Every: 250 * time.Millisecond,
}
app.AddSystem(bevi.Update, "MySystem", meta, func(ctx context.Context, w *bevi.World) {
// ...
})- Orders systems with a deterministic topological sort using
Before/Afterconstraints.- Targets can be system names or
Setnames (applies to all members of that set).
- Targets can be system names or
- Builds batches of conflict-free systems to run in parallel.
- Detects access conflicts using precomputed sets and compact bitsets:
- Component conflicts: write/read, write/write
- Resource conflicts: write/read, write/write
- Event conflicts: writer/reader, writer/writer
- Respects
Everyon each system; execution is gated by a high-resolution timestamp. - Uses a bounded worker pool sized to
GOMAXPROCSand catches panics, reporting them via diagnostics.
A bevi.EventBus delivers events from writers to readers frame-by-frame:
-
Writers:
Emit(v T)fire-and-forgetEmitResult(v T)returnsEventResult[T]with completion/cancellation handlesEmitAndWait(ctx, v T)convenience, returns whether it was cancelledEmitMany([]T)bulk emit with fewer allocations
-
Readers:
ForEach(func(T) bool)is the zero-allocation way to iterate events:reader.ForEach(func(ev MyEvent) bool { // optional cancellation reader.Cancel() if reader.IsCancelled() { /* react */ } return true // return false to stop })
Drain(),DrainTo(buf)special cases for batch extraction (when used, writers rely onCompleteNoReader()to finalize)
-
Results:
Valid(),Cancelled()Wait(ctx)blocks until the event finished processing by all readers in the frameWaitCancelled(ctx)returns as soon as cancellation is observed, completion, or ctx done
-
Frame semantics:
- Writers append to the “write” buffer this frame.
- After systems run, the app calls
CompleteNoReader(), then flips buffers viaAdvance(). - Readers iterate the previous frame’s writes.
You can access the bus directly via app.Events(), or pass it in context using bevi.WithEventBus and fetch typed readers/writers with bevi.ReaderFromContext[T] and bevi.WriterFromContext[T].
Plug a diagnostics implementation into your app:
type Diagnostics interface {
SystemStart(name string, stage bevi.Stage)
SystemEnd(name string, stage bevi.Stage, err error, duration time.Duration)
}
app.SetDiagnostics(bevi.NewLogDiagnostics(log.Default()))Built-ins:
NopDiagnostics– does nothingNewLogDiagnostics(l interface{ Printf(string, ...any) })– logs start/end and durations, reports panics as errors
See ./example/test. It demonstrates:
- Components, events, and multiple
//bevi:systemfunctions - Event cancellation and
WaitCancelled - Dependencies and
Everythrottling - Generated
bevi_gen.goregistering all systems
- Re-run the generator whenever you add/change
//bevi:systemor//bevi:filterlines or when parameter types change. - Pointer-marked queries (
*bevi.QueryN[...]) are treated as WRITE access; non-pointer queries as READ. Drain()/DrainTo()don’t register readers; writers will be finalized byCompleteNoReader(). PreferForEach()for normal consumption.- If you register systems manually, ensure you correctly describe access in
SystemMeta.Accessto unlock safe parallelism. - If multiple packages contain systems, run the generator once; it will emit a
bevi_gen.goper package. CallAddSystemsfor each package’sSystemsfunction. - For reliable timing, use
Everyto gate costly systems rather thantime.Sleepinside the system.
Runtime
type App structNewApp() *App(*App) AddSystem(stage Stage, name string, meta SystemMeta, fn func(context.Context, *bevi.World)) *App(*App) AddSystems(reg func(*App)) *App(*App) SetDiagnostics(d Diagnostics) *App(*App) Run()(*App) World() *bevi.World(*App) Events() *EventBus
Scheduling
type Stage intwith: PreStartup, Startup, PostStartup, PreUpdate, Update, PostUpdatetype AccessMeta struct+ helpers:NewAccess() AccessMetaAccessRead[T],AccessWrite[T],AccessResRead[T],AccessResWrite[T]AccessEventRead[E],AccessEventWrite[E]
type SystemMeta struct { Access AccessMeta; Set string; Before, After []string; Every time.Duration }
Events
type EventBusNewEventBus() *EventBus(*EventBus) Advance()(*EventBus) CompleteNoReader()
WriterFor[T],ReaderFor[T]type EventWriter[T]Emit(T),EmitResult(T) EventResult[T],EmitAndWait(ctx, T) bool,EmitMany([]T)
type EventReader[T]ForEach(func(T) bool),Cancel(),IsCancelled(),Drain() []T,DrainTo([]T) int
type EventResult[T]Valid() bool,Cancelled() bool,Wait(ctx) bool,WaitCancelled(ctx) bool
WithEventBus(ctx, *EventBus) context.Context,EventBusFrom(ctx) *EventBusWriterFromContext[T](ctx) EventWriter[T],ReaderFromContext[T](ctx) EventReader[T]
Diagnostics
type Diagnostics interfaceSystemStart(name string, stage Stage)SystemEnd(name string, stage Stage, err error, duration time.Duration)
NopDiagnostics,NewLogDiagnostics(logger)
MIT — see license.md.