Skip to content

Plain C++ Structs and Loops: A Simulation Before ECS

Start with one struct per body and a loop over the world. This gives us a readable C++ baseline, an explicit simulation contract, and tests that later designs must preserve.

Simulation Contract

Input is a list of finite positions and velocities, three independent boolean properties, and finite remaining lifetimes. The supplied fixture uses small values; handling overflow or validating arbitrary external input is outside this first example.

  • Each step requires a finite, positive dt.
  • Move each live moving body by velocity * dt.
  • Decrease an expiring body's lifetime by dt.
  • Remove bodies whose remaining lifetime is zero or negative.
  • A body with an expired lifetime starts absent.
  • Rendering returns visible survivors in their existing order.
  • Reject invalid dt before changing any state.

A body that expires during a step still participates in that step's movement. There is no sub-step collision handling or interpolation at its exact death time. That is a specification choice, not a physical simulation guarantee.

Build the Plain C++ Simulation

On Raspberry Pi OS, install build tools if needed:

sudo apt update
sudo apt install build-essential cmake git

Clone the source repository into a new directory:

1
2
3
4
5
6
7
git clone https://github.com/ohyaan/blog-raspberry-pi-source.git
cd blog-raspberry-pi-source
cmake -S docs/data-oriented-design/examples -B /tmp/pi-design-debug \
  -DCMAKE_BUILD_TYPE=Debug
cmake --build /tmp/pi-design-debug
ctest --test-dir /tmp/pi-design-debug --output-on-failure
/tmp/pi-design-debug/simulation_demo

Use another build directory if that path already belongs to a different project. The code requires C++17 and uses no third-party libraries.

You can also download the current project's sixteen files into one local folder: CMakeLists.txt, simulation.hpp, demo.cpp, tests.cpp, entity_identity.hpp, identity_demo.cpp, identity_tests.cpp, minimal_ecs.hpp, ecs_demo.cpp, ecs_tests.cpp, generational_ecs.hpp, generation_demo.cpp, generation_tests.cpp, deferred_ecs.hpp, deferred_demo.cpp, and deferred_tests.cpp. The identity and ECS files supply the separate Chapter 4–7 targets; the original simulation sources and demonstration remain unchanged. Then point cmake -S at that folder.

Expected demonstration output from the supplied fixture:

1
2
3
4
after 0.25s: alive=3 visible=2
after 0.50s: alive=2 visible=2
visible position: 1,0
visible position: 5,5

This is a correctness example, not a benchmark.

Verification scope: the supplied project was compiled and its CTest suite run in Debug and Release with GNU C++ 15.2.0 on the development host. It has not yet been run on Raspberry Pi hardware. Record your OS, compiler version, and test output when reproducing it on a Pi.

One Record per Body

The baseline uses this input and storage record:

1
2
3
4
5
6
7
8
9
struct Vec2 { double x = 0; double y = 0; };
struct Seed {
    Vec2 position;
    Vec2 velocity;
    bool moving = false;
    bool visible = false;
    bool expiring = false;
    double remaining = 0;
};

A stationary body still contains velocity fields, and a non-expiring body still contains a remaining-lifetime field. The flags determine whether those values are relevant. That is a simple representation with some unused data, not necessarily a problem worth fixing.

The plain::World class owns a std::vector<Seed>. Its step method is:

void step(double dt) {
    validate_dt(dt);
    for (auto& body : bodies_) {
        if (body.moving) move(body.position, body.velocity, dt);
        if (body.expiring) body.remaining -= dt;
    }
    bodies_.erase(std::remove_if(bodies_.begin(), bodies_.end(),
        [](const Seed& body) {
            return body.expiring && body.remaining <= 0;
        }), bodies_.end());
}

The full header supplies move, time-step validation, construction, and snapshots. Deletion happens after iteration rather than erasing elements inside the range loop. Erasure and vector growth can invalidate references; do not retain a pointer to a body across those operations.

Check Behaviour Independently

Tests should contain expected results, not just compare two implementations that could share the same mistake.

For a body at (0, 0) with velocity (2, -4), a 0.25 second step should produce (0.5, -1). A stationary body must not move even if its stored velocity is nonzero. A moving invisible body moves but does not appear in rendering.

The supplied tests check those cases, exact-boundary expiration, initially expired bodies, an empty world, and invalid time steps. They also run all eight combinations of the three flags. Failures use explicit checks rather than assert, so the checks remain active in Release builds.

Test the Release build separately:

1
2
3
4
cmake -S docs/data-oriented-design/examples -B /tmp/pi-design-release \
  -DCMAKE_BUILD_TYPE=Release
cmake --build /tmp/pi-design-release
ctest --test-dir /tmp/pi-design-release --output-on-failure

Costs and Limits

The baseline mixes behaviour flags and data in one record. It does not support stable entity handles, concurrent mutation, arbitrary component registration, or a renderer. Those are explicit non-goals.

Keep this design if your workload and changes remain easy to express. Do not introduce a registry merely because the course title contains ECS.

Exercise

Add a stationary, invisible, expiring seed. Write its expected survivor count before running the program. Then change dt to a value larger than its lifetime and explain the movement-before-expiration rule.

Previous: Design boundaries. Next: Inheritance and composition.

Donate