eco2d: creature life simulation
Hey there,
In this article, we detail how to design a new feature in eco2d effortlessly.
Let's start with the idea: I envision a creature seeking food to satisfy its hunger; once well-fed, it seeks a companion to reproduce.
This implies we would need to design an item our creature would consume. It would also be nice to have some form of freeform movement implemented for the creatures to roam around. Finally, to balance the world out, creatures could die either of hunger or old age.
Data
Let's start with data:
typedef struct {
int16_t hunger_satisfied;
int16_t mating_satisfied;
int16_t life_remaining;
} Creature;
typedef struct { char _unused; } SeeksFood;
typedef struct { char _unused; } SeeksCompanion;
We declare one component that couples the 2 distinct features together and 2 additional tags we will use to process other logic when needed.
We would need to implement 1 system to keep track of creatures' needs and tag them, then 2 subsequent systems that will carry on with the tasks given. We could also set up a system that will make creatures roam around if they don't feel like doing anything. But let's take our time and satisfy some asset requirements.
Asset database
We add a new asset called ASSET_CREATURE
and its food we'd call ASSET_CREATURE_FOOD
.
Both will re-use our existing texture for other assets to save time. (protip: always prepare your art before embarking on an adventure)
We can assign textures to assets in the gen/texgen_fallback.c
file.
Item database
We add ASSET_CREATURE
as an entity spawning item (ITEM_ENT) and food as a held item (ITEM_HOLD)
Entity prefab
Since our creature is made of a couple of components, we need to create a prefab to spawn it into our world.
We implement creature_spawn
and creature_despawn
, then hook it up in our entity spawner. BEWARE API DESIGN STILL IN REVIEW!
(detail: We classify our creature as EKIND_DEMO_NPC to re-use existing rendering logic on client side)
Systems
We're done with all the setup, and with all the data tied up, we're ready to start implementing our creature!
CreatureCheckNeeds
Ensures our creature is not hungry. When well-fed and in mood, it also sets out to seek a similar companion to mate with.
(detail: We register this as a ticked system, 20 ticks represent 1 second in real-time.)
void CreatureCheckNeeds(ecs_iter_t *it) {
Creature *c = ecs_field(it, Creature, 1);
for (int i = 0; i < it->count; i++) {
// check hunger
if (c[i].hunger_satisfied < 1) {
ecs_add(it->world, it->entities[i], SeeksFood);
}
// check mating
if (c[i].mating_satisfied < 1) {
ecs_add(it->world, it->entities[i], SeeksCompanion);
}
// die of an old age
if (c[i].life_remaining < 1) {
entity_despawn(it->entities[i]);
continue;
}
// tick down needs
TICK_VAR(c[i].hunger_satisfied);
TICK_VAR(c[i].mating_satisfied);
TICK_VAR(c[i].life_remaining);
}
}
TICK_VAR ensures our ticking value does not drop below zero.
CreatureSeekFood
Finds the closest food item and moves towards it. When close enough, it consumes it, sating the hunger need. If no food is found, creatures die.
void CreatureSeekFood(ecs_iter_t *it) {
Creature *c = ecs_field(it, Creature, 1);
Position *p = ecs_field(it, Position, 2);
Velocity *v = ecs_field(it, Velocity, 3);
for (int i = 0; i < it->count; i++) {
size_t ents_count;
uint64_t *ents = world_chunk_query_entities(it->entities[i], &ents_count, 2);
float closest_ent_dist = ZPL_F32_MAX;
uint64_t closest_ent = 0;
Position *p2 = 0;
// find the closest item of kind ASSET_CREATURE_FOOD
for (size_t j = 0; j < ents_count; j++) {
Item *drop = 0;
uint64_t ent_id = ents[j];
if ((drop = ecs_get_mut_if_ex(it->world, ent_id, Item))) {
if (drop->kind != ASSET_CREATURE_FOOD)
continue;
p2 = ecs_get_mut_ex(it->world, ent_id, Position);
float dx = p2->x - p[i].x;
float dy = p2->y - p[i].y;
float range = (dx*dx + dy*dy);
// item is close enough, eat it!
if (range < CREATURE_INTERACT_RANGE) {
drop->quantity--;
if (drop->quantity == 0)
item_despawn(ent_id);
c[i].hunger_satisfied = CREATURE_FOOD_SATISFY_FOR;
ecs_remove(it->world, it->entities[i], SeeksFood);
}
else if (range < closest_ent_dist)
closest_ent = ent_id;
}
}
// drift towards the item
if (closest_ent) {
v[i].x = dx * CREATURE_SEEK_MATE_MOVEMENT_SPEED;
v[i].y = dy * CREATURE_SEEK_MATE_MOVEMENT_SPEED;
} else {
// die if no food is left
entity_despawn(it->entities[i]);
continue;
}
}
}
CreatureSeekCompanion
Seeks out a companion who also searches for someone to mate with.
void CreatureSeekCompanion(ecs_iter_t *it) {
Creature *c = ecs_field(it, Creature, 1);
Position *p = ecs_field(it, Position, 2);
Velocity *v = ecs_field(it, Velocity, 3);
for (int i = 0; i < it->count; i++) {
size_t ents_count;
uint64_t *ents = world_chunk_query_entities(it->entities[i], &ents_count, 2);
float closest_ent_dist = ZPL_F32_MAX;
uint64_t closest_ent = 0;
Position *p2 = 0;
// find the closest entity that also seeks a companion
for (size_t j = 0; j < ents_count; j++) {
uint64_t ent_id = ents[j];
if (ecs_get_if(it->world, ent_id, SeeksCompanion)) {
p2 = ecs_get_mut_ex(it->world, ent_id, Position);
float dx = p2->x - p[i].x;
float dy = p2->y - p[i].y;
float range = (dx*dx + dy*dy);
// creature is close enough, mate them!
if (range < CREATURE_INTERACT_RANGE) {
// remove the need
c[i].mating_satisfied = CREATURE_MATING_SATISFY_FOR;
ecs_remove(it->world, it->entities[i], SeeksCompanion);
ecs_remove(it->world, ent_id, SeeksCompanion);
// spawn a new creature
uint64_t ch = entity_spawn_id(ASSET_CREATURE);
entity_set_position(ch, p[i].x, p[i].y);
}
else if (range < closest_ent_dist)
closest_ent = ent_id;
}
}
// drift towards the creature
if (closest_ent) {
v[i].x = dx * CREATURE_SEEK_MATE_MOVEMENT_SPEED;
v[i].y = dy * CREATURE_SEEK_MATE_MOVEMENT_SPEED;
entity_wake(it->entities[i]);
} else {
// no companion is found, let's try again later.
c[i].mating_satisfied = CREATURE_MATING_SATISFY_FOR;
ecs_remove(it->world, it->entities[i], SeeksCompanion);
}
}
}
CreatureRoamAround
Moves aimlessly when there's nothing else to do.
void CreatureRoamAround(ecs_iter_t *it) {
Velocity *v = ecs_field(it, Velocity, 1);
for (int i = 0; i < it->count; i++) {
float d = zpl_quake_rsqrt(v[i].x*v[i].x + v[i].y*v[i].y);
v[i].x += (v[i].x*d*CREATURE_SEEK_ROAM_MOVEMENT_SPEED*safe_dt(it) + zpl_cos(zpl_to_radians((float)(rand()%360)))*game_rules.demo_npc_steer_speed*safe_dt(it));
v[i].y += (v[i].y*d*CREATURE_SEEK_ROAM_MOVEMENT_SPEED*safe_dt(it) + zpl_sin(zpl_to_radians((float)(rand()%360)))*game_rules.demo_npc_steer_speed*safe_dt(it));
entity_wake(it->entities[i]);
}
}
Register our systems
ECS_SYSTEM_TICKED(ecs, CreatureCheckNeeds, EcsPostUpdate, components.Creature);
ECS_SYSTEM_TICKED(ecs, CreatureSeekFood, EcsPostUpdate, components.Creature, components.Position, components.Velocity, components.SeeksFood, !components.SeeksCompanion);
ECS_SYSTEM_TICKED(ecs, CreatureSeekCompanion, EcsPostUpdate, components.Creature, components.Position, components.Velocity, components.SeeksCompanion, !components.SeeksFood);
ECS_SYSTEM(ecs, CreatureRoamAround, EcsPostUpdate, components.Velocity, components.Creature, !components.SeeksCompanion, !components.SeeksFood);
Done!
With the logic implemented, our creatures now have a purpose in life and act based on their needs.
What to improve
I want to discuss what could be improved both on this feature and the library itself.
Prefabs
These require you to implement the spawn/despawn method per each prefab envisioned. Initially, this worked out well since we'd only spawn entities in specific places. Over time, however, we needed to generate a prefab based on asset id, and this is where this setup became cumbersome, as we needed to maintain a set of prefab files just to keep spawn methods somewhere.
We should put some effort into ensuring that prefabs are designed in one place and without the unnecessary bloat that currently comes with them.
Entity queries
At the moment, we query for entities in each frame per each creature as it seeks out its food or companion. World queries are costly and should be used sparingly. Instead, we should cache the closest target and move towards it in a separate system.
Managing creature states
Currently, our creature has 3 states: free roam, seek food, and a companion.
With the current amount of systems, this is easy to maintain, but if we were to grow that list, the conditions under which our systems run would soon get very messy.
We could avoid these complications by relying on yet another tag that would tell us if we already perform any task, then only letting the system run if the job we're supposed to accomplish is locked to the state we seek.
That's it!
Yup, that's the whole article. Thanks for reading this! I'm looking forward to another adventure!
P.S.
The code is for illustration only, copied then tweaked but eventually left with some errors. I didn't catch those in time, so I'd recommend checking out the actual source code to see how it's all implemented.
https://github.com/zpl-c/eco2d/commit/1c68c730280a1997687cdfa12a98f807a72114d8