Entity Systems - Two ECS Alternatives
I’m not making the case for or against ECS (Entity Component Systems) in this article.
I’ll assume you have done your research and decided against it.
So then, what is an Entity System and how do we build one?
Keeping things simple, we’ll put data required for all entities onto an Entity
type.
This is often referred to as a “mega-struct”.
Entity :: struct {
pos, vel: Vec2,
collider_size: Vec2,
sprite_size, sprite_offset: Vec2,
health, max_health: int,
flags: bit_set[Entity_Flags],
}
We can store a bunch of boolean values inside a bit_set
.
Entity_Flags :: enum {
Is_Dead,
Is_Flipped,
// etc...
}
We’ll store our entities in a simple dynamic array.
entities: [dynamic]Entity
Then, if you language allows it, I recommend creating a distinct type for your entity IDs.
Entity_Id :: distinct int
Why a distinct type?
Imagine we use a simple int
.
Then imagine we also create another system that uses int
as IDs.
other_system_id := other_system_create()
entity := entity_get(other_system_id)
Obviously that is contrived, but you can get into situations where you pass the wrong ID in because they are all int
.
With distinct types, you’ll get a type error at compile time if you pass anything other than Entity_Id
in.
Let’s build out the entity system procs:
entity_get :: proc(id: Entity_Id) -> ^Entity {
if int(id) < len(entities) {
return &entities[int(id)]
}
return nil
}
entity_create :: proc(entity: Entity) -> Entity_Id {
for &e, i in entities {
if .Is_Dead in e.flags {
e = entity
e.flags -= {.Is_Dead}
return Entity_Id(i)
}
}
index := len(entities)
append(&entities, entity)
return Entity_Id(index)
}
entity_kill :: proc(id: Entity_Id) -> bool {
if int(id) < len(entities) {
entities[int(id)].flags += {.Is_Dead}
return true
}
return false
}
You should not keep Entity pointers across calls of
entity_create
. If the array is full and a reallocation is required, pointers will be invalidated.
These are almost the simplest versions I have used across various projects.
There may be a problem, though, depending on your game’s design.
For example:
- Given two Entities A and B
- B is following A
- A gets killed
- C gets created in A’s slot
- B will now incorrectly follow C
To counter this issue, we can introduce the concept of a “generation” to the entities.
Entity :: struct {
generation: int,
// other fields remain unchanged
}
Now when you assign an Entity to follow another, you also include the generation.
Something like this, (just one way of many)
Follow :: struct {
id: Entity_Id,
generation: int,
}
following: map[Entity_Id]Follow
Then in your update code, you’d make sure the generation matches:
if follow, ok := following[id]; ok {
other := entity_get(other_id)
if other.generation == follow.generation {
// follow code goes here
} else {
delete_key(id)
}
}
The generation is set when an entity slot is reused:
entity_create :: proc(entity: Entity) -> Entity_Id {
for &e, i in entities {
if .Is_Dead in e.flags {
gen := e.generation + 1 // Save generation before overwriting
e = entity
e.flags -= {.Is_Dead}
e.generation = gen // Set to new generation
return Entity_Id(i)
}
}
index := len(entities)
append(&entities, entity)
return Entity_Id(index)
}
Here’s the rest of a very simple program I wrote that uses the first style:
entity_update :: proc(dt: f32) {
for &e in entities {
if .Is_Dead in e.flags do continue
e.pos += e.vel * dt
}
}
main :: proc() {
id := entity_create({pos = 100, vel = 200})
dt :: 1.0 / 60.0
for i in 0 ..< 4 {
entity_update(dt)
fmt.println(entity_get(id).pos)
}
}
It prints out:
[103.333336, 103.333336]
[106.66667, 106.66667]
[110.000008, 110.000008]
[113.33334, 113.33334]
Alright, that’s it!
Those are my two simple entity systems to get you going without having to create complex ECS queries.
If you enjoyed this post, please consider joining the mailing list. Thank you for reading!