Every enemy in a game needs to move, take damage, and maybe attack. The obvious way to model that in C++ or C# is a class hierarchy: a base Enemy class, and subclasses that add behavior. That works fine until a designer asks for an enemy that flies and swims. This chapter starts with why that innocent request breaks a deep inheritance hierarchy, walks through composition as the first fix, shows where Unity's own component system (GameObject + MonoBehaviour) still runs into trouble at large scale, and ends with the data-oriented answer studios actually reach for: the Entity Component System (ECS). You will write a small working ECS yourself, in C++, before seeing Unity's production version, DOTS / Entities.
Same shape as always: small runnable code, its real output or a worked trace, then a plain explanation. This chapter leans on Chapter 3's memory-layout lessons — cache lines, Array-of-Structs vs Struct-of-Arrays — so it helps to have that chapter fresh in mind.
Start with the natural first design for enemies in a game. A base class holds the data every enemy needs, and subclasses add a specific movement ability:
#include <cstdio>
class Enemy {
public:
virtual void takeDamage(int amount) { health -= amount; }
virtual ~Enemy() = default;
protected:
float x = 0.0f, y = 0.0f, z = 0.0f;
int health = 100;
};
class FlyingEnemy : public Enemy {
public:
void fly(float dt) { y += flySpeed * dt; }
protected:
float flySpeed = 5.0f;
};
class SwimmingEnemy : public Enemy {
public:
void swim(float dt) { y -= swimSpeed * dt; }
protected:
float swimSpeed = 3.0f;
};
This is a worked trace, not a program with printed output. So far it looks reasonable: a bat is a FlyingEnemy, a fish is a SwimmingEnemy. Then the designer asks for a dragonfly-like enemy that flies over the water and dives in to swim after the player. It needs both abilities. The obvious move in C++ is multiple inheritance:
class FlyingSwimmingEnemy : public FlyingEnemy, public SwimmingEnemy {
public:
void update(float dt) {
fly(dt);
swim(dt);
}
};
This compiles, but it creates the classic diamond problem: FlyingSwimmingEnemy inherits from two classes that each separately inherit from Enemy, so — unless you add the keyword virtual to both inheritance lines — the object ends up containing two separate copies of Enemy's data (two x, two y, two health). Calling takeDamage() only ever reduces one of the two copies, so the enemy silently keeps some of its old health forever. That is not a syntax error — it compiles and runs, and the bug shows up as "this enemy takes twice the hits to kill," discovered during playtesting.
C++ lets you fix the duplication with virtual inheritance (class FlyingEnemy : public virtual Enemy), which forces both paths to share a single Enemy sub-object. That removes the duplicate-data bug, but it does not remove the deeper problem: the hierarchy does not scale. Add a BurrowingEnemy and a WalkingEnemy, and a designer asks for a burrowing-flying enemy, a walking-swimming enemy, a flying-swimming-burrowing enemy... Every new combination either needs a new class written by hand, or another multiple-inheritance diamond to untangle. With N independent abilities, the number of possible combinations grows toward 2^N — a combinatorial explosion the class hierarchy was never designed to hold.
The fix has a name: composition over inheritance. Instead of asking "what is this enemy a kind of?" (an is-a relationship, which is what inheritance models), ask "what abilities does this enemy have?" (a has-a relationship). Give each ability its own small, independent struct or class, and let an enemy hold whichever ones it needs — as plain member data, not as base classes.
#include <cstdio>
struct FlyMovement {
float speed = 5.0f;
void update(float dt, float& y) { y += speed * dt; }
};
struct SwimMovement {
float speed = 3.0f;
void update(float dt, float& y) { y -= speed * dt; }
};
class Enemy {
public:
void update(float dt) {
if (flyMovement) flyMovement->update(dt, y);
if (swimMovement) swimMovement->update(dt, y);
}
float x = 0.0f, y = 0.0f, z = 0.0f;
int health = 100;
FlyMovement* flyMovement = nullptr; // nullptr = "does not have this ability"
SwimMovement* swimMovement = nullptr;
};
int main() {
FlyMovement fly;
SwimMovement swim;
Enemy dragonfly;
dragonfly.flyMovement = &fly; // has both abilities - no new class needed
dragonfly.swimMovement = &swim;
Enemy bat;
bat.flyMovement = &fly; // has only one ability
dragonfly.update(1.0f);
bat.update(1.0f);
printf("dragonfly.y = %.1f\n", dragonfly.y);
printf("bat.y = %.1f\n", bat.y);
}
Output:
dragonfly.y = 2.0
bat.y = 5.0
The dragonfly's y moved by +5.0 - 3.0 = 2.0 — both abilities ran, added on top of each other, with no diamond and no duplicated health. There is exactly one Enemy class. A flying-swimming-burrowing enemy is just an Enemy with three pointers set instead of one class hand-written for that exact combination. This is the same idea labeled the "has-a" principle in basic OOP teaching, just applied deliberately to solve the combinatorial explosion from section 1: pick abilities off a shelf and attach them, instead of writing a new class for every combination.
Unity does not make you build composition by hand — it is the engine's core design. A GameObject by itself is nearly empty: it is little more than a name, a Transform (position, rotation, scale), and a list of components attached to it. Every ability — rendering a mesh, playing a sound, colliding with the world, or running your own gameplay code — is a separate component attached to that list. Your own gameplay logic is a MonoBehaviour subclass, which is itself just another kind of component.
using UnityEngine;
public class FlyMovement : MonoBehaviour {
public float speed = 5f;
void Update() {
transform.position += Vector3.up * speed * Time.deltaTime;
}
}
public class SwimMovement : MonoBehaviour {
public float speed = 3f;
void Update() {
transform.position += Vector3.down * speed * Time.deltaTime;
}
}
To make the dragonfly enemy, you do not write a new class at all: drag both scripts onto the same GameObject in the Inspector (or call gameObject.AddComponent<FlyMovement>() and AddComponent<SwimMovement>() from code). Unity calls Update() on every attached MonoBehaviour every frame, so both scripts run independently on the same object. A bat just gets the one script it needs. This is exactly the composition idea from section 2 — GameObject plays the role of the "shelf" of pointers, except Unity's editor lets a designer attach components visually, with no code at all.
Components talk to each other with GetComponent<T>(), which searches the GameObject's component list for one of type T:
using UnityEngine;
public class Health : MonoBehaviour {
public int hp = 100;
}
public class DamageOnTouch : MonoBehaviour {
public int amount = 10;
void OnCollisionEnter(Collision other) {
Health h = other.gameObject.GetComponent<Health>();
if (h != null) {
h.hp -= amount;
}
}
}
This has no printed output by itself — it is triggered by a physics collision. Notice the pattern: no inheritance chain between Health and DamageOnTouch at all. Any GameObject that happens to have a Health component can be damaged; one that does not simply gets null back and nothing happens. This is Unity's component system solving exactly the problem section 1 raised, and it is why real Unity projects almost never build deep custom class hierarchies of MonoBehaviour — the engine already gives you composition for free.
For dozens or hundreds of enemies, GameObject + MonoBehaviour works great and nothing in this chapter argues against it. The trouble starts at large scale — tens of thousands of simple objects updated every frame: bullets in a bullet-hell shooter, individual boids in a flock, units in a large RTS battle. Two costs, invisible at small scale, start to dominate:
GameObject and every MonoBehaviour is a separate managed (garbage-collected) object on the heap. Ten thousand enemies means at least ten thousand separate heap allocations, each living at whatever address the memory manager happened to hand out — scattered, not contiguous. That is the pointer-chasing, cache-unfriendly layout from Chapter 3, except now it is whole objects, not just struct fields.Update() on every MonoBehaviour is not one tight loop over one array. It is Unity's engine walking its own internal list of components and invoking a method on each one, wherever in memory that object happens to live. Each call can touch a completely different, cold region of RAM.Recall from Chapter 3.1 and 3.2: the CPU pulls in a whole 64-byte cache line at a time, and it is fast only when the next thing you touch was already dragged in with the last cache line — that is locality. Ten thousand MonoBehaviours scattered across the managed heap is close to the worst possible layout for locality: nearly every Update() call is a cache miss, and the CPU stalls waiting for RAM. None of this is a bug in Unity — it is the direct cost of what makes GameObject/MonoBehaviour so convenient: independent objects, added and removed freely, each fully self-contained. That convenience and a cache-friendly packed layout pull in opposite directions, and at tens of thousands of objects the layout starts to win the argument.
MonoBehaviour — it is a reason to know when the object count gets large enough for it to matter. Section 11 comes back to this with concrete guidance.Entity Component System (ECS) throws out the object entirely and asks three separate questions instead of one. It gives each question its own, deliberately narrow, answer:
Position component is just { x, y, z } and nothing else — it has no update() function of its own.Compare this to GameObject + MonoBehaviour: there, a component bundles its own data and its own Update() logic together, and lives as its own heap object. In ECS, data (components) and logic (systems) are pulled completely apart, and an entity is nothing more than a number that says "these particular component values belong together." A game world in ECS looks like a table:
A movement system is a function that only cares about entities in the "Position" and "Velocity" columns — it would touch entities 0, 1, and 3, and skip entity 2 entirely, without ever knowing or caring that entity 2 has a Health value. A damage system only cares about the "Health" column — entities 0, 2, 3. No entity's class needs to change to support a new combination; you simply attach or remove component values from an entity, and whichever systems match that entity's new component set start or stop touching it automatically. This is composition taken all the way to its logical end: not just "has-a" instead of "is-a", but data and logic fully separated, and — as the next section shows — arranged in memory specifically for speed.
The table in section 5 is not just a teaching diagram — it is close to how ECS actually stores data. Instead of scattering whole entity objects across the heap the way GameObjects do, ECS keeps one tightly packed array per component type. This is exactly the Struct-of-Arrays (SoA) layout from Chapter 3.2, applied to a whole game world instead of just a particle system.
A movement system that only reads Position and Velocity streams through two small, fully-packed arrays, back to back — every byte it loads from RAM is a byte it actually uses. It never touches the Health array at all, and it never wastes a cache line dragging in unrelated fields the way an AoS Enemy struct would. Recall the concrete numbers from Chapter 3.2: for a struct with 7 fields where a loop only needs 1 of them, AoS pulls in roughly 7 times more bytes than it needs. ECS's packed component arrays get that same win, but for an entire game world's worth of entities instead of one particle struct — and it is also why a movement system in ECS can use SIMD instructions directly on the Position array, the same way Chapter 3.2 showed SIMD wanting contiguous floats to load in one shot.
Time to build a small, real ECS. An entity is just a number:
#include <cstdint>
using Entity = std::uint32_t;
Components are plain structs with no methods:
struct Position { float x, y, z; };
struct Velocity { float x, y, z; };
The interesting part is storage: a ComponentArray<T> that keeps every value of type T in one tightly packed std::vector, and separately remembers which entity owns which slot:
#include <vector>
#include <unordered_map>
template <typename T>
class ComponentArray {
public:
void add(Entity e, T component) {
std::size_t index = data.size();
entityToIndex[e] = index;
indexToEntity.push_back(e);
data.push_back(component);
}
bool has(Entity e) const {
return entityToIndex.find(e) != entityToIndex.end();
}
T& get(Entity e) {
return data[entityToIndex[e]];
}
std::vector<T>& all() { return data; }
std::vector<Entity>& entities() { return indexToEntity; }
private:
std::vector<T> data; // tightly packed values
std::vector<Entity> indexToEntity; // slot index -> owning entity
std::unordered_map<Entity, std::size_t> entityToIndex; // entity -> slot index
};
data is the packed array from section 6 — nothing but Position or Velocity values, back to back, no gaps. entityToIndex and indexToEntity are the bookkeeping that lets you go from "which entity" to "which slot" and back, without that bookkeeping ever touching the packed data itself. A tiny World ties everything together:
struct World {
Entity nextEntity = 0;
ComponentArray<Position> positions;
ComponentArray<Velocity> velocities;
Entity createEntity() { return nextEntity++; }
};
Now create a small world and prove the array really is packed:
#include <cstdio>
int main() {
World world;
Entity e0 = world.createEntity();
world.positions.add(e0, {0.0f, 0.0f, 0.0f});
world.velocities.add(e0, {1.0f, 0.0f, 0.0f});
Entity e1 = world.createEntity();
world.positions.add(e1, {5.0f, 0.0f, 0.0f});
world.velocities.add(e1, {0.0f, 1.0f, 0.0f});
Entity e2 = world.createEntity(); // e2 has no Velocity - it does not move
world.positions.add(e2, {9.0f, 0.0f, 0.0f});
Position* p0 = &world.positions.get(e0);
Position* p1 = &world.positions.get(e1);
printf("sizeof(Position) = %zu\n", sizeof(Position));
printf("p1 - p0 = %td bytes\n", (char*)p1 - (char*)p0);
printf("positions stored: %zu\n", world.positions.all().size());
printf("velocities stored: %zu\n", world.velocities.all().size());
}
Output:
sizeof(Position) = 12
p1 - p0 = 12 bytes
positions stored: 3
velocities stored: 2
p1 - p0 = 12 bytes, exactly sizeof(Position) — proof that e0's and e1's positions sit immediately next to each other in memory, with nothing in between. positions holds 3 values (all three entities have one) while velocities holds only 2 (e2 opted out) — each component type keeps its own independent packed array, exactly the SoA table from section 6, now backed by real, runnable code.
A system is a function that finds every entity matching a component set (a query) and updates it. The simplest query: pick the shortest of the arrays involved, iterate it, and check the others:
void movementSystem(World& world, float dt) {
for (Entity e : world.velocities.entities()) {
if (world.positions.has(e)) {
Position& pos = world.positions.get(e);
Velocity& vel = world.velocities.get(e);
pos.x += vel.x * dt;
pos.y += vel.y * dt;
pos.z += vel.z * dt;
}
}
}
Run it against the world from section 7:
int main() {
World world;
Entity e0 = world.createEntity();
world.positions.add(e0, {0.0f, 0.0f, 0.0f});
world.velocities.add(e0, {1.0f, 0.0f, 0.0f});
Entity e1 = world.createEntity();
world.positions.add(e1, {5.0f, 0.0f, 0.0f});
world.velocities.add(e1, {0.0f, 1.0f, 0.0f});
Entity e2 = world.createEntity();
world.positions.add(e2, {9.0f, 0.0f, 0.0f}); // no Velocity - untouched below
movementSystem(world, 1.0f);
movementSystem(world, 1.0f);
for (Entity e : world.positions.entities()) {
Position& p = world.positions.get(e);
printf("entity %u position = (%.1f, %.1f, %.1f)\n", e, p.x, p.y, p.z);
}
}
Output:
entity 0 position = (2.0, 0.0, 0.0)
entity 1 position = (5.0, 2.0, 0.0)
entity 2 position = (9.0, 0.0, 0.0)
Entity 0 moved by (1,0,0) twice, landing at x = 2.0. Entity 1 moved by (0,1,0) twice, landing at y = 2.0. Entity 2 never moved at all — movementSystem iterates velocities.entities(), and entity 2 was never added to velocities, so the system simply never sees it. No if statement anywhere asks "is this entity the kind of thing that moves?" — whether an entity moves is decided entirely by whether it has a Velocity component, checked once, not baked into a class hierarchy.
The if (world.positions.has(e)) line is a real cost: a hash-map lookup per entity, on top of the array walk. Production ECS engines avoid it with a signature — a small bitmask per entity where bit k is set if the entity has component type k. Checking "does this entity have both Position and Velocity" becomes one bitwise AND between two bitmasks instead of a hash lookup, and finding all matching entities becomes a fast intersection instead of a per-entity check. The tiny ECS here skips that only to keep the code short enough to read in one sitting.
The ComponentArray<T> from section 7 is one array per component type, shared by every entity that has it, regardless of what else those entities have. Production ECS engines (Unity DOTS among them) go one step further with an idea called an archetype: the exact set of component types an entity owns. Every entity with exactly {Position, Velocity} and nothing else belongs to one archetype; every entity with exactly {Position, Velocity, Health} belongs to a different archetype, even though they share two component types.
All entities of the same archetype are stored together in one contiguous block of memory called a chunk (commonly a fixed size like 16 KB), and inside a chunk, each component type still gets its own packed sub-array — SoA within the chunk. Iterating "all entities with Position and Velocity" no longer means walking one giant array and skip-checking each entity; it means visiting only the handful of chunks whose archetype includes both, and streaming straight through them with no per-entity branching at all.
When an entity gains or loses a component at runtime (say, a Health component gets added to an entity that only had {Position, Velocity}), its archetype changes, so the engine copies its component values out of the old chunk and into a chunk for the new archetype — a real cost, which is one reason production ECS code tends to set up an entity's full component set once, up front, rather than adding and removing components every frame. This chunk idea is the production-grade version of the packed arrays from section 7: same principle — pack data by type, iterate without branching — organized so an entire matching group of entities can be swept through in one contiguous pass instead of many small ones.
Unity's production ECS is called DOTS (Data-Oriented Technology Stack), built around a package called Entities. It is everything from sections 5-9, with a real engine, a compiler, and a scheduler behind it. Components are plain structs implementing IComponentData — no methods, exactly like section 5's rule:
using Unity.Entities;
using Unity.Mathematics;
public struct Position : IComponentData { public float3 Value; }
public struct Velocity : IComponentData { public float3 Value; }
A system is a struct implementing ISystem, and its OnUpdate is the equivalent of section 8's movementSystem function — except the query is written declaratively, and the engine does the iteration:
using Unity.Entities;
using Unity.Burst;
[BurstCompile]
public partial struct MovementSystem : ISystem {
[BurstCompile]
public void OnUpdate(ref SystemState state) {
float dt = SystemAPI.Time.DeltaTime;
foreach (var (pos, vel) in
SystemAPI.Query<RefRW<Position>, RefRO<Velocity>>()) {
pos.ValueRW.Value += vel.ValueRO.Value * dt;
}
}
}
SystemAPI.Query<RefRW<Position>, RefRO<Velocity>>() is a direct expression of "give me every entity that has both a Position and a Velocity" — the same query section 8 wrote by hand as a hash-map check, except DOTS finds it by jumping straight to the matching chunks from section 9, with no per-entity branching. RefRW means "read-write access to this component," RefRO means "read-only" — the engine uses that information to safely run multiple systems on multiple CPU cores at once through Unity's Job System, since it can prove in advance which systems only read a component (safe to run in parallel) versus which ones write to it.
[BurstCompile] tells Unity's Burst compiler to compile this exact method down to tightly optimized native machine code — including SIMD instructions operating on several entities' Position values at once, the same SIMD idea from Chapter 3.2, now generated automatically because the data is already laid out as packed arrays inside each chunk. This is the whole point of the chapter in one sentence: DOTS is not a different idea from the tiny C++ ECS in sections 7-9 — it is the same entities-components-systems idea, with a job scheduler and a specializing compiler built to exploit the cache-friendly layout that idea produces.
ECS is not a universal upgrade — it is a specialized tool with a real learning curve and a genuinely different debugging workflow (no Inspector drag-and-drop, no stepping into a familiar object's methods in a debugger the same way). Reaching for it by default, for a game with fifty enemies, adds complexity that buys nothing.
GameObject + MonoBehaviour for most gameplay code in most games: dozens to low thousands of objects, complex one-off behavior (a unique boss, a quest trigger, a UI panel), anything that benefits from the Inspector and prefab workflow designers rely on. This is the right default, and it is what most shipped Unity games use almost everywhere.Update() overhead or garbage-collector pauses from huge object counts are the actual bottleneck: tens of thousands of bullets in a bullet-hell game, large crowds, big RTS unit counts, particle-like simulations with real gameplay logic attached (not just visual particles, which the engine's particle system already handles without ECS).MonoBehaviours and drop into ECS only for the one or two systems that actually need tens of thousands of cheap, uniform objects updated every frame — a crowd simulation behind normal, individually-scripted named characters, for example.The practical rule echoes Chapter 3's allocator lesson: profile first, understand where the actual milliseconds are going, and only then reach for the specialized tool that fits the pattern you measured — never adopt ECS "because it's faster" without first checking whether the object count in your actual game is anywhere near where that speed would matter.
virtual.Transform, and a list of attached components.GameObject as a component, carrying both data and its own logic (e.g. Update()).Position { x, y, z }).Entities package.AmphibiousEnemy that both walk()s and swim()s. Explain, in your own words, what goes wrong if you write class AmphibiousEnemy : public WalkingEnemy, public SwimmingEnemy without virtual inheritance, then rewrite the whole thing using composition instead (no multiple inheritance at all).
class Enemy {
protected:
float x = 0.0f, y = 0.0f;
int health = 100;
};
class WalkingEnemy : public Enemy {
public:
void walk(float dt) { x += walkSpeed * dt; }
protected:
float walkSpeed = 2.0f;
};
class SwimmingEnemy : public Enemy {
public:
void swim(float dt) { y -= swimSpeed * dt; }
protected:
float swimSpeed = 3.0f;
};
Without virtual inheritance, AmphibiousEnemy : public WalkingEnemy, public SwimmingEnemy ends up with two separate Enemy sub-objects — one reached through the WalkingEnemy path, one through the SwimmingEnemy path — so two copies of x, y, and health. walk() updates the x inside the WalkingEnemy-path copy; anything reading x through the SwimmingEnemy path (or through a plain Enemy* that happens to point at the wrong sub-object) sees a stale, never-updated value. That is the diamond problem from section 1, playing out on a fresh example.
Composition sidesteps it completely — one Enemy class, movement abilities held as pointers instead of base classes:
#include <cstdio>
struct WalkMovement {
float speed = 2.0f;
void update(float dt, float& x) { x += speed * dt; }
};
struct SwimMovement {
float speed = 3.0f;
void update(float dt, float& y) { y -= speed * dt; }
};
class Enemy {
public:
void update(float dt) {
if (walkMovement) walkMovement->update(dt, x);
if (swimMovement) swimMovement->update(dt, y);
}
float x = 0.0f, y = 0.0f;
int health = 100;
WalkMovement* walkMovement = nullptr;
SwimMovement* swimMovement = nullptr;
};
int main() {
WalkMovement walk;
SwimMovement swim;
Enemy amphibious;
amphibious.walkMovement = &walk;
amphibious.swimMovement = &swim;
amphibious.update(1.0f);
printf("amphibious x=%.1f y=%.1f\n", amphibious.x, amphibious.y);
}
Output:
amphibious x=2.0 y=-3.0
There is exactly one Enemy class, exactly one health, and no diamond — AmphibiousEnemy never needed to exist as its own class at all.
ComponentArray<T> from section 7 has no way to remove an entity's component. Add a remove(Entity e) method. It must keep data tightly packed with no gaps — use the "swap-and-pop" trick: move the last element into the removed slot, then shrink the array by one, and fix up both index maps to match.
// Starting point: same ComponentArray<T> as section 7
// (data, indexToEntity, entityToIndex).
// TODO: add void remove(Entity e);
// It must not leave a gap in "data".
template <typename T>
class ComponentArray {
public:
void add(Entity e, T component) {
std::size_t index = data.size();
entityToIndex[e] = index;
indexToEntity.push_back(e);
data.push_back(component);
}
void remove(Entity e) {
std::size_t removedIndex = entityToIndex[e];
std::size_t lastIndex = data.size() - 1;
Entity lastEntity = indexToEntity[lastIndex];
data[removedIndex] = data[lastIndex]; // swap
indexToEntity[removedIndex] = lastEntity;
data.pop_back(); // and pop
indexToEntity.pop_back();
entityToIndex[lastEntity] = removedIndex; // fix the moved entity
entityToIndex.erase(e); // remove the deleted one
}
bool has(Entity e) const {
return entityToIndex.find(e) != entityToIndex.end();
}
T& get(Entity e) { return data[entityToIndex[e]]; }
std::vector<T>& all() { return data; }
std::vector<Entity>& entities() { return indexToEntity; }
private:
std::vector<T> data;
std::vector<Entity> indexToEntity;
std::unordered_map<Entity, std::size_t> entityToIndex;
};
int main() {
ComponentArray<Position> positions;
positions.add(0, {1.0f, 0.0f, 0.0f});
positions.add(1, {2.0f, 0.0f, 0.0f});
positions.add(2, {3.0f, 0.0f, 0.0f});
positions.remove(0); // removes entity 0, entity 2's data moves into slot 0
printf("count = %zu\n", positions.all().size());
printf("slot 0 now belongs to entity %u, x=%.1f\n",
positions.entities()[0], positions.all()[0].x);
printf("entity 0 still present: %s\n", positions.has(0) ? "yes" : "no");
}
Output:
count = 2
slot 0 now belongs to entity 2, x=3.0
entity 0 still present: no
Entity 2's Position (the last element) got copied into slot 0, where entity 0's used to be, and the array shrank by one — data stays fully packed with zero gaps, at the cost of entity 2 now living at a different index than before. Any code that cached a raw index into this array across a remove() call would break; going through get(Entity), which always re-looks-up the current index, is what makes this safe.
Health component and a ComponentArray<Health> healths to World. Write damageSystem(World& world, Entity target, int amount) that subtracts amount from target's health only if it has one, and if health drops to 0 or below, removes the entity's Position, Velocity, and Health components entirely (using remove() from Exercise 2) so no system will ever touch it again. Explain in one sentence why this system never has to look at the Position or Velocity arrays.
struct Health { int hp; };
// TODO: add ComponentArray<Health> healths; to World
// TODO: void damageSystem(World& world, Entity target, int amount);
struct Health { int hp; };
struct World {
Entity nextEntity = 0;
ComponentArray<Position> positions;
ComponentArray<Velocity> velocities;
ComponentArray<Health> healths;
Entity createEntity() { return nextEntity++; }
};
void damageSystem(World& world, Entity target, int amount) {
if (!world.healths.has(target)) {
return; // no Health - nothing to damage
}
Health& h = world.healths.get(target);
h.hp -= amount;
if (h.hp <= 0) {
if (world.positions.has(target)) world.positions.remove(target);
if (world.velocities.has(target)) world.velocities.remove(target);
world.healths.remove(target);
}
}
int main() {
World world;
Entity e0 = world.createEntity();
world.positions.add(e0, {0.0f, 0.0f, 0.0f});
world.velocities.add(e0, {1.0f, 0.0f, 0.0f});
world.healths.add(e0, {30});
damageSystem(world, e0, 20);
printf("after first hit, alive: %s, hp: %d\n",
world.healths.has(e0) ? "yes" : "no",
world.healths.has(e0) ? world.healths.get(e0).hp : 0);
damageSystem(world, e0, 20);
printf("after second hit, alive: %s\n", world.healths.has(e0) ? "yes" : "no");
printf("positions remaining: %zu\n", world.positions.all().size());
}
Output:
after first hit, alive: yes, hp: 10
after second hit, alive: no
positions remaining: 0
damageSystem only ever touches world.healths (and, on death, calls remove on the others) because health is the only value the query for this system needs to read or write — it never streams through positions or velocities at all, which is precisely the section 6 payoff: a system only pays for the component arrays it actually queries, never for arrays that happen to belong to the same entities but are irrelevant to this particular piece of logic.