Add a component and a system¶
This guide adds a new behavior to the engine, step by step: a component that holds the data, a system that uses it every tick, the tests, the documentation and the pull request. Follow it in order; each step ends with something you can check. Read ECS design first if entities, components and views are new to you.
The example is a Lifetime: an entity created with a Lifetime of 0.5 seconds is
destroyed half a second later, whatever happens to it. It is what a muzzle flash, an
explosion or a short-lived power-up needs. The example is not part of the engine: follow
the guide with your own component, or with this one on a throwaway branch to learn.
| Step | Files |
|---|---|
| 1. Decide what goes where | — |
| 2. Declare the component | public/aegis2d/ecs/components.hpp |
| 3. Write the system | public/aegis2d/ecs/lifetime.hpp (new) |
| 4. Test it | tests/ecs/lifetime_test.cpp (new), tests/CMakeLists.txt |
| 5. Check format, lint and the API reference | — |
| 6. Document it | docs/architecture.md, changelogs/, CHANGELOG.md |
| 7. Commit and open the pull request | — |
Before you start, have a configured build (see Getting started); the
commands below use the server preset, whose build folder is build/server. Use
build/client if you configured the client preset. Create the branch from develop:
git switch develop && git pull
git switch --no-track -c feat/ecs-lifetime develop
1. Decide what goes where¶
| Question | Answer for Lifetime |
Rule |
|---|---|---|
| What is the data? | the seconds left before the entity is destroyed | a component: plain data, no logic |
| What happens every tick? | count the time down, destroy the entities at zero | a system: a function over the entities that own the component |
| Engine or game? | engine: any game needs short-lived entities | in the engine only if it is generic (no R-Type type, value or rule); otherwise in the game, see In a game instead |
| Who needs to know what happened? | the server, to tell the clients which entities are gone | the system fills a list the caller reads |
Keep the component as small as the behavior: one value here. If you need a second behavior, it is often a second component, not a second field.
2. Declare the component¶
Shared components live in public/aegis2d/ecs/components.hpp, inside
namespace aegis2d::ecs. Add yours next to the others, before NetworkId:
/// @brief Entity destroyed after a fixed time.
/// @details `ecs::expire_lifetimes()` counts `seconds` down every tick and destroys the
/// entity once it reaches zero, for effects and pickups that only last a moment.
struct Lifetime {
/// @brief Time left before the entity is destroyed, in seconds.
float seconds = 1.0F;
};
What every component follows (the reasons are in ECS design):
- a
structof public fields with default values, soLifetime{}is valid; - Doxygen comments on the struct and on each field:
/// @brief, then/// @detailsif needed. The API reference is generated from them, and every public declaration of the engine has one; - units in the comment: seconds, logical pixels, pixels per second;
- no pointer to another component (store an
Entity), noboolalone (use an empty marker struct such asAutoTrigger), no render, audio or SDL type: the server includes this header.
It can already be used, even with no system:
registry.emplace<ecs::Lifetime>(flash, 0.5F); // Lifetime{.seconds = 0.5F}
3. Write the system¶
A system is a function that takes the registry and the tick. Create
public/aegis2d/ecs/lifetime.hpp:
/// @file
/// @brief Lifetime: entities destroyed after a fixed time.
#pragma once
#include <aegis2d/ecs/components.hpp>
#include <aegis2d/ecs/entity.hpp>
#include <aegis2d/ecs/registry.hpp>
#include <aegis2d/ecs/system.hpp>
#include <vector>
namespace aegis2d::ecs {
/// @brief Counts every Lifetime down and destroys the entities whose time is up.
/// @details Each entity with a Lifetime loses `tick.delta()` seconds; once it has none
/// left, the entity is destroyed with all its components. Run it once per tick:
/// `systems.add("lifetime", [&expired](ecs::Registry& r, const ecs::Tick& t) {
/// ecs::expire_lifetimes(r, t, expired); });`. Defined in the header because it reads
/// components (see the known limits of the ECS design page).
/// @param registry World holding the entities.
/// @param tick Current tick; its delta is the time that passed.
/// @param expired Cleared, then filled with the destroyed entities, so the server can tell
/// the clients.
inline void expire_lifetimes(Registry& registry, const Tick& tick, std::vector<Entity>& expired) {
expired.clear();
registry.view<Lifetime>().each([&tick, &expired](Entity entity, Lifetime& lifetime) {
lifetime.seconds -= tick.delta();
if (lifetime.seconds <= 0.0F) {
expired.push_back(entity);
}
});
for (const Entity entity : expired) {
registry.destroy(entity);
}
}
} // namespace aegis2d::ecs
The choices to copy for your own system:
inline, in a header. Engine code that reads components is compiled in the caller's code, never in a.cppof the engine library, or it may see empty pools on macOS (see ECS design, known limits). A new header inpublic/aegis2d/ecs/needs no CMake change.view<...>().each()to visit the entities that own the components; ask for every component the system needs,view<Transform, Lifetime>()for two (see Iterating).tick.delta()for anything timed: the step in seconds, so the behavior is the same at 20, 60 or 144 ticks per second.- Collect, then change the world. The loop only collects the expired entities; they
are destroyed after it. Creating entities or adding components of the viewed types
inside
each()is not allowed. - An output list (
expired) instead of a return value: the caller keeps one vector for the whole game, so no allocation per tick, and reads what happened.
The game registers the system in its Scheduler, where the order matters: here after the
movement and the collisions, before the snapshot is sent.
std::vector<ecs::Entity> expired; // reused every tick
systems.add("lifetime", [&expired](ecs::Registry& world, const ecs::Tick& tick) {
ecs::expire_lifetimes(world, tick, expired);
for (const ecs::Entity entity : expired) { /* tell the clients */ }
});
4. Test it¶
Every new behavior comes with Catch2 tests in tests/. Create
tests/ecs/lifetime_test.cpp:
#include <aegis2d/core/clock.hpp>
#include <aegis2d/ecs/components.hpp>
#include <aegis2d/ecs/entity.hpp>
#include <aegis2d/ecs/lifetime.hpp>
#include <aegis2d/ecs/registry.hpp>
#include <aegis2d/ecs/system.hpp>
#include <catch2/catch_test_macros.hpp>
#include <type_traits>
#include <vector>
using aegis2d::core::step_from_rate;
using aegis2d::ecs::Entity;
using aegis2d::ecs::expire_lifetimes;
using aegis2d::ecs::Health;
using aegis2d::ecs::Lifetime;
using aegis2d::ecs::Registry;
using aegis2d::ecs::Scheduler;
using aegis2d::ecs::Tick;
namespace {
Entity short_lived(Registry& registry, float seconds) {
const Entity entity = registry.create();
registry.emplace<Lifetime>(entity, seconds);
registry.emplace<Health>(entity, 1);
return entity;
}
} // namespace
TEST_CASE("a lifetime is plain data that lasts a second by default", "[ecs][lifetime]") {
STATIC_CHECK(std::is_trivially_copyable_v<Lifetime>);
STATIC_CHECK(Lifetime{}.seconds == 1.0F);
}
TEST_CASE("an entity is destroyed once its lifetime is over", "[ecs][lifetime]") {
Registry registry;
const Entity flash = short_lived(registry, 0.5F);
const Tick tick{.step = step_from_rate(4)};
std::vector<Entity> expired;
expire_lifetimes(registry, tick, expired);
CHECK(registry.alive(flash));
CHECK(registry.get<Lifetime>(flash).seconds == 0.25F);
CHECK(expired.empty());
expire_lifetimes(registry, tick, expired);
CHECK(!registry.alive(flash));
CHECK(expired == std::vector{flash});
CHECK(registry.count<Health>() == 0);
}
TEST_CASE("only the expired entities are destroyed", "[ecs][lifetime]") {
Registry registry;
const Entity flash = short_lived(registry, 0.1F);
const Entity pickup = short_lived(registry, 5.0F);
const Entity wall = registry.create();
std::vector<Entity> expired;
expire_lifetimes(registry, Tick{.step = step_from_rate(5)}, expired);
CHECK(expired == std::vector{flash});
CHECK(registry.alive(pickup));
CHECK(registry.alive(wall));
}
TEST_CASE("a lifetime lasts the same time at any tick rate", "[ecs][lifetime]") {
for (const unsigned rate : {20U, 60U, 144U}) {
Registry registry;
const Entity flash = short_lived(registry, 0.5F);
Scheduler systems;
std::vector<Entity> expired;
systems.add("lifetime", [&expired](Registry& world, const Tick& tick) {
expire_lifetimes(world, tick, expired);
});
while (registry.alive(flash)) {
systems.update(registry, step_from_rate(rate));
}
CHECK(systems.ticks() >= rate / 2);
CHECK(systems.ticks() <= (rate / 2) + 1);
}
}
What to test, for any system: the defaults of the component, the normal case, what must not happen (other entities untouched), the edges (exactly at zero) and, for anything timed, the same result at several tick rates. Compare times with a margin of one tick: steps are whole nanoseconds, so 0.5 s is not always a whole number of ticks. Prefer durations that floats hold exactly (0.25 s ticks and a 0.5 s lifetime) when a test needs to land exactly on zero.
Add the file to the test executable in tests/CMakeLists.txt, after the other ECS tests:
ecs/waves_test.cpp
ecs/lifetime_test.cpp
Build and run the new tests, then all of them:
cmake --build build/server
build/server/tests/aegis2d_tests "[lifetime]"
ctest --test-dir build/server --output-on-failure
The first run must end with All tests passed (… assertions in 4 test cases). No test
cases matched means the file is not in tests/CMakeLists.txt.
5. Check format, lint and the API reference¶
CI runs the same checks; run them before pushing (install the tools once with
pip install -r tools/requirements.txt in the project .venv). New files must be known to
git first, because the scripts list the files with git ls-files:
git add public/aegis2d/ecs/lifetime.hpp tests/ecs/lifetime_test.cpp
tools/format.sh # rewrites the files in place
tools/lint.sh build/server # clang-tidy, warnings are errors
mkdir -p build && doxygen docs/Doxyfile # fails on a wrong @param
tools/lint.sh checks the .cpp files of the build and the engine headers they include,
so the new header is checked through its test.
6. Document it¶
Three places, in the same pull request:
docs/architecture.md, ECS module: add the new header to the table of headers (what it provides, in one row) and the component to thecomponents.hpprow. If the system belongs to the server's tick, add it to the scheduler example there.changelogs/YYYY-MM-DD-ecs-lifetime.md: copy the newest file ofchangelogs/and fill it in (pull request, branch, statusOpen, author, ticket, then Summary, Added, Changed, Commits, Notes). Mark there, and inCHANGELOG.md, the pull requests merged since the last update.CHANGELOG.md: one row at the top of the table, for your pull request.
Guess the pull request number from the last row of CHANGELOG.md and the open pull
requests on GitHub; fix it once the pull request is open if it was taken.
7. Commit and open the pull request¶
Three commits, in this order (see CONTRIBUTING):
git add public/aegis2d/ecs/components.hpp public/aegis2d/ecs/lifetime.hpp
git commit -m "feat(ecs): added entity lifetimes"
git add tests/CMakeLists.txt tests/ecs/lifetime_test.cpp
git commit -m "feat(tests): added lifetime tests"
git add docs CHANGELOG.md changelogs
git commit -m "feat(docs): documented lifetimes and updated changelogs"
git push -u origin feat/ecs-lifetime
Open the pull request against develop, with the ticket title as its title, fill in the
template (Closes #<ticket>, how it was tested, the checklist) and ask a teammate for a
review. Merge it with Create a merge commit once CI is green and it is approved.
In a game instead¶
A component or a system that only makes sense in one game (a Bydo's attack pattern, a R-Type power-up) lives in the game repository, with the same steps and these differences:
| Engine | Game (project-rtype) | |
|---|---|---|
| Component | public/aegis2d/ecs/components.hpp |
a header in game/include/rtype/ (a new components.hpp for the first one) |
| System | inline function in a new engine header |
a function of the game's static library rtype_game, declared in game/include/rtype/ and defined in a .cpp of game/ listed in game/CMakeLists.txt |
| Engine math | Vec2, Vec4 (GLM) |
aegis2d::Vec2, never glm:: directly |
| Tests | tests/ecs/, added to tests/CMakeLists.txt |
the game's tests/ |
| Documentation | docs/architecture.md and the engine changelogs |
the game's docs and changelogs |
The game's Add an enemy type guide walks through this for a new enemy.
Checklist¶
- [ ] The component is plain data with defaults and a Doxygen comment on it and on each field.
- [ ] The system is an
inlinefunction in a header (engine), usestick.delta()for time, and changes the world after its loop. - [ ] Tests cover the defaults, the normal case, what must not change, an edge and
several tick rates; the file is in
tests/CMakeLists.txt. - [ ]
cmake --build,ctest,tools/format.sh,tools/lint.shanddoxygenpass. - [ ]
docs/architecture.md, a changelog file and aCHANGELOG.mdrow are updated. - [ ] Three commits: code, tests, docs and changelogs.
When it goes wrong¶
| Symptom | Cause |
|---|---|
emplace throws std::logic_error |
the entity already has that component: use emplace_or_replace |
get throws std::out_of_range |
the entity does not have the component (or the handle is stale): use try_get or has first |
A bool or a type with a throwing move does not compile as a component |
pools require a noexcept move assignment and reject bool; use a marker struct |
On macOS, a system in an engine .cpp sees no entity |
the typeid limit: move the function to a header as inline |
| A system crashes or skips entities after creating some | entities or components of the viewed types were added inside each(): collect, then add after the loop |
doxygen fails with argument 'x' of command @param is not found or parameter 'x' is not documented |
the @param lines of a function do not match its parameters: one per parameter, same names |
| Different results at 60 and 144 Hz | a duration counted in ticks instead of tick.delta() seconds |