This is an ecs library I wrote for doing game development in Go. I'm actively using it and its pretty stable, but I do find bugs every once in a while. I might vary the APIs in the future if native iterators are added.
Conceptually you can imagine an ECS as one big table, where an Id
column associates an Entity Id with various other component columns. Kind of like this:
Id | Position | Rotation | Size |
---|---|---|---|
0 | {1, 1} | 3.14 | 11 |
1 | {2, 2} | 6.28 | 22 |
We use an archetype-based storage mechanism. Which simply means we have a specific table for a specific component layout. This means that if you add or remove components it can be somewhat expensive, because we have to copy the entire entity to the new table.
Import the library: import "github.com/unitoftime/ecs"
Create Components like you normally would:
type Position struct {
X, Y float64
}
type Rotation float64
Create a World
to store all of your data
world := ecs.NewWorld()
Create an entity and add components to it
id := world.NewId()
ecs.Write(world, id,
ecs.C(Position{1, 1}),
ecs.C(Rotation(3.14)),
// Note: Try to reduce the number of write calls by packing as many components as you can in
)
// Side-Note: I'm trying to get rid of the `ecs.C(...)` boxing, but I couldn't figure out how when
// I first wrote the ECS. I'll try to get back to fixing that because ideally you
// shouldn't have to worry about it. For now though, you have to box your components
// to the `ecs.Component` interface type before passing them in, so `ecs.C(...)`
// does that for you.
Create a View, by calling QueryN
:
query := ecs.Query2[Position, Rotation](world)
Iterate on the query. You basically pass in a lambda, and internally the library calls it for every entity in the world which has all of the components specified. Notably your lambda takes pointer values which represent a pointer to the internally stored component. So modifying these pointers will modify the entity's data.
query.MapId(func(id ecs.Id, pos *Position, rot *Rotation) {
pos.X += 1
pos.Y += 1
rot += 0.01
})
There are several map functions you can use, each with varying numbers of parameters. I support up to Map12
. They all look like this:
ecs.MapN(world, func(id ecs.Id, a *ComponentA, /*... */, n *ComponentN) {
// Do your work
})
You can also filter your queries for more advanced usage:
// Returns a view of Position and Velocity, but only if the entity also has the `Rotation` component.
query := ecs.Query2[Position, Velocity](world, ecs.With(Rotation))
// Returns a view of Position and Velocity, but if velocity is missing on the entity, will just return nil during the `MapId(...)`. You must do nil checks for all components included in the `Optional()`!
query := ecs.Query2[Position, Velocity](world, ecs.Optional(Velocity))
Commands will eventually replace ecs.Write(...)
once I figure out how their usage will work. Commands essentially buffer some work on the ECS so that the work can be executed later on. You can use them in loop safe ways by calling Execute()
after your loop has completed. Right now they work like this:
cmd := ecs.NewCommand(world)
WriteCmd(cmd, id, Position{1,1,1})
WriteCmd(cmd, id, Velocity{1,1,1})
cmd.Execute()
- Improving iterator performance: See: golang/go#54245
- Automatic multithreading
- Without() filter
Hopefully, eventually I can have some automated test-bench that runs and measures performance, but for now you'll just have to refer to my second video and hopefully trust me. Of course, you can run the benchmark in the bench
folder to measure how long frames take on your computer.
- How it works: https://youtu.be/71RSWVyOMEY
- Simulation Performance: https://youtu.be/i2gWDOgg50k