my silly little ecs engine for odin and raylib :3
clone this repo in your project directory
git clone https://git.egfk.net/harper/shoaland import the package
import ecs "shoal/src"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"
main :: proc() {
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 are procedures that run on schedules. there are four root schedules,
and run() walks them in this order:
"startup": once, before the first frame (plugins run before this)"update": every frame, your main logic (its walk ends with the"draw": every frame, inside the raylib frame. this is where your draw calls go"destroy": once, after the last frame, on shutdown
systems registered on the same schedule run in registration order: whoever was
add_system()ed first runs first, including the systems your plugins add.
add_system takes three arguments: app, the schedule name, and the system
proc:
main :: proc() {
ecs.add_system(&app, "startup", setup_window)
ecs.add_system(&app, "update", update_balls)
ecs.add_system(&app, "draw", draw_balls)
ecs.run(&app)
}a system's parameters can either be empty, or get a reference to app's World. procedures that modify or read from the world will require ^World.
setup_window :: proc() {
// ...
}
update_balls :: proc(world: ^ecs.World) {
// ...
}
a system set (child schedule) is a named group of systems within a root
schedule. you declare it with init_system_set, telling the engine which root
it lives under:
main :: proc() {
ecs.init_system_set(&app, "startup", "window")
ecs.init_system_set(&app, "startup", "spawn")
ecs.init_system_set(&app, "update", "physics")
ecs.init_system_set(&app, "draw", "render")
ecs.init_system_set(&app, "draw", "hud")
ecs.add_system(&app, "window", setup_window)
ecs.add_system(&app, "spawn", spawn_balls)
ecs.add_system(&app, "physics", update_balls)
ecs.add_system(&app, "render", draw_balls)
ecs.add_system(&app, "hud", show_framerate)
ecs.run(&app)
}add_system resolves the schedule: a child name goes to the root that
declared it, a root name goes to its "default" set, anything else panics.
a set can't shadow a root name, and cross-root duplicate names panic too.
init_system_set optionally takes before("other_set") or after("other_set")
to control where a set is declared:
before("foo")inserts the set before "foo" (or at the front if "foo" doesn't exist yet)after("bar")inserts the set after "bar" (or at the end if "bar" doesn't exist yet)- no option, append after the sets declared so far
DefaultPlugin :: proc(app: ^App) {
// "pre_draw" to the front, engine "default" after it, "post_draw" last
ecs.init_system_set(app, "draw", "pre_draw", before("default"))
ecs.init_system_set(app, "draw", "default", after("pre_draw"))
ecs.add_system(app, "pre_draw", begin_frame)
ecs.add_system(app, "draw", draw_sprite) // the root's built-in "default" set
ecs.init_system_set(app, "draw", "post_draw")
ecs.add_system(app, "post_draw", end_frame)
}plugins run when ecs.run() starts, after main() has declared its sets,
so a plugin's own sets are anchored by name (before/after) rather than
registration order. that's what keeps shoal's BeginDrawing/EndDrawing
wrapped around your draw systems no matter when you declared things.
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.
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))
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
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)
}single() panics with a message naming the component (single: no entity has component ExampleComponent) if nobody in the world has it yet
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)
}get_component() panics with a message naming the entity and the component
(get_component: entity 3 has no component OtherExampleComponent) if that
entity never got that component. it used to hand back whatever was at index 0
of the storage instead, which crashed the app with a bounds error as soon as
nothing was there, the panic is the fixed version of that
if you're not sure whether the entity has it (probing a component another
system or plugin owns, say), ask first. has_component() answers yes/no, and
try_get_component() hands back the component and a flag, (nil, false)
instead of a panic when it's missing
example_system :: proc(world: ^ecs.World) {
if ecs.has_component(world, entity_id, OtherExampleComponent) {
// ...
}
if other, ok := ecs.try_get_component(world, entity_id, OtherExampleComponent); ok {
// use other
}
}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, schedules, and system sets on the app the same way main()
does, and reach the game data through get_world(app)
ExamplePlugin :: proc(app: ^App) {
ecs.init_system_set(app, "update", "physics", before("default"))
ecs.add_system(app, "physics", physics_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" schedule, so everything they register is in place before anything
executes
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 (registered by
DefaultPlugin in the "post_update" set at the end of "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 clear (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 in the "post_update" set at the end of
"update", before "draw". so:
- systems running before
"post_update"in the"update"walk (the default set and any sets main() declared before the plugin ran) see same-frame writes - a
"draw"system sees none of them, signal_system already cleared the bus. it only sees signals written after signal_system by an earlier system in the same frame (say, one"draw"system reacting to another's write) - writes made after signal_system survive the rest of the frame and are picked
up by the next frame's
"update"systems, then cleared at the next frame's"post_update". the next frame's"draw"never sees them either
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
CameraPlugin renders the game through a camera. add it after DefaultPlugin, since
it anchors its sets around the draw pipeline the same way DefaultPlugin anchors
its own:
main :: proc() {
ecs.add_plugin(&app, ecs.DefaultPlugin)
ecs.add_plugin(&app, ecs.CameraPlugin)
ecs.add_system(&app, "draw", my_game) // renders through the camera
}a camera is an entity: add a Camera component to mark it, and the same entity's
Transform becomes the look-at — translation is the world point that appears at
the viewport center, rotation is the camera roll around it. move the camera by
writing the transform, exactly like moving any other entity. Camera.zoom scales
world space (2.0 = everything twice as big; zero means the default 1.0, same
zero-means-default rule as a sprite's tint). a Camera entity without a
Transform component is a fail-fast panic, like any sprite entity missing one
cam := ecs.spawn_entity(world)
ecs.add_component(world, cam, ecs.Transform{translation = {400, 300}})
ecs.add_component(world, cam, ecs.Camera{})
// later, any system:
ecs.get_component(world, cam, ecs.Transform).translation = player_positionthe plugin wraps draw's default set in a 2d camera context, so the pipeline is:
"pre_draw"->"camera_begin"(BeginMode2D, skipped with no camera) ->"default"(draw_sprite and anything registered on the"draw"root) ->"camera_end"(EndMode2D) -> your declared sets ->"post_draw"
anything registered on the "draw" root renders world space; sets you declared
with before("post_draw") sit after "camera_end" and render in plain screen
space — free HUD/overlay separation. multiple Camera components are allowed but
the first-registered one acts. with no Camera component at all rendering is
exactly as before, so the plugin is opt-in per world
ecs.cursor_world() returns the mouse position in camera world space (the raw
cursor position when no camera is active), for click-to-move and picking:
move_to :: proc(world: ^ecs.World) {
if rl.IsMouseButtonPressed(rl.MouseButton.LEFT) {
target := ecs.cursor_world()
}
}Camera3DPlugin wraps draw's default set in a 3d camera context, the same
shape as CameraPlugin but for 3d. ModelPlugin draws every Model
component in world space inside that context. Default3DPlugin bundles
DefaultPlugin + Camera3DPlugin + ModelPlugin for a 3d game:
main :: proc() {
ecs.add_plugin(&app, ecs.Default3DPlugin)
ecs.add_system(&app, "draw", my_game) // renders through the 3d camera
}a 3d camera is an entity: add a Camera3D component to mark it, and the
same entity's Transform3D becomes the position. the camera looks at
Camera3D.target (zero means one unit forward along +Z), with up
(zero means {0, 1, 0}), fovy (zero means 45 degrees), and a
projection (perspective by default, or orthographic). a Camera3D
entity without a Transform3D component is a fail-fast panic, like any
sprite entity missing its Transform:
cam := ecs.spawn_entity(world)
ecs.add_component(world, cam, ecs.Transform3D{translation = {0, 5, 10}})
ecs.add_component(world, cam, ecs.Camera3D{target = {0, 0, 0}})move the camera by writing its transform, exactly like moving any other
entity. the first-registered Camera3D acts; with none at all 3d camera
mode is skipped entirely, so the plugin is opt-in per world. CameraPlugin
(2d) and Camera3DPlugin both wrap the same default set, so use one or the
other, not both
models are loaded from disk with load_model (cached by path, like
sprites) or generated in memory with gen_model_cube / gen_model_sphere
/ gen_model_plane for prototyping. placement comes from the entity's
Transform3D:
cube := ecs.spawn_entity(world)
ecs.add_component(world, cube, ecs.Transform3D{translation = {0, 0, 0}, scale = {1, 1, 1}})
ecs.add_component(world, cube, ecs.gen_model_cube(1, 1, 1))
model := ecs.spawn_entity(world)
ecs.add_component(world, model, ecs.Transform3D{translation = {2, 0, 0}})
ecs.add_component(world, model, ecs.load_model("models/duck.glb"))Transform3D.rotation is euler angles in radians (pitch, yaw, roll), and
scale defaults to {1, 1, 1}. ecs.cursor_ray() returns the mouse as a
picking ray through the active 3d camera (a zero ray when none is active).
lighting uses raylib's default shader: models render with ambient light by
default, and full directional/point lights are left to user plugins for now
(raylib has no built-in light helper; see raylib's rlights example)
shoal's DefaultPlugin wires in an audio plugin automatically: the device
opens at "startup", playing streams are kept fed at "update" (a stream
nobody updates goes silent), and everything unloads at "destroy". every
sound is a music stream under the hood, raylib decodes incrementally from
the compressed file, so nothing sits fully decoded in RAM. assets are cached
by path: the path is the handle, first call loads from disk and every call
after replays the loaded asset
main :: proc() {
// audio comes with DefaultPlugin, no extra plugin to add
ecs.play_sound("sounds/theme.ogg", true) // background music, loops
ecs.play_sound("sounds/jump.wav") // one-shot effect
ecs.set_volume("sounds/jump.wav", 0.5)
ecs.master_volume(0.8)
}play_sound restarts from the top on repeat calls; loop makes it wrap at
the end instead of stopping. stop, pause, resume, and set_volume
control a stream by path. loading a path that doesn't exist panics with the
path, same fail-fast rule as registering on an undeclared schedule. paths
are relative to where you run the binary from
shoal/plugins/ui wraps microui with its own plugin. add it before any system that
wants to draw ui
import ui "shoal/plugins/ui"
import mu "vendor:microui"
main :: proc() {
ecs.add_plugin(&app, ui.UIPlugin)
ecs.add_system(&app, "draw", my_ui) // draw's built-in "default" set
}ui.UIPlugin registers init() at "draw" (uploads microui's atlas texture),
begin() at "draw" (forwards raylib input and starts the frame), end() at
"draw" (renders the ui after all game drawing), frees the frame's temp
memory at "draw", 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")
}
}