The first step to the project is to implement the ECS architecture. Everything is adapted from the lecture series.
Summary: ECS
To put it simply, ECS is an architecture to organise data. In this implementation, a component is just a data store; an entity is a collection of components; a system will describe the interaction with the components and entities. A better explanation and illustration can be found in the lecture series. This is how I understand it.
Implementation
An entity is implemented as such in entity.h
:
typedef struct Entity
{
unsigned long m_id; // Unique id for the entity
EntityTag_t m_tag; // Entity tag for quick reference
bool m_alive;
struct sc_map_64 components; // Component map
}Entity_t
It is expected that an entity can only have at most one of any given components. Thus, a map is used to keep track of it. Each component is given a tag (implemented as enum) to identify it. One commonly used component is the Bounding Box component (will refer to it as 'bbox'). This is implemented in components.h
.
#define N_COMPONENTS 1
enum ComponentEnum
{
CBBOX_COMP_T,
};
typedef enum ComponentEnum ComponentEnum_t;
typedef struct _CBBox_t
{
int x;
int y;
int width;
int height;
}CBBox_t;
The components header file will be updated as more components are needed.
Entity Manager
Following the lecture series, the entity manager is implemented. As C is used, typical OO-style calls cannot be used. There is also no concept of public/private/friend/ members as in C++.
The entity manager struct is implemented in entManager.h
:
typedef struct EntityManager
{
// All fields are Read-Only
struct sc_map_64v entities; // id : entity
struct sc_map_64v entities_map[N_TAGS]; // [{id: ent}]
struct sc_map_64v component_map[N_COMPONENTS]; // [{id: comp}, ...]
struct sc_queue_uint to_add;
struct sc_queue_uint to_remove;
}EntityManager_t;
The first three maps are to quickly accesses the respective entities and components.
- First map is general map from entity id to the entity pointer.
- Second map is a tag-specific map from entity id to the entity pointer. This is used to quickly select a group of related entities
- Third map is a component-specific map from component id to the component pointer. This is used to quickly select a group of components, independent of the entity having them.
Due to the implementation of the data structure, it can only handle void pointer. Although it is possible to implement a map to a struct pointer, I didn't really bother. Therefore, the pointer obtained has to be casted according. For safer development, I should look into implementing a map to struct instead.
The to_add
and to_remove
fields are to handle iterator invalidation. This is explained in the lecture series. This arises when the data structure is modified while iterating through it. When adding or removing an entity, they are not immediately added to the map but is added to the respective fields. After that, the entity manager will be updated to properly include the newly added entity and drop the removed entity. Due to this design, this update procedure has to be explicitly called.
Memory Pool
In a game, it is expected than entities can be created and removed at any time, thus it makes sense to use dynamic allocation for them (using malloc/free). However, it does have performance drawback. Particularly, I am not too fond of the slower call speed and memory fragmentation that can happen due to this.
One way to solve this is to implement a memory pool for the entities and components. This way, a new entity will merely draw from the pool, and a removed entity will just return to the pool. No new allocation needed. This is covered by the lecture series as well, though it is much later down the series.
This is actually a premature optimisation, which is bad. However, I thought it would be fun to implement a memory pool. That's why I did it.
Note: this does not get rid of dynamic allocation usage. The library SC will make use of it for obvious reasons.
The mempool struct can be seen in mempool.c
:
typedef struct MemPool
{
void * const buffer;
const unsigned long max_size;
const unsigned long elem_size;
bool * use_list;
struct sc_queue_uint free_list;
}MemPool_t;
The first three fields are generic fields about the memory pool. The use_list
and the free_list
are used to keep track of which elements are free and in use.
A queue is used for the free list so that each element can be used evenly. A boolean array is used to keep track of whether an element is in use. It's a bit easier on the mind doing this way although it is not as memory efficient.
For this implementation, the buffer is statically allocated. This imposes a hard limit on the number of entities and components in the game.