├── AddMany.go ├── GetStorageMany.go ├── LICENSE ├── README.md ├── RegisterMany.go ├── doc.go ├── ecs.go ├── ecs_concurrent_test.go ├── ecs_test.go ├── examples ├── MovementSystem │ ├── go.mod │ ├── go.sum │ └── main.go ├── ebiten │ └── DodgeRocks │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go └── raylib │ ├── BreakOut │ ├── go.mod │ └── main.go │ └── DodgeRocks │ ├── go.mod │ ├── go.sum │ └── main.go ├── generations.go ├── go.mod ├── go.sum └── internal ├── bitset.go └── bitset_test.go /AddMany.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | // add 2 components to an entity 4 | // automatically register component if ecs.AutoRegisterComponents 5 | // is true (default) 6 | // This is just a wrapper arround calling ecs.Add multiple times 7 | func Add2[A any, B any](p *Pool, e Entity, 8 | c1 A, c2 B, 9 | ) { 10 | Add(p, e, c1) 11 | Add(p, e, c2) 12 | } 13 | 14 | // add 3 components to an entity 15 | // automatically register component if ecs.AutoRegisterComponents 16 | // is true (default) 17 | // This is just a wrapper arround calling ecs.Add multiple times 18 | func Add3[A any, B any, C any](p *Pool, e Entity, 19 | c1 A, c2 B, c3 C, 20 | ) { 21 | Add2(p, e, c1, c2) 22 | Add(p, e, c3) 23 | } 24 | 25 | // add 4 components to an entity 26 | // automatically register component if ecs.AutoRegisterComponents 27 | // is true (default) 28 | // This is just a wrapper arround calling ecs.Add multiple times 29 | func Add4[A any, B any, C any, D any](p *Pool, e Entity, 30 | c1 A, c2 B, c3 C, c4 D, 31 | ) { 32 | Add3(p, e, c1, c2, c3) 33 | Add(p, e, c4) 34 | } 35 | 36 | // add 5 components to an entity 37 | // automatically register component if ecs.AutoRegisterComponents 38 | // is true (default) 39 | // This is just a wrapper arround calling ecs.Add multiple times 40 | func Add5[A any, B any, C any, D any, E any](p *Pool, e Entity, 41 | c1 A, c2 B, c3 C, c4 D, c5 E, 42 | ) { 43 | Add4(p, e, c1, c2, c3, c4) 44 | Add(p, e, c5) 45 | } 46 | 47 | // add 6 components to an entity 48 | // automatically register component if ecs.AutoRegisterComponents 49 | // is true (default) 50 | // This is just a wrapper arround calling ecs.Add multiple times 51 | func Add6[A any, B any, C any, D any, E any, F any](p *Pool, e Entity, 52 | c1 A, c2 B, c3 C, c4 D, c5 E, c6 F, 53 | ) { 54 | Add5(p, e, c1, c2, c3, c4, c5) 55 | Add(p, e, c6) 56 | } 57 | 58 | // add 7 components to an entity 59 | // automatically register component if ecs.AutoRegisterComponents 60 | // is true (default) 61 | // This is just a wrapper arround calling ecs.Add multiple times 62 | func Add7[A any, B any, C any, D any, E any, F any, G any](p *Pool, e Entity, 63 | c1 A, c2 B, c3 C, c4 D, c5 E, c6 F, c7 G, 64 | ) { 65 | Add6(p, e, c1, c2, c3, c4, c5, c6) 66 | Add(p, e, c7) 67 | } 68 | 69 | // add 8 components to an entity 70 | // automatically register component if ecs.AutoRegisterComponents 71 | // is true (default) 72 | // This is just a wrapper arround calling ecs.Add multiple times 73 | func Add8[A any, B any, C any, D any, E any, F any, G any, H any](p *Pool, e Entity, 74 | c1 A, c2 B, c3 C, c4 D, c5 E, c6 F, c7 G, c8 H, 75 | ) { 76 | Add7(p, e, c1, c2, c3, c4, c5, c6, c7) 77 | Add(p, e, c8) 78 | } 79 | 80 | // add 9 components to an entity 81 | // automatically register component if ecs.AutoRegisterComponents 82 | // is true (default) 83 | // This is just a wrapper arround calling ecs.Add multiple times 84 | func Add9[A any, B any, C any, D any, E any, F any, G any, H any, I any](p *Pool, e Entity, 85 | c1 A, c2 B, c3 C, c4 D, c5 E, c6 F, c7 G, c8 H, c9 I, 86 | ) { 87 | Add8(p, e, c1, c2, c3, c4, c5, c6, c7, c8) 88 | Add(p, e, c9) 89 | } 90 | -------------------------------------------------------------------------------- /GetStorageMany.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | // storage contains all components of a type 4 | // This is just a wrapper arround calling ecs.GetStorage multiple times 5 | func GetStorage2[A any, B any](p *Pool) (*Storage[A], *Storage[B]) { 6 | return GetStorage[A](p), 7 | GetStorage[B](p) 8 | } 9 | 10 | // storage contains all components of a type 11 | // This is just a wrapper arround calling ecs.GetStorage multiple times 12 | func GetStorage3[A any, B any, C any](p *Pool) ( 13 | *Storage[A], *Storage[B], 14 | *Storage[C], 15 | ) { 16 | a, b := GetStorage2[A, B](p) 17 | c := GetStorage[C](p) 18 | return a, b, c 19 | } 20 | 21 | // storage contains all components of a type 22 | // This is just a wrapper arround calling ecs.GetStorage multiple times 23 | func GetStorage4[A any, B any, 24 | C any, D any](p *Pool) ( 25 | *Storage[A], *Storage[B], 26 | *Storage[C], *Storage[D], 27 | ) { 28 | a, b, c := GetStorage3[A, B, C](p) 29 | d := GetStorage[D](p) 30 | return a, b, c, d 31 | } 32 | 33 | // storage contains all components of a type 34 | // This is just a wrapper arround calling ecs.GetStorage multiple times 35 | func GetStorage5[A any, B any, C any, 36 | D any, E any](p *Pool) ( 37 | *Storage[A], *Storage[B], 38 | *Storage[C], *Storage[D], 39 | *Storage[E], 40 | ) { 41 | a, b, c, d := GetStorage4[A, B, C, D](p) 42 | e := GetStorage[E](p) 43 | return a, b, c, d, e 44 | } 45 | 46 | // storage contains all components of a type 47 | // This is just a wrapper arround calling ecs.GetStorage multiple times 48 | func GetStorage6[A any, B any, C any, 49 | D any, E any, F any](p *Pool) ( 50 | *Storage[A], *Storage[B], 51 | *Storage[C], *Storage[D], 52 | *Storage[E], *Storage[F]) { 53 | a, b, c, d, e := GetStorage5[A, B, C, D, E](p) 54 | f := GetStorage[F](p) 55 | return a, b, c, d, e, f 56 | } 57 | 58 | // storage contains all components of a type 59 | // This is just a wrapper arround calling ecs.GetStorage multiple times 60 | func GetStorage7[A any, B any, C any, 61 | D any, E any, F any, G any](p *Pool) ( 62 | *Storage[A], *Storage[B], 63 | *Storage[C], *Storage[D], 64 | *Storage[E], *Storage[F], 65 | *Storage[G], 66 | ) { 67 | a, b, c, d, e, f := GetStorage6[A, B, C, D, E, F](p) 68 | g := GetStorage[G](p) 69 | return a, b, c, d, e, f, g 70 | } 71 | 72 | // storage contains all components of a type 73 | // This is just a wrapper arround calling ecs.GetStorage multiple times 74 | func GetStorage8[A any, B any, C any, 75 | D any, E any, F any, G any, H any](p *Pool) ( 76 | *Storage[A], *Storage[B], 77 | *Storage[C], *Storage[D], 78 | *Storage[E], *Storage[F], 79 | *Storage[G], *Storage[H], 80 | ) { 81 | a, b, c, d, e, f, g := GetStorage7[A, B, C, D, E, F, G](p) 82 | h := GetStorage[H](p) 83 | return a, b, c, d, e, f, g, h 84 | } 85 | 86 | // storage contains all components of a type 87 | // This is just a wrapper arround calling ecs.GetStorage multiple times 88 | func GetStorage9[A any, B any, C any, 89 | D any, E any, F any, G any, H any, I any](p *Pool) ( 90 | *Storage[A], *Storage[B], 91 | *Storage[C], *Storage[D], 92 | *Storage[E], *Storage[F], 93 | *Storage[G], *Storage[H], 94 | *Storage[I], 95 | ) { 96 | a, b, c, d, e, f, g, h := GetStorage8[A, B, C, D, E, F, G, H](p) 97 | i := GetStorage[I](p) 98 | return a, b, c, d, e, f, g, h, i 99 | } 100 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Omer Farooqui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple-ECS 2 | dead-simple library for writing 3 | game systems in Go 4 | 5 | ### Install 6 | ```go get github.com/BrownNPC/simple-ecs``` 7 | 8 | #### Documentation 9 | [GoDoc can be found here](https://pkg.go.dev/github.com/BrownNPC/simple-ecs#pkg-variables) 10 | 11 | [Jump to Example](https://github.com/BrownNPC/simple-ecs?tab=readme-ov-file#now-here-is-an-example) 12 | 13 | ### Simple-ECS Features: 14 | - Easy syntax / api 15 | - Good perfomance! 16 | - Low level (implement what you need) 17 | - No Dependencies on other libraries 18 | - Safe Concurrency (use goroutines without issues) 19 | 20 | 21 | ### What is ECS? (and why you should use it) 22 | I recommend you watch [this](https://www.youtube.com/watch?v=JxI3Eu5DPwE) 23 | video. feel free to skip around 24 | or watch at 2x speed 25 | 26 | #### ECS is an alternative to inheritance. 27 | 28 | 29 | Instead of creating game objects using Object Oriented Design where 30 | things inherit from each other eg. 31 | Player inherits from Actor, Actor inherits From Entity, 32 | we 33 | think about the **Data**. The goal is to 34 | seperate the logic from the data. 35 | This is known as 36 | Data oriented design. 37 | 38 | In this pattern, we have entities 39 | which have components. 40 | **Components are pure-data** 41 | for example a position component 42 | might look like this: 43 | ```go 44 | type Position struct{ 45 | X,Y float64 46 | } 47 | ``` 48 | A health component might 49 | just be an integer. 50 | 51 | Using components you can make systems. 52 | Systems are just normal functions that 53 | modify components. 54 | 55 | For example, you may have a movement system 56 | that loops over all the entities that 57 | have a Position and a Velocity component, 58 | and then 59 | adds the Velocity to the Position of the entity 60 | 61 | ```go 62 | func MovementSystem(entities []entity){ 63 | for _ ent := range entities{ 64 | ent.Position.X += ent.Velocity.X 65 | ent.Position.Y += ent.Velocity.Y 66 | } 67 | } 68 | ``` 69 | 70 | ### Why use ECS for writing game systems? 71 | Because Go does not have inheritance. 72 | The language prefers seperating data from 73 | logic. 74 | #### How to use Simple ECS for writing systems 75 | Before we jump into the example, understanding how 76 | this library is implemented will help us learn it easily. 77 | 78 | The heart of this ECS is the memory pool 79 | Think of the pool like a database or a spreadsheet. 80 | On the Y axis (columns) there are arrays of components 81 | 82 | We use a struct called storage to hold the components arrays. 83 | 84 | components can be any data type 85 | 86 | These arrays are pre-allocated to a fixed size provided by the user 87 | 88 | An entity is just an index into these arrays 89 | 90 | So on the X axis there are entities which are just indexes 91 | ```go 92 | // stores slice of components 93 | type Storage[Component any] struct { 94 | // slice of components 95 | components []Component 96 | // a bitset is used to store which 97 | //indexes are occupied by entities 98 | b bitset.BitSet 99 | } 100 | ``` 101 | The storage struct also has a bitset (like an array of boleans) 102 | 103 | Each bit in the bitset corresponds to an entity. 104 | By setting the bit on the bitset, we can keep 105 | a record of whether an entity has the component added to it. 106 | 107 | The pool also has its own bitset that tracks which entities are alive 108 | you dont need to worry about how the pool works, just know that the 109 | pool is responsible for creating and deleting entities. 110 | 111 | ## Now here is an example: 112 | ```go 113 | package main 114 | 115 | import ( 116 | ecs "github.com/BrownNPC/simple-ecs" 117 | "math/rand" 118 | ) 119 | 120 | // Define component types 121 | type Vec2 struct { 122 | X, Y float64 123 | } 124 | 125 | // components need to be concrete types 126 | type Position Vec2 127 | type Velocity Vec2 128 | 129 | func main() { 130 | // create a memory pool of component arrays 131 | // the pool can hold a maximum of 1000 alive entities 132 | var pool = ecs.New(1000) 133 | // create 1000 entities 134 | for range 1000 { 135 | // entities (which are just ids) 136 | // should only be created using the pool 137 | var ent = ecs.NewEntity(pool) 138 | // add position and 139 | // velocity components to the entity 140 | ecs.Add2(pool, ent, 141 | Position{}, 142 | Velocity{ 143 | X: rand.Float64(), 144 | Y: rand.Float64(), 145 | }) 146 | } 147 | // run movement system 60 times 148 | for range 60 { 149 | MovementSystem(pool, 1.0/60) 150 | } 151 | } 152 | 153 | // a system is a regular function that 154 | // operates on the components 155 | func MovementSystem(p *ecs.Pool, deltaTime float64) { 156 | // a storage holds a slice (array) of components 157 | POSITION, VELOCITY := 158 | ecs.GetStorage2[ // helper function so you dont have to call GetStorage twice 159 | Position, 160 | Velocity, 161 | ](p) 162 | // get entities (id/index) that have 163 | // a position and velocity component 164 | for _, ent := range POSITION.And(VELOCITY) { 165 | // use the entity to index the 166 | // position and velocity slices 167 | pos, vel := 168 | POSITION.Get(ent), 169 | VELOCITY.Get(ent) 170 | pos.X += vel.X * deltaTime 171 | pos.Y += vel.Y * deltaTime 172 | // update position of entity 173 | POSITION.Update(ent, pos) 174 | } 175 | } 176 | ``` 177 | 178 | ### When to not use an ECS 179 | You dont need ECS if your game is going to be very simple 180 | like pong or flappy bird. But if you are making eg. "flappy bird with guns" 181 | then ECS makes sense. 182 | But even if you are using ECS; 183 | 184 | **Everything in your game does not *need* to be an entity.** 185 | For example. If you are making "Chess with magic spells", 186 | you might want to represent the board state using a Chess board struct (object) 187 | and the pieces would probably be entities that have components. and you would 188 | probably have systems for animations, the timer, magic spells, and maybe 189 | checking if a piece can move to a square etc. 190 | 191 | Your user interface (UI) would probably also not benefit from being entities. 192 | 193 | ### Motivation: 194 | The other ECS libraries seem 195 | to focus on having the best 196 | possible performance, 197 | sometimes sacrificing a 198 | simpler syntax. They also provide features 199 | I dont need. 200 | And these libraries had 201 | many ways to do 202 | the same thing. (eg. Arche has 2 apis) 203 | 204 | I made this library to have less features, 205 | and sacrifice a little performance 206 | for more simplicity. 207 | Note: if you care about every nanosecond of performance, dont use my library. 208 | 209 | ### Acknowledgements 210 | Donburi is another library that 211 | implements ECS with a simple API. 212 | 213 | 214 | ## Running tests 215 | `go test -count 10 -race ./...` 216 | -------------------------------------------------------------------------------- /RegisterMany.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | // allocate a new component storage 4 | // 5 | // if you wish to register interfaces, 6 | // wrap them in a struct 7 | func Register2[A any, B any](p *Pool) { 8 | Register[A](p) 9 | Register[B](p) 10 | } 11 | 12 | // allocate a new component storage 13 | // 14 | // if you wish to register interfaces, 15 | // wrap them in a struct 16 | func Register3[A any, B any, C any](p *Pool) { 17 | Register2[A, B](p) 18 | Register[C](p) 19 | } 20 | 21 | // allocate a new component storage 22 | // 23 | // if you wish to register interfaces, 24 | // wrap them in a struct 25 | func Register4[A any, B any, C any, D any](p *Pool) { 26 | Register3[A, B, C](p) 27 | Register[D](p) 28 | } 29 | 30 | // allocate a new component storage 31 | // 32 | // if you wish to register interfaces, 33 | // wrap them in a struct 34 | func Register5[A any, B any, C any, D any, E any](p *Pool) { 35 | Register4[A, B, C, D](p) 36 | Register[E](p) 37 | } 38 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | entity component system that is performant an easy to use. 3 | 4 | The heart of this ECS is the memory pool 5 | Think of the pool like a database. 6 | On the Y axis (columns) there are arrays of components 7 | 8 | We use a struct called storage to hold the components arrays 9 | components can be any data type, but they cannot be interfaces 10 | These arrays are pre-allocated to a fixed size provided by the user 11 | 12 | an entity is just an index into these arrays 13 | So on the X axis there are entities which are just indexes 14 | 15 | The storage struct also has a bitset. 16 | 17 | each bit in the bitset corresponds to an entity 18 | the bitset is used for maintaining 19 | a record of which entity has the component the storage is storing 20 | 21 | The pool also has its own bitset that tracks which entities are alive 22 | 23 | there is also a map from entities to a slice of component storages 24 | 25 | we update this map when an entity has a component added to it 26 | 27 | we use this map to go into every storage and zero out the component 28 | when an entity is killed 29 | */ 30 | package ecs 31 | -------------------------------------------------------------------------------- /ecs.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | // simple-ecs copyright Omer Farooqui all rights reserved.. 4 | // this code is MIT licensed. 5 | import ( 6 | "fmt" 7 | "slices" 8 | "sync" 9 | 10 | bitset "github.com/BrownNPC/simple-ecs/internal" 11 | ) 12 | 13 | // when this is false, the library will panic if components 14 | // are not registered before use. 15 | var AutoRegisterComponents = true 16 | 17 | // Entity is an id that has components, 18 | // they can only be created using ecs.NewEntity() 19 | type Entity uint32 20 | 21 | // Entity 0 is never used 22 | const UnusedEntity = Entity(0) 23 | 24 | // stores slice of components 25 | /* 26 | We use a struct called storage to hold the components array. 27 | this struct also has a bitset where 28 | each bit in the bitset corresponds to an entity. 29 | the bitset is used for maintaining 30 | a record of which entity has the component the storage is storing 31 | */ 32 | type Storage[Component any] struct { 33 | // slice of components 34 | components []Component 35 | // a bitset is used to store which 36 | //indexes are occupied by entities 37 | b bitset.BitSet 38 | mut sync.Mutex 39 | registeredNum int // used for sorting, set when the storage is registered by the pool 40 | } 41 | type _Storage interface { 42 | delete(e Entity) 43 | getBitset() *bitset.BitSet 44 | lock() 45 | unlock() 46 | getNum() int 47 | } 48 | 49 | func (s *Storage[Component]) getNum() int { 50 | return s.registeredNum 51 | } 52 | 53 | func (s *Storage[Component]) delete(e Entity) { 54 | s.mut.Lock() 55 | defer s.mut.Unlock() 56 | var zero Component 57 | s.components[e] = zero 58 | } 59 | 60 | // takes in other storages and returns 61 | // entities that exist in all of them 62 | // 63 | // in simple terms: 64 | // entities that have all of these components 65 | // 66 | // passing in nil or nothing will return entities that have this storage's component 67 | func (s *Storage[Component]) And(storages ..._Storage) []Entity { 68 | unlock := s.orderedLock(storages...) 69 | defer unlock() 70 | mask := s.b.Clone() 71 | if len(storages) > 0 { 72 | for _, otherSt := range storages { 73 | if otherSt != nil { 74 | mask.And(otherSt.getBitset()) 75 | } 76 | } 77 | } 78 | return bitset.ActiveIndices[Entity](&mask) 79 | } 80 | 81 | // takes in other storages and returns 82 | // entities that exist in this storage but 83 | // not in the storages passed in 84 | // 85 | // in simple terms: 86 | // entities that have this component 87 | // but not the other ones 88 | // 89 | // passing in nil or nothing will return the entities with the component this storage stores 90 | func (s *Storage[Component]) ButNot(storages ..._Storage) []Entity { 91 | unlock := s.orderedLock(storages...) 92 | defer unlock() 93 | mask := s.b.Clone() 94 | for _, s := range storages { 95 | if s != nil { 96 | mask.AndNot(s.getBitset()) 97 | } 98 | } 99 | return bitset.ActiveIndices[Entity](&mask) 100 | } 101 | 102 | // set an entity's component. 103 | // 104 | // NOTE: will do nothing if entity isnt alive or doesnt have this component 105 | func (st *Storage[Component]) Update(e Entity, component Component) { 106 | st.mut.Lock() 107 | defer st.mut.Unlock() 108 | if !st.b.IsSet(uint(e)) { 109 | return 110 | } 111 | if len(st.components) <= int(e) { 112 | st.components[e] = component 113 | } 114 | } 115 | 116 | // check if an Entity has a component 117 | func (st *Storage[Component]) EntityHasComponent(e Entity) bool { 118 | //by looking at the bitset of storage 119 | st.mut.Lock() 120 | defer st.mut.Unlock() 121 | return st.b.IsSet(uint(e)) 122 | } 123 | 124 | // get a copy of an entity's component 125 | // You can then update the entity using 126 | // Storage[T].Update() 127 | // if the entity is dead, or does not have this component 128 | // then the returned value will be the zero value of the component 129 | func (s *Storage[Component]) Get(e Entity) (component Component) { 130 | s.mut.Lock() 131 | defer s.mut.Unlock() 132 | if !s.b.IsSet(uint(e)) { 133 | return component 134 | } 135 | return s.components[e] 136 | } 137 | 138 | func newStorage[T any](size int, num int) *Storage[T] { 139 | return &Storage[T]{ 140 | components: make([]T, size), 141 | registeredNum: num, 142 | } 143 | } 144 | 145 | // A pool holds component storages and does book keeping of 146 | // alive and dead entities 147 | /* 148 | Think of the pool like a database. 149 | On the Y axis (columns) there are arrays of components 150 | components can be any data type 151 | These arrays are pre-allocated to a fixed size provided by the user 152 | 153 | an entity is just an index into these arrays 154 | So on the X axis there are entities which are just indexes 155 | 156 | */ 157 | type Pool struct { 158 | // we map pointer to type T to the storage of T 159 | // *T -> Storage[T] 160 | stores map[any]_Storage 161 | // used to track components an entity has 162 | // we zero out the components when an entity dies 163 | // and update this map when a component is added to an entity 164 | // this is only used for internal book keeping of 165 | // dead and alive entities 166 | componentsUsed map[Entity][]_Storage 167 | // which entities are alive 168 | aliveEntities bitset.BitSet 169 | // recycle killed entities 170 | freeList []Entity 171 | // no. of entities to pre-allocate / max entity count 172 | maxEntities int 173 | //how many entities are alive 174 | aliveCount int 175 | // generations track how many times an entity was recycled 176 | generations []uint32 177 | numComponents int // how many components have been registered so far. 178 | mut sync.Mutex 179 | } 180 | 181 | // make a new memory pool of components. 182 | // 183 | // size is the number of entities 184 | // worth of memory to pre-allocate 185 | // and the maximum number of entities if pool.EnableResize is not called 186 | // 187 | // the memory usage of the pool depends on 188 | // how many components your game has and how many 189 | // entities you allocate 190 | func New(size int) *Pool { 191 | p := &Pool{ 192 | stores: make(map[any]_Storage), 193 | componentsUsed: make(map[Entity][]_Storage), 194 | generations: make([]uint32, size), 195 | maxEntities: size + 1, 196 | } 197 | NewEntity(p) // entity 0 is unused 198 | return p 199 | } 200 | 201 | // Get an entity 202 | // this will panic if pool does not have entities available 203 | func NewEntity(p *Pool) Entity { 204 | p.mut.Lock() 205 | defer p.mut.Unlock() 206 | // if no entities are available for recycling 207 | if len(p.freeList) == 0 { 208 | if p.aliveCount >= p.maxEntities { 209 | 210 | msg := fmt.Sprintf("Entity limit exceeded. please initialize more entities by increasing the number you passed to ecs.New(). \nGiven size: %d\n Entity: %d", p.maxEntities, p.aliveCount+1) 211 | panic(msg) 212 | } 213 | e := Entity(p.aliveCount) 214 | p.aliveEntities.Set(uint(e)) 215 | p.aliveCount++ 216 | return e 217 | } 218 | // recycle an entity 219 | var newEntity = p.freeList[0] 220 | p.freeList = p.freeList[1:] 221 | var storagesUsed []_Storage = p.componentsUsed[newEntity] 222 | for _, store := range storagesUsed { 223 | //zero out the component for this entity 224 | store.delete(newEntity) 225 | } 226 | // entity no longer has these components 227 | // set slice length to 0 228 | p.componentsUsed[newEntity] = p.componentsUsed[newEntity][:0] 229 | p.generations[newEntity] += 1 230 | 231 | return newEntity 232 | } 233 | 234 | // give entities back to the pool 235 | func Kill(p *Pool, entities ...Entity) { 236 | p.mut.Lock() 237 | defer p.mut.Unlock() 238 | for _, e := range entities { 239 | if e == 0 { // cannot kill entity 0 (unused) 240 | continue 241 | } 242 | p.aliveEntities.Unset(uint(e)) 243 | //mark the entity as available 244 | p.freeList = append(p.freeList, e) 245 | var storagesUsed []_Storage = p.componentsUsed[e] 246 | for _, store := range storagesUsed { 247 | //mark as dead but dont zero out the component for this entity 248 | store.lock() 249 | store.getBitset().Unset(uint(e)) 250 | store.unlock() 251 | } 252 | } 253 | } 254 | 255 | // allocate a new component storage 256 | // 257 | // will panic if you register components twice 258 | // 259 | // Components cannot be aliases eg. 260 | // 261 | // type Position Vec2 // correct 262 | // type Position = Vec2 // incorrect 263 | func Register[Component any](pool *Pool) { 264 | pool.mut.Lock() 265 | defer pool.mut.Unlock() 266 | var nilptr *Component 267 | _, ok := pool.stores[nilptr] 268 | if !ok { 269 | pool.stores[nilptr] = newStorage[Component](pool.maxEntities, pool.numComponents) 270 | pool.numComponents++ 271 | return 272 | } 273 | panic(fmt.Sprintln("Component", nilptr, `is already registered 274 | If you are using type aliases 275 | use concrete types instead 276 | Example: 277 | type Position Vec2 // correct 278 | type Position = Vec2 // incorrect `)) 279 | } 280 | 281 | // add a component to an entity 282 | // automatically register component if ecs.AutoRegisterComponents 283 | // is true (default) 284 | func Add[Component any](pool *Pool, e Entity, component Component) { 285 | pool.mut.Lock() 286 | defer pool.mut.Unlock() 287 | if !pool.aliveEntities.IsSet(uint(e)) { 288 | return 289 | } 290 | st := registerAndGetStorage[Component](pool) 291 | st.mut.Lock() 292 | defer st.mut.Unlock() 293 | if st.b.IsSet(uint(e)) { 294 | return 295 | } 296 | st.b.Set(uint(e)) 297 | st.components[e] = component 298 | pool.componentsUsed[e] = 299 | append(pool.componentsUsed[e], st) 300 | } 301 | 302 | // remove a component from an entity 303 | func Remove[Component any](pool *Pool, e Entity) { 304 | pool.mut.Lock() 305 | defer pool.mut.Unlock() 306 | st := registerAndGetStorage[Component](pool) 307 | st.mut.Lock() 308 | if !st.b.IsSet(uint(e)) { 309 | return 310 | } 311 | st.mut.Unlock() 312 | st.delete(e) 313 | var s []_Storage = pool.componentsUsed[e] 314 | 315 | store := (_Storage)(st) 316 | // iterate in reverse 317 | // incase the component was added recently 318 | for i := len(s) - 1; i >= 0; i-- { 319 | if s[i] == store { 320 | if len(s) == 1 { 321 | pool.componentsUsed[e] = s[:0] 322 | return 323 | } 324 | // move the _Storage to the end of the slice and 325 | // shrink the slice by one 326 | last := s[len(s)-1] 327 | s[len(s)-1] = s[i] 328 | s[i] = last 329 | // "delete" last element 330 | pool.componentsUsed[e] = s[0 : len(s)-1] 331 | return 332 | } 333 | } 334 | } 335 | 336 | // check if an entity has a component 337 | // shorthand for 338 | // 339 | // POSITION := ecs.GetStorage[Position](pool) 340 | // POSITION.EntityHasComponent(entity) 341 | func Has[Component any](pool *Pool, e Entity) bool { 342 | pool.mut.Lock() 343 | defer pool.mut.Unlock() 344 | st := registerAndGetStorage[Component](pool) 345 | st.mut.Lock() 346 | defer st.mut.Unlock() 347 | return st.b.IsSet(uint(e)) 348 | } 349 | 350 | // Check if an entity is alive 351 | func IsAlive(pool *Pool, e Entity) bool { 352 | pool.mut.Lock() 353 | defer pool.mut.Unlock() 354 | return pool.aliveEntities.IsSet(uint(e)) 355 | } 356 | 357 | // NOTE: Only useful if you are storing entities in components. 358 | // 359 | // Check if an entity is alive, given its generation (reuse count). 360 | // 361 | // NOTE: check if the entity you were storing is alive with this before running 362 | // the system on it 363 | // NOTE: You can get an entity's generation with ecs.GetGeneration. 364 | func IsAliveWithGeneration(pool *Pool, e Entity, generation uint32) bool { 365 | pool.mut.Lock() 366 | defer pool.mut.Unlock() 367 | if pool.generations[e] != generation { 368 | return false 369 | } 370 | return pool.aliveEntities.IsSet(uint(e)) 371 | } 372 | 373 | // NOTE: Only useful if you are storing entities inside of components. 374 | // 375 | // generation is the number of times the entity has been reused. 376 | // 377 | // NOTE: also see ecs.IsAliveWithGeneration 378 | func GetGeneration(pool *Pool, e Entity) (generation uint32) { 379 | pool.mut.Lock() 380 | defer pool.mut.Unlock() 381 | if e > Entity(pool.maxEntities) { 382 | return 0 383 | } 384 | return pool.generations[e] 385 | } 386 | 387 | // storage contains all components of a type 388 | func GetStorage[Component any](pool *Pool) *Storage[Component] { 389 | pool.mut.Lock() 390 | defer pool.mut.Unlock() 391 | st := registerAndGetStorage[Component](pool) 392 | return st 393 | } 394 | 395 | // same as public register but also returns the storage. 396 | // it will not allocate a new storage if it already exists 397 | // this will use the pool's mutexes appropriately 398 | func registerAndGetStorage[Component any](pool *Pool) *Storage[Component] { 399 | var nilptr *Component 400 | st, ok := pool.stores[nilptr] 401 | if ok { 402 | return st.(*Storage[Component]) 403 | } else if AutoRegisterComponents { 404 | // allocate storage 405 | var st = newStorage[Component](pool.maxEntities, pool.numComponents) 406 | pool.numComponents++ 407 | pool.stores[nilptr] = st 408 | return st 409 | } 410 | var zero Component 411 | panic(fmt.Sprintf("Component of type %T was not registered", zero)) 412 | } 413 | 414 | func (s *Storage[Component]) getBitset() *bitset.BitSet { 415 | return &s.b 416 | } 417 | 418 | // If two goroutines call these methods with reversed storage parameters: 419 | 420 | // Goroutine 1: storageA.And(storageB) // Locks A → B 421 | // Goroutine 2: storageB.And(storageA) // Locks B → A 422 | // This creates a deadlock: 423 | 424 | // Goroutine 1 holds lock A, waits for lock B 425 | 426 | // Goroutine 2 holds lock B, waits for lock A 427 | 428 | // Both goroutines wait forever 429 | 430 | // The Solution: Consistent Ordering 431 | // The fix ensures locks are always acquired in the same order regardless of parameter order: 432 | func (s *Storage[Component]) orderedLock(storages ..._Storage) func() { 433 | all := make([]_Storage, 0, len(storages)+1) 434 | all = append(all, s) 435 | for _, s := range storages { 436 | if s != nil { 437 | all = append(all, s) 438 | } 439 | } 440 | // Sort by underlying storage addresses 441 | slices.SortFunc(all, func(a, b _Storage) int { 442 | if a.getNum() < b.getNum() { 443 | return -1 444 | } 445 | return +1 446 | }) 447 | // Lock in sorted order 448 | for _, st := range all { 449 | st.lock() 450 | } 451 | 452 | // Return unlock function (reverse order) 453 | return func() { 454 | for i := len(all) - 1; i >= 0; i-- { 455 | all[i].unlock() 456 | } 457 | } 458 | } 459 | 460 | func (s *Storage[Component]) lock() { 461 | s.mut.Lock() 462 | } 463 | func (s *Storage[Component]) unlock() { 464 | s.mut.Unlock() 465 | } 466 | -------------------------------------------------------------------------------- /ecs_concurrent_test.go: -------------------------------------------------------------------------------- 1 | package ecs_test 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/BrownNPC/simple-ecs" 8 | ) 9 | 10 | // Concurrency & Thread Safety 11 | func TestConcurrentEntityCreation(t *testing.T) { 12 | pool := ecs.New(1000) 13 | var wg sync.WaitGroup 14 | entities := make(chan ecs.Entity, 1000) 15 | 16 | // Create 1000 entities concurrently 17 | for i := 0; i < 1000; i++ { 18 | wg.Add(1) 19 | go func() { 20 | defer wg.Done() 21 | entities <- ecs.NewEntity(pool) 22 | }() 23 | } 24 | 25 | wg.Wait() 26 | close(entities) 27 | 28 | // Verify all entities are unique 29 | unique := make(map[ecs.Entity]bool) 30 | for e := range entities { 31 | if unique[e] { 32 | t.Fatalf("Duplicate entity created: %v", e) 33 | } 34 | unique[e] = true 35 | } 36 | } 37 | 38 | func TestConcurrentComponentOperations(t *testing.T) { 39 | for i := 0; i <= 1000; i++ { 40 | pool := ecs.New(100) 41 | e := ecs.NewEntity(pool) 42 | 43 | type Position struct{ X, Y float32 } 44 | 45 | var wg sync.WaitGroup 46 | for i := 0; i < 100; i++ { 47 | wg.Add(3) 48 | // Concurrent Add 49 | go func() { 50 | defer wg.Done() 51 | ecs.Add(pool, e, Position{1, 2}) 52 | }() 53 | // Concurrent Remove 54 | go func() { 55 | defer wg.Done() 56 | ecs.Remove[Position](pool, e) 57 | }() 58 | // Concurrent Get 59 | go func() { 60 | defer wg.Done() 61 | _ = ecs.GetStorage[Position](pool).Get(e) 62 | }() 63 | } 64 | wg.Wait() 65 | } 66 | } 67 | 68 | func TestParallelReadWrite(t *testing.T) { 69 | pool := ecs.New(100) 70 | e := ecs.NewEntity(pool) 71 | type Position struct{ X, Y float32 } 72 | 73 | var wg sync.WaitGroup 74 | for i := 0; i < 100; i++ { 75 | wg.Add(2) 76 | // Writers 77 | go func(i int) { 78 | defer wg.Done() 79 | ecs.Add(pool, e, Position{X: float32(i)}) 80 | ecs.GetStorage[Position](pool).Update(e, Position{X: float32(i)}) 81 | }(i) 82 | // Readers 83 | go func() { 84 | defer wg.Done() 85 | _ = ecs.Has[Position](pool, e) 86 | _ = ecs.GetStorage[Position](pool).Get(e) 87 | }() 88 | } 89 | wg.Wait() 90 | } 91 | 92 | // Mutex Integrity (indirectly tested via race detector) 93 | func TestNoDeadlocks(t *testing.T) { 94 | pool := ecs.New(1000) 95 | type Position struct{ X, Y int } 96 | type Velocity struct{ X, Y int } 97 | 98 | var wg sync.WaitGroup 99 | for i := 0; i < 10; i++ { 100 | wg.Add(1) 101 | go func(i int) { 102 | defer wg.Done() 103 | e := ecs.NewEntity(pool) 104 | ecs.Add(pool, e, Position{i, 0}) 105 | ecs.Add(pool, e, Velocity{0, i}) 106 | ecs.GetStorage[Position](pool).Update(e, Position{1, 1}) 107 | ecs.Remove[Velocity](pool, e) 108 | ecs.Kill(pool, e) 109 | }(i) 110 | } 111 | wg.Wait() 112 | } 113 | 114 | func TestRecycledEntitiesHaveZeroComponent(t *testing.T) { 115 | for{ 116 | type Health struct{ X, Y float32 } 117 | var wg sync.WaitGroup 118 | // first create dead entities with a non-zero component 119 | var deadEntitiesWithNonZeroComponent int 120 | var Nentities = 10 121 | pool := ecs.New(Nentities) 122 | entities := make([]ecs.Entity, 0, Nentities) 123 | for i := 0; i < Nentities; i++ { 124 | e := ecs.NewEntity(pool) 125 | ecs.Add(pool, e, Health{}) 126 | entities = append(entities, e) 127 | } 128 | wg.Add(1) 129 | go func() { 130 | ecs.Kill(pool, entities...) 131 | wg.Done() 132 | }() 133 | func(p *ecs.Pool) { 134 | HEALTH := ecs.GetStorage[Health](pool) 135 | deadEntities := make([]ecs.Entity, 0, Nentities) 136 | for _, e := range HEALTH.And(nil) { 137 | hp := HEALTH.Get(e) 138 | hp.X -= 1 139 | hp.Y -= 1 140 | HEALTH.Update(e, hp) 141 | if len(deadEntities) > 0 { 142 | hp := HEALTH.Get(deadEntities[0]) 143 | if hp.X == 0 { 144 | deadEntitiesWithNonZeroComponent += 1 145 | } 146 | deadEntities = deadEntities[1:] 147 | } 148 | 149 | if !ecs.IsAlive(pool, e) { 150 | deadEntities = append(deadEntities, e) 151 | } 152 | } 153 | }(pool) 154 | wg.Wait() 155 | //make sure dead entities with non zero components were created 156 | if deadEntitiesWithNonZeroComponent == 0 { 157 | continue // restart since actual test cant happen 158 | } 159 | // Begin the actual test 160 | POSITION := ecs.GetStorage[Health](pool) 161 | for i := 0; i < Nentities; i++ { 162 | e := ecs.NewEntity(pool) 163 | pos := POSITION.Get(e) 164 | if pos.X != 0 { 165 | t.Error("reused entity does not have a zero component") 166 | } 167 | } 168 | break 169 | }} 170 | 171 | -------------------------------------------------------------------------------- /ecs_test.go: -------------------------------------------------------------------------------- 1 | // run the tests with: go:build -race 2 | 3 | package ecs_test 4 | 5 | import ( 6 | "testing" 7 | 8 | ecs "github.com/BrownNPC/simple-ecs" // Replace with actual import path 9 | ) 10 | 11 | func TestNewEntityCreation(t *testing.T) { 12 | 13 | t.Run("Create entities up to limit", func(t *testing.T) { 14 | poolSize := 5 15 | p := ecs.New(poolSize) 16 | 17 | // Create entities up to pool size 18 | var entities []ecs.Entity 19 | for i := 0; i < poolSize; i++ { 20 | e := ecs.NewEntity(p) 21 | entities = append(entities, e) 22 | } 23 | 24 | // Verify all entities are unique and within bounds 25 | seen := make(map[ecs.Entity]bool) 26 | for _, e := range entities { 27 | if e < 0 || e >= ecs.Entity(poolSize) { 28 | t.Errorf("Entity %d out of bounds", e) 29 | } 30 | if seen[e] { 31 | t.Errorf("Duplicate entity %d created", e) 32 | } 33 | seen[e] = true 34 | } 35 | }) 36 | 37 | t.Run("Panic when exceeding limit", func(t *testing.T) { 38 | poolSize := 1 39 | p := ecs.New(poolSize) 40 | 41 | // Fill pool 42 | for i := 0; i < poolSize; i++ { 43 | ecs.NewEntity(p) 44 | } 45 | 46 | defer func() { 47 | if r := recover(); r == nil { 48 | t.Error("Expected panic when exceeding entity limit") 49 | } 50 | }() 51 | 52 | // This should panic 53 | ecs.NewEntity(p) 54 | }) 55 | } 56 | 57 | func TestEntityReuse(t *testing.T) { 58 | 59 | poolSize := 3 60 | p := ecs.New(poolSize) 61 | 62 | // Create and kill an entity 63 | e := ecs.NewEntity(p) 64 | ecs.Kill(p, e) 65 | 66 | t.Run("Reuse from free list", func(t *testing.T) { 67 | newEntity := ecs.NewEntity(p) 68 | if newEntity != e { 69 | t.Errorf("Expected reused entity %d, got %d", e, newEntity) 70 | } 71 | }) 72 | 73 | t.Run("Components properly reset", func(t *testing.T) { 74 | type Position struct{ x, y float32 } 75 | 76 | // Add component to original entity 77 | ecs.Add(p, e, Position{10, 20}) 78 | ecs.Kill(p, e) 79 | 80 | // Reuse entity 81 | reused := ecs.NewEntity(p) 82 | // make sure entity is reused 83 | if reused != e { 84 | t.Errorf("Entity was not reused.") 85 | } 86 | 87 | // Check components 88 | if ecs.Has[Position](p, reused) { 89 | t.Error("Reused entity has residual component") 90 | } 91 | 92 | // Verify storage is clean 93 | storage := ecs.GetStorage[Position](p) 94 | if storage.EntityHasComponent(reused) { 95 | t.Error("Storage shows component for reused entity") 96 | } 97 | }) 98 | } 99 | 100 | func TestKillEntities(t *testing.T) { 101 | 102 | poolSize := 5 103 | p := ecs.New(poolSize) 104 | e := ecs.NewEntity(p) 105 | 106 | t.Run("Mark as dead", func(t *testing.T) { 107 | ecs.Kill(p, e) 108 | if ecs.IsAlive(p, e) { 109 | t.Error("Entity still marked alive after kill") 110 | } 111 | }) 112 | 113 | t.Run("Remove components from storage", func(t *testing.T) { 114 | type Health struct{ value int } 115 | 116 | // Add component and kill 117 | ecs.Add(p, e, Health{100}) 118 | ecs.Kill(p, e) 119 | 120 | // Check storage 121 | storage := ecs.GetStorage[Health](p) 122 | if storage.EntityHasComponent(e) { 123 | t.Error("Component still present in storage after kill") 124 | } 125 | }) 126 | 127 | } 128 | 129 | func TestIsAliveCheck(t *testing.T) { 130 | 131 | poolSize := 3 132 | p := ecs.New(poolSize) 133 | 134 | t.Run("True for alive entities", func(t *testing.T) { 135 | e := ecs.NewEntity(p) 136 | if !ecs.IsAlive(p, e) { 137 | t.Error("New entity not marked alive") 138 | } 139 | }) 140 | 141 | t.Run("False for killed entities", func(t *testing.T) { 142 | e := ecs.NewEntity(p) 143 | ecs.Kill(p, e) 144 | if ecs.IsAlive(p, e) { 145 | t.Error("Killed entity still marked alive") 146 | } 147 | }) 148 | } 149 | 150 | // Edge Cases & Error Handling 151 | func TestInvalidEntityHandling(t *testing.T) { 152 | pool := ecs.New(10) 153 | e := ecs.NewEntity(pool) 154 | type Position struct{ X, Y float32 } 155 | 156 | // Kill entity first 157 | ecs.Kill(pool, e) 158 | 159 | // Try operations on dead entity 160 | ecs.Add(pool, e, Position{1, 2}) 161 | if ecs.Has[Position](pool, e) { 162 | t.Error("Dead entity should not receive components") 163 | } 164 | 165 | // Remove on dead entity (should be no-op) 166 | ecs.Remove[Position](pool, e) 167 | } 168 | 169 | func TestUpdateDeadEntityPanic(t *testing.T) { 170 | t.Run("updating dead entity should not panic.", func(t *testing.T) { 171 | pool := ecs.New(10) 172 | e := ecs.NewEntity(pool) 173 | type Position struct{ X, Y float32 } 174 | 175 | ecs.Add(pool, e, Position{1, 2}) 176 | ecs.Kill(pool, e) 177 | ecs.GetStorage[Position](pool).Update(e, Position{3, 4}) 178 | }) 179 | } 180 | func TestComponentZeroValue(t *testing.T) { 181 | pool := ecs.New(10) 182 | e := ecs.NewEntity(pool) 183 | type Position struct{ X, Y float32 } 184 | 185 | // Add component and kill entity 186 | ecs.Add(pool, e, Position{1, 2}) 187 | ecs.Kill(pool, e) 188 | 189 | // Verify component was zeroed 190 | st := ecs.GetStorage[Position](pool) 191 | if st.EntityHasComponent(e) { 192 | t.Error("Killed entity's component should be zeroed") 193 | } 194 | } 195 | 196 | -------------------------------------------------------------------------------- /examples/MovementSystem/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrownNPC/simple-ecs/examples/MovementSystem 2 | 3 | go 1.24.1 4 | 5 | require github.com/BrownNPC/simple-ecs v1.3.0 6 | -------------------------------------------------------------------------------- /examples/MovementSystem/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BrownNPC/simple-ecs v1.3.0 h1:XMu7a7bunlztiG22zpIqiHr/35MTQwYwZnlnw948uIk= 2 | github.com/BrownNPC/simple-ecs v1.3.0/go.mod h1:za2tJyb/GgSyo40nG1nxohU4ThHNP+pzDmAz4KkahiU= 3 | -------------------------------------------------------------------------------- /examples/MovementSystem/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | ecs "github.com/BrownNPC/simple-ecs" 5 | "math/rand" 6 | ) 7 | 8 | // Define component types 9 | type Vec2 struct { 10 | X, Y float64 11 | } 12 | 13 | // components need to be concrete types 14 | // type Position = Vec2 // is incorrect 15 | type Position Vec2 16 | type Velocity Vec2 17 | 18 | func main() { 19 | // create a memory pool of component arrays 20 | // the pool can hold 1000 entities 21 | var pool = ecs.New(1000) 22 | // create 1000 entities 23 | for i:=0; i<=1000; i++{ 24 | // entities (which are just ids) 25 | // should only be created using the pool 26 | var e = ecs.NewEntity(pool) 27 | // add position and 28 | // velocity components to the entity 29 | ecs.Add2(pool, e, 30 | Position{}, 31 | Velocity{ 32 | X: rand.Float64(), 33 | Y: rand.Float64(), 34 | }) 35 | } 36 | // run movement system 60 times 37 | for i:=0; i<=60; i++ { 38 | MovementSystem(pool, 1.0/60) 39 | } 40 | } 41 | 42 | // a system is a regular function that 43 | // operates on the components 44 | func MovementSystem(p *ecs.Pool, 45 | deltaTime float64, 46 | ) { 47 | // a storage holds a slice (array) of components 48 | POSITION, VELOCITY := 49 | ecs.GetStorage2[ 50 | Position, 51 | Velocity, 52 | ](p) 53 | // get entities (id/index) that have 54 | // a position and velocity component 55 | for _, ent := range POSITION.And(VELOCITY) { 56 | // use the entity to index the 57 | // position and velocity slices 58 | pos, vel := 59 | POSITION.Get(ent), 60 | VELOCITY.Get(ent) 61 | pos.X += vel.X * deltaTime 62 | pos.Y += vel.Y * deltaTime 63 | // update position of entity 64 | POSITION.Update(ent, pos) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/ebiten/DodgeRocks/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrownNPC/simple-ecs/examples/ebiten/DodgeRocks 2 | 3 | go 1.23.0 4 | 5 | require github.com/BrownNPC/simple-ecs v1.3.0 6 | 7 | require ( 8 | github.com/hajimehoshi/ebiten/v2 v2.8.6 9 | golang.org/x/image v0.25.0 10 | ) 11 | 12 | require ( 13 | github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 // indirect 14 | github.com/ebitengine/hideconsole v1.0.0 // indirect 15 | github.com/ebitengine/purego v0.8.0 // indirect 16 | github.com/jezek/xgb v1.1.1 // indirect 17 | golang.org/x/sync v0.8.0 // indirect 18 | golang.org/x/sys v0.25.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /examples/ebiten/DodgeRocks/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BrownNPC/simple-ecs v1.3.0 h1:XMu7a7bunlztiG22zpIqiHr/35MTQwYwZnlnw948uIk= 2 | github.com/BrownNPC/simple-ecs v1.3.0/go.mod h1:za2tJyb/GgSyo40nG1nxohU4ThHNP+pzDmAz4KkahiU= 3 | github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 h1:Gk1XUEttOk0/hb6Tq3WkmutWa0ZLhNn/6fc6XZpM7tM= 4 | github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325/go.mod h1:ulhSQcbPioQrallSuIzF8l1NKQoD7xmMZc5NxzibUMY= 5 | github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= 6 | github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= 7 | github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= 8 | github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 9 | github.com/hajimehoshi/bitmapfont/v3 v3.2.0 h1:0DISQM/rseKIJhdF29AkhvdzIULqNIIlXAGWit4ez1Q= 10 | github.com/hajimehoshi/bitmapfont/v3 v3.2.0/go.mod h1:8gLqGatKVu0pwcNCJguW3Igg9WQqVXF0zg/RvrGQWyg= 11 | github.com/hajimehoshi/ebiten/v2 v2.8.6 h1:Dkd/sYI0TYyZRCE7GVxV59XC+WCi2BbGAbIBjXeVC1U= 12 | github.com/hajimehoshi/ebiten/v2 v2.8.6/go.mod h1:cCQ3np7rdmaJa1ZnvslraVlpxNb3wCjEnAP1LHNyXNA= 13 | github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= 14 | github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= 15 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 16 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 17 | golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= 18 | golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= 19 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 20 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 21 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 22 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 23 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 24 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 25 | -------------------------------------------------------------------------------- /examples/ebiten/DodgeRocks/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "image/color" 5 | "log" 6 | "math" 7 | "math/rand" 8 | 9 | ecs "github.com/BrownNPC/simple-ecs" 10 | "github.com/hajimehoshi/ebiten/v2" 11 | "github.com/hajimehoshi/ebiten/v2/text" 12 | "golang.org/x/image/font" 13 | "golang.org/x/image/font/basicfont" 14 | ) 15 | 16 | // Define Components 17 | type Position struct { 18 | X, Y float32 19 | } 20 | type Velocity struct { 21 | X, Y float32 22 | } 23 | 24 | type Tag int 25 | 26 | const ( 27 | Player Tag = iota 28 | Rock 29 | ) 30 | 31 | var lastSpawnTime float32 = 0 32 | 33 | // For convenience sake 34 | var player ecs.Entity 35 | var gameOver bool 36 | 37 | var playerImage *ebiten.Image 38 | var rockImage *ebiten.Image 39 | var fontFace font.Face = basicfont.Face7x13 40 | 41 | func initImages() { 42 | // Create a blue square for the player (30x30) 43 | playerImage = ebiten.NewImage(30, 30) 44 | playerImage.Fill(color.RGBA{0, 0, 255, 255}) 45 | 46 | // Create a dark brown circle for the rock (diameter 24, radius 12) 47 | rockImage = ebiten.NewImage(24, 24) 48 | r := float32(12) 49 | for y := 0; y < 24; y++ { 50 | for x := 0; x < 24; x++ { 51 | dx := float32(x) - r 52 | dy := float32(y) - r 53 | if dx*dx+dy*dy <= r*r { 54 | rockImage.Set(x, y, color.RGBA{101, 67, 33, 255}) 55 | } else { 56 | rockImage.Set(x, y, color.Transparent) 57 | } 58 | } 59 | } 60 | } 61 | 62 | func main() { 63 | initImages() 64 | 65 | pool := ecs.New(100) 66 | 67 | // Create the player 68 | player = ecs.NewEntity(pool) 69 | ecs.Add3(pool, player, 70 | Position{X: 180, Y: 600}, // Center bottom 71 | Velocity{X: 0, Y: 0}, 72 | Player, 73 | ) 74 | 75 | game := &Game{pool: pool} 76 | ebiten.SetWindowSize(360, 640) 77 | ebiten.SetWindowTitle("Dodge the Rocks") 78 | if err := ebiten.RunGame(game); err != nil { 79 | log.Fatal(err) 80 | } 81 | } 82 | 83 | type Game struct { 84 | pool *ecs.Pool 85 | } 86 | 87 | func (g *Game) Update() error { 88 | var dt float32 = 1.0 / 60.0 89 | if !gameOver { 90 | playerMovementSystem(g.pool) 91 | movementSystem(g.pool, dt) 92 | collisionSystem(g.pool, player) 93 | spawnSystem(g.pool, dt) 94 | despawnSystem(g.pool) 95 | } 96 | return nil 97 | } 98 | 99 | func (g *Game) Draw(screen *ebiten.Image) { 100 | // Clear the screen with white (similar to rl.ClearBackground(rl.RayWhite)) 101 | screen.Fill(color.RGBA{255, 255, 255, 255}) 102 | renderingSystem(g.pool, screen) 103 | } 104 | 105 | func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { 106 | return 360, 640 107 | } 108 | 109 | func playerMovementSystem(p *ecs.Pool) { 110 | POSITION, VELOCITY, TAG := ecs.GetStorage3[Position, Velocity, Tag](p) 111 | 112 | for _, e := range POSITION.And(VELOCITY, TAG) { 113 | pos, vel := POSITION.Get(e), VELOCITY.Get(e) 114 | tag := TAG.Get(e) 115 | if tag != Player { 116 | continue 117 | } 118 | 119 | // Move left/right using ebiten key input 120 | if ebiten.IsKeyPressed(ebiten.KeyArrowLeft) { 121 | vel.X = -200 122 | } else if ebiten.IsKeyPressed(ebiten.KeyArrowRight) { 123 | vel.X = 200 124 | } else { 125 | vel.X = 0 126 | } 127 | 128 | POSITION.Update(e, pos) 129 | VELOCITY.Update(e, vel) 130 | } 131 | } 132 | 133 | func movementSystem(p *ecs.Pool, dt float32) { 134 | POSITION, VELOCITY, TAG := ecs.GetStorage3[Position, Velocity, Tag](p) 135 | 136 | for _, e := range POSITION.And(VELOCITY) { 137 | pos, vel := POSITION.Get(e), VELOCITY.Get(e) 138 | pos.X += vel.X * dt 139 | pos.Y += vel.Y * dt 140 | 141 | // Clamp player within screen bounds 142 | if TAG.Get(e) == Player { 143 | if pos.X < 0 { 144 | pos.X = 0 145 | } else if pos.X > 360 { 146 | pos.X = 360 147 | } 148 | } 149 | 150 | POSITION.Update(e, pos) 151 | } 152 | } 153 | 154 | func spawnSystem(p *ecs.Pool, dt float32) { 155 | lastSpawnTime += dt 156 | if lastSpawnTime < 0.5 { 157 | return 158 | } 159 | lastSpawnTime = 0 // Reset timer 160 | 161 | e := ecs.NewEntity(p) 162 | ecs.Add3(p, e, 163 | Position{X: float32(rand.Intn(360)), Y: 0}, // Random X at top 164 | Velocity{X: 0, Y: 100}, // Falling down 165 | Rock, 166 | ) 167 | } 168 | 169 | func despawnSystem(p *ecs.Pool) { 170 | TAG, POSITION := ecs.GetStorage2[Tag, Position](p) 171 | for _, e := range TAG.And() { 172 | if TAG.Get(e) == Rock { 173 | pos := POSITION.Get(e) 174 | if pos.Y > 700 { 175 | ecs.Kill(p, e) 176 | } 177 | } 178 | } 179 | } 180 | 181 | func collisionSystem(p *ecs.Pool, player ecs.Entity) { 182 | POSITION, TAG := ecs.GetStorage2[Position, Tag](p) 183 | for _, e := range POSITION.And(TAG) { 184 | if TAG.Get(e) == Player { 185 | continue 186 | } 187 | rockPos := POSITION.Get(e) 188 | plrPos := POSITION.Get(player) 189 | 190 | if checkCollisionCircleRect(rockPos, 12, plrPos.X, plrPos.Y, 30, 30) { 191 | gameOver = true 192 | } 193 | } 194 | } 195 | 196 | func checkCollisionCircleRect(circlePos Position, radius float32, rectX, rectY, rectW, rectH float32) bool { 197 | // Find the closest point to the circle within the rectangle 198 | closestX := math.Max(float64(rectX), math.Min(float64(circlePos.X), float64(rectX+rectW))) 199 | closestY := math.Max(float64(rectY), math.Min(float64(circlePos.Y), float64(rectY+rectH))) 200 | dx := float64(circlePos.X) - closestX 201 | dy := float64(circlePos.Y) - closestY 202 | 203 | return dx*dx+dy*dy <= float64(radius*radius) 204 | } 205 | 206 | func renderingSystem(p *ecs.Pool, screen *ebiten.Image) { 207 | if gameOver { 208 | msg := "GAME OVER" 209 | bounds := text.BoundString(fontFace, msg) 210 | textWidth := bounds.Dx() 211 | x := (360 - textWidth) / 2 212 | y := 640 / 2 213 | text.Draw(screen, msg, fontFace, x, y, color.RGBA{255, 0, 0, 255}) 214 | return 215 | } 216 | 217 | POSITION, TAG := ecs.GetStorage2[Position, Tag](p) 218 | 219 | // Draw rocks and player 220 | for _, e := range POSITION.And(TAG) { 221 | pos, tag := POSITION.Get(e), TAG.Get(e) 222 | if tag == Rock { 223 | op := &ebiten.DrawImageOptions{} 224 | // Center the circle image (radius 12) 225 | op.GeoM.Translate(float64(pos.X-12), float64(pos.Y-12)) 226 | screen.DrawImage(rockImage, op) 227 | } else if tag == Player { 228 | op := &ebiten.DrawImageOptions{} 229 | // Center the player rectangle (half of 30 is 15) 230 | op.GeoM.Translate(float64(pos.X-15), float64(pos.Y-15)) 231 | screen.DrawImage(playerImage, op) 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /examples/raylib/BreakOut/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrownNPC/simple-ecs/examples/raylib/BreakOut 2 | 3 | go 1.21 4 | 5 | -------------------------------------------------------------------------------- /examples/raylib/BreakOut/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | ecs "github.com/BrownNPC/simple-ecs" 5 | rl "github.com/gen2brain/raylib-go/raylib" 6 | ) 7 | func main(){ 8 | ecs.New(100) 9 | rl.InitWindow 10 | } 11 | -------------------------------------------------------------------------------- /examples/raylib/DodgeRocks/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrownNPC/simple-ecs/examples/raylib/DodgeRocks 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/BrownNPC/simple-ecs v1.3.0 7 | github.com/gen2brain/raylib-go/raylib v0.0.0-20250327103758-b542022337b8 8 | ) 9 | 10 | require ( 11 | github.com/ebitengine/purego v0.7.1 // indirect 12 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect 13 | golang.org/x/sys v0.20.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /examples/raylib/DodgeRocks/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BrownNPC/simple-ecs v1.3.0 h1:XMu7a7bunlztiG22zpIqiHr/35MTQwYwZnlnw948uIk= 2 | github.com/BrownNPC/simple-ecs v1.3.0/go.mod h1:za2tJyb/GgSyo40nG1nxohU4ThHNP+pzDmAz4KkahiU= 3 | github.com/ebitengine/purego v0.7.1 h1:6/55d26lG3o9VCZX8lping+bZcmShseiqlh2bnUDiPA= 4 | github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= 5 | github.com/gen2brain/raylib-go/raylib v0.0.0-20250327103758-b542022337b8 h1:US4FifqOxazVDge0Pc/8WUln30yL+cTEUhWLKtUcAKo= 6 | github.com/gen2brain/raylib-go/raylib v0.0.0-20250327103758-b542022337b8/go.mod h1:BaY76bZk7nw1/kVOSQObPY1v1iwVE1KHAGMfvI6oK1Q= 7 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 8 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 9 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 10 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 11 | -------------------------------------------------------------------------------- /examples/raylib/DodgeRocks/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | 6 | ecs "github.com/BrownNPC/simple-ecs" 7 | rl "github.com/gen2brain/raylib-go/raylib" 8 | ) 9 | 10 | // Define Components 11 | type Position rl.Vector2 12 | type Velocity rl.Vector2 13 | 14 | type Tag int 15 | 16 | const ( 17 | Player Tag = iota 18 | Rock 19 | ) 20 | 21 | var lastSpawnTime float32 = 0 22 | 23 | // for convenience sake 24 | var player ecs.Entity 25 | var gameOver bool 26 | 27 | func main() { 28 | rl.InitWindow(360, 640, "Dodge the Rocks") 29 | defer rl.CloseWindow() 30 | 31 | p := ecs.New(100) 32 | 33 | // Create the player 34 | player = ecs.NewEntity(p) 35 | ecs.Add3(p, player, 36 | Position{X: 180, Y: 600}, // Center bottom 37 | Velocity{X: 0, Y: 0}, 38 | Player, 39 | ) 40 | 41 | for !rl.WindowShouldClose() { 42 | dt := rl.GetFrameTime() 43 | Update(p, dt) 44 | 45 | rl.BeginDrawing() 46 | rl.ClearBackground(rl.RayWhite) 47 | renderingSystem(p) 48 | rl.EndDrawing() 49 | } 50 | } 51 | 52 | func Update(p *ecs.Pool, dt float32) { 53 | if !gameOver { 54 | playerMovementSystem(p) 55 | movementSystem(p, dt) 56 | collisionSystem(p, player) 57 | spawnSystem(p, dt) 58 | despawnSystem(p) 59 | } 60 | } 61 | 62 | func playerMovementSystem(p *ecs.Pool) { 63 | POSITION, VELOCITY, TAG := 64 | ecs.GetStorage3[Position, Velocity, Tag](p) 65 | 66 | for _, e := range POSITION.And(VELOCITY, TAG) { 67 | pos, vel := POSITION.Get(e), VELOCITY.Get(e) 68 | tag := TAG.Get(e) 69 | if tag != Player { 70 | continue 71 | } 72 | 73 | // Move left 74 | if rl.IsKeyDown(rl.KeyLeft) { 75 | vel.X = -200 76 | } else if rl.IsKeyDown(rl.KeyRight) { 77 | vel.X = 200 78 | } else { 79 | vel.X = 0 80 | } 81 | 82 | POSITION.Update(e, pos) 83 | VELOCITY.Update(e, vel) 84 | } 85 | } 86 | 87 | func movementSystem(p *ecs.Pool, dt float32) { 88 | POSITION, VELOCITY, TAG := 89 | ecs.GetStorage3[Position, Velocity, Tag](p) 90 | 91 | for _, e := range POSITION.And(VELOCITY) { 92 | pos, vel := POSITION.Get(e), VELOCITY.Get(e) 93 | pos.X += vel.X * dt 94 | pos.Y += vel.Y * dt 95 | 96 | // Clamp player within screen bounds 97 | if TAG.Get(e) == Player { 98 | if pos.X < 0 { 99 | pos.X = 0 100 | } else if pos.X > 360 { 101 | pos.X = 360 102 | } 103 | } 104 | 105 | POSITION.Update(e, pos) 106 | } 107 | } 108 | 109 | func spawnSystem(p *ecs.Pool, dt float32) { 110 | lastSpawnTime += dt 111 | if lastSpawnTime < 0.5 { 112 | return 113 | } 114 | lastSpawnTime = 0 // Reset timer 115 | 116 | e := ecs.NewEntity(p) 117 | ecs.Add3(p, e, 118 | Position{X: float32(rand.Intn(360)), Y: 0}, // Random X at top 119 | Velocity{X: 0, Y: 100}, // Falling down 120 | Rock, 121 | ) 122 | } 123 | 124 | func despawnSystem(p *ecs.Pool) { 125 | TAG, POSITION := ecs.GetStorage2[Tag, Position](p) 126 | for _, e := range TAG.And() { 127 | if TAG.Get(e) == Rock { 128 | pos := POSITION.Get(e) 129 | if pos.Y > 700 { 130 | ecs.Kill(p, e) 131 | } 132 | } 133 | } 134 | } 135 | 136 | func collisionSystem(p *ecs.Pool, player ecs.Entity) { 137 | POSITION, TAG := 138 | ecs.GetStorage2[Position, Tag](p) 139 | for _, e := range POSITION.And(TAG) { 140 | if TAG.Get(e) == Player { 141 | continue 142 | } 143 | rock_pos := POSITION.Get(e) 144 | plr_pos := POSITION.Get(player) 145 | 146 | if rl.CheckCollisionCircleRec(rl.Vector2(rock_pos), 12, 147 | rl.NewRectangle(plr_pos.X, plr_pos.Y, 30, 30)) { 148 | gameOver = true 149 | } 150 | } 151 | } 152 | func renderingSystem(p *ecs.Pool) { 153 | if gameOver { 154 | rl.DrawText("GAME OVER", (360/2)-rl.MeasureText("GAME OVER", 30)/2, 640/2, 30, rl.Red) 155 | return 156 | } 157 | 158 | POSITION, TAG := 159 | ecs.GetStorage2[Position, Tag](p) 160 | 161 | // Draw rocks and player 162 | for _, e := range POSITION.And(TAG) { 163 | pos, tag := 164 | POSITION.Get(e), TAG.Get(e) 165 | if tag == Rock { 166 | pos := POSITION.Get(e) 167 | rl.DrawCircle(int32(pos.X), int32(pos.Y), 12, rl.DarkBrown) 168 | } else if tag == Player { 169 | rl.DrawRectangle(int32(pos.X-15), int32(pos.Y-15), 30, 30, rl.Blue) 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /generations.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrownNPC/simple-ecs 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrownNPC/simple-ecs/5597ef0079de226b88195d20b32fcb6ca1cac9a2/go.sum -------------------------------------------------------------------------------- /internal/bitset.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "slices" 5 | ) 6 | 7 | const bitsPerByte = 8 8 | 9 | type BitSet struct { 10 | Data []byte 11 | } 12 | 13 | func (b *BitSet) Set(pos uint) { 14 | 15 | byteIndex := pos / bitsPerByte 16 | bitIndex := pos % bitsPerByte 17 | 18 | if byteIndex >= uint(len(b.Data)) { 19 | newSize := byteIndex + 1 20 | newData := make([]byte, newSize) 21 | copy(newData, b.Data) 22 | b.Data = newData 23 | } 24 | 25 | b.Data[byteIndex] |= 1 << bitIndex 26 | } 27 | 28 | func (b *BitSet) Unset(pos uint) { 29 | 30 | byteIndex := pos / bitsPerByte 31 | if byteIndex >= uint(len(b.Data)) { 32 | return 33 | } 34 | bitIndex := pos % bitsPerByte 35 | b.Data[byteIndex] &^= 1 << bitIndex 36 | } 37 | 38 | func (b *BitSet) IsSet(pos uint) bool { 39 | 40 | byteIndex := pos / bitsPerByte 41 | if byteIndex >= uint(len(b.Data)) { 42 | return false 43 | } 44 | bitIndex := pos % bitsPerByte 45 | return (b.Data[byteIndex] & (1 << bitIndex)) != 0 46 | } 47 | 48 | func (b *BitSet) And(other *BitSet) { 49 | otherLen := len(other.Data) 50 | bLen := len(b.Data) 51 | minLen := Minint(bLen, otherLen) 52 | 53 | for i := 0; i < minLen; i++ { 54 | b.Data[i] &= other.Data[i] 55 | } 56 | 57 | if bLen > minLen { 58 | b.Data = b.Data[:minLen] 59 | } 60 | } 61 | 62 | func (b *BitSet) Or(other *BitSet) { 63 | otherLen := len(other.Data) 64 | bLen := len(b.Data) 65 | 66 | if otherLen > bLen { 67 | newData := make([]byte, otherLen) 68 | copy(newData, b.Data) 69 | b.Data = newData 70 | } 71 | 72 | for i := 0; i < otherLen; i++ { 73 | b.Data[i] |= other.Data[i] 74 | } 75 | } 76 | 77 | func (b *BitSet) AndNot(other *BitSet) { 78 | minLen := Minint(len(b.Data), len(other.Data)) 79 | 80 | for i := 0; i < minLen; i++ { 81 | b.Data[i] &^= other.Data[i] 82 | } 83 | } 84 | 85 | func ActiveIndices[T ~uint32](b *BitSet) []T { 86 | ret := make([]T, 0, len(b.Data)) 87 | for NthByte, byteVal := range b.Data { 88 | for NthBit := 0; NthBit < 8; NthBit++ { 89 | if byteVal&(1<> 31)) 104 | } 105 | -------------------------------------------------------------------------------- /internal/bitset_test.go: -------------------------------------------------------------------------------- 1 | package internal_test 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/BrownNPC/simple-ecs/internal" 8 | "slices" 9 | ) 10 | 11 | func TestSetAndIsSet(t *testing.T) { 12 | bs := &internal.BitSet{} 13 | 14 | // Test basic set/get 15 | bs.Set(3) 16 | if !bs.IsSet(3) { 17 | t.Error("Bit 3 should be set") 18 | } 19 | if bs.IsSet(2) { 20 | t.Error("Bit 2 should not be set") 21 | } 22 | 23 | // Test auto-expansion 24 | bs.Set(15) // Needs 2 bytes (index 1) 25 | if !bs.IsSet(15) { 26 | t.Error("Bit 15 should be set after expansion") 27 | } 28 | if len(bs.Data) != 2 { 29 | t.Error("Data slice should have expanded to 2 bytes") 30 | } 31 | } 32 | 33 | func TestUnset(t *testing.T) { 34 | bs := &internal.BitSet{} 35 | bs.Set(5) 36 | bs.Unset(5) 37 | if bs.IsSet(5) { 38 | t.Error("Bit 5 should be unset") 39 | } 40 | 41 | // Test unset beyond data length 42 | bs.Unset(100) 43 | if bs.IsSet(100) { 44 | t.Error("Bit 100 should not be set") 45 | } 46 | } 47 | 48 | func TestBitwiseOperations(t *testing.T) { 49 | t.Run("And", func(t *testing.T) { 50 | bs1 := &internal.BitSet{} 51 | bs2 := &internal.BitSet{} 52 | 53 | bs1.Set(1) 54 | bs1.Set(2) 55 | bs2.Set(2) 56 | bs2.Set(3) 57 | 58 | bs1.And(bs2) 59 | if !bs1.IsSet(2) || bs1.IsSet(1) || bs1.IsSet(3) { 60 | t.Error("AND operation failed") 61 | } 62 | }) 63 | 64 | t.Run("Or", func(t *testing.T) { 65 | bs1 := &internal.BitSet{} 66 | bs2 := &internal.BitSet{} 67 | 68 | bs1.Set(1) 69 | bs2.Set(2) 70 | bs1.Or(bs2) 71 | 72 | if !bs1.IsSet(1) || !bs1.IsSet(2) { 73 | t.Error("OR operation failed") 74 | } 75 | }) 76 | 77 | t.Run("AndNot", func(t *testing.T) { 78 | bs1 := &internal.BitSet{} 79 | bs2 := &internal.BitSet{} 80 | 81 | bs1.Set(1) 82 | bs1.Set(2) 83 | bs2.Set(2) 84 | bs1.AndNot(bs2) 85 | 86 | if !bs1.IsSet(1) || bs1.IsSet(2) { 87 | t.Error("AND NOT operation failed") 88 | } 89 | }) 90 | } 91 | 92 | func TestActiveIndices(t *testing.T) { 93 | bs := &internal.BitSet{} 94 | bs.Set(0) // First bit 95 | bs.Set(7) // Last bit of first byte 96 | bs.Set(8) // First bit of second byte 97 | bs.Set(15) // Last bit of second byte 98 | 99 | expected := []uint32{0, 7, 8, 15} 100 | indices := internal.ActiveIndices[uint32](bs) 101 | 102 | if !slices.Equal(indices, expected) { 103 | t.Errorf("Expected %v, got %v", expected, indices) 104 | } 105 | } 106 | 107 | func TestClone(t *testing.T) { 108 | bs := &internal.BitSet{} 109 | bs.Set(5) 110 | clone := bs.Clone() 111 | 112 | // Modify original 113 | bs.Set(10) 114 | 115 | if clone.IsSet(10) { 116 | t.Error("Clone should not be affected by original modification") 117 | } 118 | if !clone.IsSet(5) { 119 | t.Error("Clone should retain original bits") 120 | } 121 | } 122 | 123 | func TestConcurrentAccess(t *testing.T) { 124 | bs := &internal.BitSet{} 125 | var wg sync.WaitGroup 126 | 127 | for i := 0; i < 1000; i++ { 128 | wg.Add(1) 129 | go func(n int) { 130 | defer wg.Done() 131 | bs.Set(uint(n)) 132 | _ = bs.IsSet(uint(n)) 133 | bs.Unset(uint(n)) 134 | }(i) 135 | } 136 | 137 | wg.Wait() 138 | } 139 | 140 | func TestMinint(t *testing.T) { 141 | testCases := []struct { 142 | a, b, expected int 143 | }{ 144 | {5, 3, 3}, 145 | {3, 5, 3}, 146 | {-1, 0, -1}, 147 | {100, 200, 100}, 148 | {0, 0, 0}, 149 | } 150 | 151 | for _, tc := range testCases { 152 | result := internal.Minint(tc.a, tc.b) 153 | if result != tc.expected { 154 | t.Errorf("minint(%d, %d) = %d, want %d", tc.a, tc.b, result, tc.expected) 155 | } 156 | } 157 | } 158 | 159 | func TestEdgeCases(t *testing.T) { 160 | t.Run("ZeroValue", func(t *testing.T) { 161 | bs := &internal.BitSet{} 162 | if bs.IsSet(0) { 163 | t.Error("New bitset should have no bits set") 164 | } 165 | }) 166 | 167 | t.Run("HighBitPosition", func(t *testing.T) { 168 | bs := &internal.BitSet{} 169 | bs.Set(1023) // Test with large position 170 | if !bs.IsSet(1023) { 171 | t.Error("High bit position should be set") 172 | } 173 | }) 174 | } 175 | --------------------------------------------------------------------------------