shoal
my silly little ecs engine for odin and raylib :3
contents
install
clone this repo in your project directory
git clone https://git.egfk.net/harper/shoal
and import the package
import ecs "shoal/src"
usage
first create a new app struct. it holds your systems and plugins, and tucked
inside it a World holding every entity and component (your game data).
systems never see the app itself, only the world
import ecs "shoal/src"
app: ecs.App
you can run your app with run(). run takes a pointer to your app and drives its systems.
main :: proc() {
ecs.run(&app)
}
systems
systems are procedures that run on set schedules
a schedule determines when a procedure runs
there are 8 of them, and run() walks them in this order:
STARTUP: once, before the first frameUPDATE: every frame, your main logicDRAW: every frame, inside the frame. this is where your raylib draw calls go
systems registered on the same schedule run in registration order: whoever was add_system()ed first runs first, including the systems your plugins add. plugins themselves always run once before all the STARTUP systems, no matter when you add_plugin() them, so adding add_plugin(&app, ecs.DefaultPlugin) first doesn't change when DefaultPlugin runs. it just puts shoal's defaults at the front of each schedule, which is still a good habit.
main :: proc() {
ecs.add_system(&app, ecs.STARTUP, example_startup)
ecs.add_system(&app, ecs.UPDATE, example_update)
}
example_startup :: proc() {
// system that runs once
// ...
}
example_update :: proc() {
// system that runs every frame
// ...
}
a system can also take the world. declare it as proc(world: ^ecs.World) and run() hands it the app's world, the same one holding every entity and component. that's all a system ever gets: systems structurally can't reach the app itself (add_system, plugins, run); only plugins and main() ever hold an ^App
main :: proc() {
ecs.add_system(&app, ecs.STARTUP, example_spawn)
}
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world) // the world, no global needed
ecs.add_component(world, entity, ExampleComponent{...})
}
plain proc() systems work exactly as before. only ask for the world parameter when you actually need it
components
components are plain structs. declare one and you're done. the data lives in the world: every component type gets a storage inside it, created automatically the first time you add that component to an entity. there's no global to declare and no registration step
ExampleComponent :: struct {}
each world owns its own storages, keyed by the component's type: two worlds never share data, and a zero `ecs.World{}` is ready to use.
### spawn entities
create a new entity with `spawn_entity()`, which hands out the next id from the world's entity list. ids are never reused. systems get the world as a parameter; in `main()` or a plugin it's `&app.world` (or `ecs.get_world(app)`)
```odin
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world)
}
and give it new components with add_component()
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world)
ecs.add_component(world, entity, ExampleComponent) // just the type: a zero-initialized component
}
or fill it in on the spot by passing data instead
example_spawn :: proc(world: ^ecs.World) {
entity := ecs.spawn_entity(world)
ecs.add_component(world, entity, ExampleComponent{...})
}
either way the world's storage for that component type is created on the spot if this is the first one
query
since a world keeps components of one type together, you can loop through them and pick out what you like. slice() and slice_entity() pair up by index: entities[i] owns components[i]
example_system :: proc(world: ^ecs.World) {
components := ecs.slice(world, ExampleComponent)
entities := ecs.slice_entity(world, ExampleComponent)
for v, i in components {
e := entities[i] // get entity that owns the component
}
}
one caveat: slice() hands back the live array inside the world, not a copy, so adding components afterwards can reallocate it out from under you. don't hold one across add_component() calls.
or if you know only one entity has the component use single()
example_system :: proc(world: ^ecs.World) {
component, entity_id := ecs.single(world, ExampleComponent)
}
and once you have an entity id, get_component() reaches into any component type to get that entity's copy, handy for components that live alongside another one (a sprite's TransformComponent, say)
example_system :: proc(world: ^ecs.World) {
component, entity_id := ecs.single(world, ExampleComponent)
other := ecs.get_component(world, entity_id, OtherExampleComponent)
}
no existence check though: only ask for entities you know have that component.
plugins
plugins are a procedure that takes ^App as a parameter, the one bit of privilege over regular systems, which only ever see ^World. a plugin can register systems and plugins on the app the same way main() does, and reach the game data through get_world(app)
ExamplePlugin :: proc(app: ^App) {
ecs.add_system(app, ecs.STARTUP, some_plugin_system)
entity := ecs.spawn_entity(ecs.get_world(app)) // same world the systems will get
ecs.add_component(ecs.get_world(app), entity, ExampleComponent{...})
}
main :: proc() {
ecs.add_plugin(&app, ecs.DefaultPlugin)
ecs.add_plugin(&app, ExamplePlugin)
}
plugins make your scripts reusable. much of shoal's default behavior comes from plugins via DefaultPlugin()
run() fires every plugin in the order you added them, once, before the STARTUP systems, so everything they register is in place before anything executes
signals
signals are structs your systems can talk to each other with. one system writes a signal with data, another system reads it later in the same frame, as long as the reader runs before signal_system clears the buses at POST_UPDATE (see the caveat at the end of this section)
DamageEvent :: struct { amount: int }
write() queues a signal on a per-type bus. read() gives you back every signal of that type written since the last POST_UPDATE (an empty slice if there were none), so you can iterate it. reads don't consume, so any number of systems can read the same signal and its data
attack :: proc() {
ecs.write(DamageEvent{amount = 5})
}
// registered after attack in UPDATE, so it sees the event the same frame
check_damage :: proc() {
for e in ecs.read(DamageEvent) {
fmt.println("took damage!", e.amount)
}
}
you can also write an empty payload with the bare type: ecs.write(DamageEvent)
scheduling caveat: every system sees exactly the signals written since the last signal_system() run, and that runs at POST_UPDATE, before PRE_DRAW/DRAW. so:
- systems running before POST_UPDATE (STARTUP/PRE_UPDATE/UPDATE/early POST_UPDATE) see same-frame writes
- a DRAW system sees none of them, POST_UPDATE already cleared the bus. it only sees signals written after POST_UPDATE by an earlier system in the same frame (say, one DRAW system reacting to another's write)
- writes made after POST_UPDATE survive the rest of the frame and are picked up by the next frame's PRE_UPDATE/UPDATE systems, then cleared at the next POST_UPDATE. the next frame's DRAW never sees them either
timers
timers are named countdowns. they live in a package global for now, deliberately outside the world, since they're keyed by name rather than owned by an entity. start_timer() starts one (or restarts one already stored under that name) and get_timer() reads it back. anything that never started or already finished reads back zeroed, so a nonzero lifetime means "still ticking"
example_system :: proc() {
if ecs.get_timer("cooldown").lifetime != 0 {return} // still cooling down
ecs.start_timer(0.5, "cooldown")
}
timers tick down every frame in run_timers, which DefaultPlugin registers at UPDATE. the reset fires when the remaining lifetime drops to roughly one frame's delta (it compares against a fresh GetFrameTime() call, so it can reset a frame or two early) and resets the entry to Timer{}. that's the auto-clear, and why get_timer() goes back to zero when you've waited it out.
on expiry a TimerFired{name = ...} signal is meant to be queued for read(TimerFired). wip: as written the reset zeroes the entry before the name is read, so TimerFired currently never fires (reading the timers themselves works fine). once that's fixed the usual signal caveat applies: a reader has to run before signal_system clears the buses at POST_UPDATE, see the end of the signals section
ui
shoal/ui wraps microui with its own plugin. add it before any system that wants to draw ui
import ui "shoal/ui"
import mu "vendor:microui"
main :: proc() {
ecs.add_plugin(&app, ui.UIPlugin)
ecs.add_system(&app, ecs.DRAW, my_ui)
}
ui.UIPlugin registers init() at STARTUP (uploads microui's atlas texture), begin() at PRE_DRAW (forwards raylib input and starts the frame), frees the frame's temp memory and runs end() at POST_DRAW (renders the ui), and destroy() at DESTROY.
your system builds the ui against the context get_ctx() hands back during the frame
my_ui :: proc() {
if mu.begin_window(ui.get_ctx(), "hello", {100, 100, 100, 100}) {
defer mu.end_window(ui.get_ctx())
mu.label(ui.get_ctx(), "Hello, world")
}
}
tween
wip: tweens don't do anything yet. tween.odin ships Tween, start_tween(), create_tween() (a stub that returns an empty Tween and ignores its arguments), update_tween_system() (empty, and no schedule registers it) and a few sine easing helpers nothing calls. don't build on it. the EASE_IN/EASE_OUT/EASE_IN_OUT constants are ids for the unused Tween.ease field.