2026-10-27 19:18:43 -07:00
2026-10-09 13:47:22 -07:00
2026-10-18 11:00:01 -07:00
2026-10-27 19:18:43 -07:00
2026-10-22 18:04:03 -07:00
2026-10-09 21:26:00 -07:00
2026-10-27 19:18:43 -07:00

shoal

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. this is where your systems and plugins live.

import ecs "shoal/src"

app: ecs.App

you can run your app with run(). run takes a reference to your app to run it's systems.

main :: proc() {
  ecs.run(&app)
}

systems

systems are procedures that run on set schedules

a schedule just determines when a producure runs
schedules are:

  • STARTUP
  • UPDATE
  • DRAW

STARTUP runs once at the start of an app
UPDATE runs every frame
DRAW runs every frame after update, and makes draw calls to raylib\

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

components

components are just structs with data, and kept with other components of that type in Storage.
create a new component

ExampleComponent :: struct {}

and create it's storage

Example: ecs.Storage(ExampleComponent)

spawn entities

create a new entity with spawn_entity()

main :: proc() {
  entity := ecs.spawn_entity(&app)
}

and give it new components with add_component()

main :: proc() {
  ecs.add_component(entity, &Example) // make sure to pass a reference to the storage, not the component type
}

query

since components are saved in storage next to their entity, you can loop through a Storage and pick out what you like

main :: proc() {
  components := ecs.slice(&Example)
  entities := ecs.slice_entity(&Example)
  for v, i in components {
    e := entities[i]    // get entity that owns the component
  }
}

or if you know your storage only has one entry use single()

main :: proc() {
  component, entity_id := ecs.single(&Example)
}

plugins

plugins are just a procedure that takes App as a parameter, meaning it can make changes to app, just like main()

ExamplePlugin :: proc(app: ^App) {
  ecs.add_system(app, ecs.STARTUP, some_plugin_system)
}
main :: proc() {
  ecs.add_plugin(&app, ecs.DefaultPlugin)
  ecs.add_plugin(&app, ExamplePlugin)
}

plugins make your scripts very reuseable. in fact, much of shoal's default behaviors come from plugins via DefaultPlugin()

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 — so the next frame's DRAW never sees them either
S
Description
simple ecs game engine for odin built on raylib
Readme
193 KiB
Languages
Odin 100%