Skip to content
wooshingsPublic

About

simple ecs game engine for odin built on raylib

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

123 Commits

Folders and files

Repository files navigation

shoal

⚠️ This library is still in early development and updating your projects may cause breaking changes.

shoal logo

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"

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

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) {
  // ...
}

system sets

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.

ordering: before() and after()

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

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))

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)
}

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

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

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

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

camera

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_position

the 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()
  }
}

3d camera and models

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)

audio

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

ui

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")
  }
}

About

simple ecs game engine for odin built on raylib

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages