Skip to content

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 struct of public fields with default values, so Lifetime{} is valid;
  • Doxygen comments on the struct and on each field: /// @brief, then /// @details if 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), no bool alone (use an empty marker struct such as AutoTrigger), 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 .cpp of the engine library, or it may see empty pools on macOS (see ECS design, known limits). A new header in public/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 the components.hpp row. 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 of changelogs/ and fill it in (pull request, branch, status Open, author, ticket, then Summary, Added, Changed, Commits, Notes). Mark there, and in CHANGELOG.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 inline function in a header (engine), uses tick.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.sh and doxygen pass.
  • [ ] docs/architecture.md, a changelog file and a CHANGELOG.md row 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