An ECS built to show what FAT-P can do.
The premise: take a library of focused, production-quality components — sparse sets, slot maps, signals, hash maps — and assemble them into something non-trivial. The result should be competitive with EnTT, the industry-standard ECS, out of the box. Not as a nice-to-have. As a baseline expectation.
This is that result. 19 FAT-P components. EnTT API parity. Faster on most benchmarks. Built in a weekend by an AI working autonomously under the Fat-P guidelines.
fatp-ecs is not a replacement for EnTT. EnTT is a mature, battle-tested library with years of development and a large community behind it. If you need a production ECS today, use EnTT.
fatp-ecs exists to answer a different question: what does it look like when you compose FAT-P components into something non-trivial? The ECS is the vehicle. The FAT-P composition is the point. SparseSet, SlotMap, Signal, FastHashMap — these components were not designed for an ECS. They were designed to be independently correct under adversarial conditions. What you get when you assemble them is a system that is competitive with the industry standard not because it was tuned against it, but because the foundations were sound.
The benchmark numbers are not a scorecard against EnTT. They are evidence that the primitives work.
fatp-ecs targets the EnTT API closely enough that porting an existing EnTT project requires changing roughly one line per fifty source files — namespace and header substitutions. This is intentional: API compatibility provides a concrete, verifiable measure of completeness. It does not mean fatp-ecs and EnTT make the same design choices. Where they diverge — fixed 64-bit entities backed by a generational SlotMap, a central EventBus instead of per-storage signal mixins, a runtime atomic type-ID generator instead of compile-time hashing — those are deliberate decisions, not gaps.
fatp-ecs was designed and implemented autonomously by Claude (Anthropic) under the Fat-P development guidelines. The guidelines — covering coding standards, naming conventions, test structure, benchmark methodology, CI workflow, documentation style, and AI operational behavior — are the same ones that govern the parent FAT-P library. They transfer with the project.
The human's role across the Fat-P project is consistent: accept, reject, flag. Architecture, implementation, tests, benchmarks, and documentation are AI work. The guidelines themselves are AI-to-AI communication — written by AI instances to constrain future AI instances, not written by the project owner. The human who initiated the project has never read them.
After the initial autonomous build, adversarial review was conducted by other AI models to identify correctness issues, implementation gaps, and missed edge cases. This multi-model review mirrors how the parent library is developed: Claude as primary architect, other models as reviewers with complementary blind spots.
The implication for readers: the code, tests, benchmarks, and documentation in this repository were not written by a human and reviewed by AI. They were written by AI, reviewed by AI, and accepted or rejected by a human whose input is directional — "don't do that again" — not implementational.
Benchmarked against EnTT v3.14, the industry reference. All benchmarks use round-robin execution with randomized order, statistical reporting (median of 20 batches), and CPU frequency monitoring via FatPBenchmarkRunner.
fatp-ecs uses 64-bit entity IDs throughout. All comparisons are against EnTT configured with 64-bit IDs.
Rows 1–10: N=1M entities. Fragmented and Churn at N=100K.
| Category | fatp-ecs | EnTT-64 | ratio |
|---|---|---|---|
| Create entities | 7.76 ns | 12.46 ns | 0.62x |
| Destroy entities | 6.52 ns | 12.91 ns | 0.51x |
| Add 1 component | 14.20 ns | 13.58 ns | 1.05x |
| Add 3 components | 41.61 ns | 40.00 ns | 1.04x |
| Remove component | 5.70 ns | 15.81 ns | 0.36x |
| Get component | 3.14 ns | 4.85 ns | 0.65x |
| 1-comp iteration | 0.66 ns | 0.88 ns | 0.75x |
| 2-comp iteration | 1.34 ns | 4.11 ns | 0.33x |
| Sparse iteration | 1.56 ns | 4.20 ns | 0.37x |
| 3-comp iteration | 3.09 ns | 7.64 ns | 0.40x |
| Fragmented iter | 0.64 ns | 0.83 ns | 0.77x |
| Churn (create+destroy) | 16.03 ns | 31.41 ns | 0.51x |
Bold = fatp-ecs faster. Ratio below 1.0x means fatp-ecs wins by that factor.
Add component is slightly slower because fatp-ecs fires lifecycle events on every add(). This is deliberate — onComponentAdded<T> is always wired up, not opt-in. The overhead is ~0.6–0.7 ns per add on GCC at scale. Everything else is faster.
| Category | GCC-13 | GCC-14 | Clang-16 | Clang-17 | MSVC |
|---|---|---|---|---|---|
| Create | 0.53x | 0.62x | 0.61x | 0.61x | 0.75x |
| Destroy | 0.51x | 0.51x | 0.59x | 0.53x | 0.44x |
| Add 1 | 1.19x | 1.05x | 0.96x | 0.99x | 0.90x |
| Add 3 | 1.15x | 1.04x | 0.91x | 0.94x | 1.01x |
| Remove | 0.37x | 0.36x | 0.49x | 0.48x | 0.33x |
| Get | 0.62x | 0.65x | 0.92x | 0.89x | 1.07x |
| 1-comp iter | 0.67x | 0.75x | 0.62x | 0.64x | 0.49x |
| 2-comp iter | 0.32x | 0.33x | 0.68x | 0.62x | 0.31x |
| Sparse iter | 0.38x | 0.37x | 0.63x | 0.52x | 0.32x |
| 3-comp iter | 0.40x | 0.40x | 0.63x | 0.62x | 0.44x |
| Fragmented | 0.76x | 0.77x | 0.66x | 0.68x | 0.66x |
| Churn | 0.45x | 0.51x | 0.49x | 0.50x | 0.34x |
The iteration advantage is consistent across all five compilers. MSVC shows the strongest gains in sparse iteration (0.32x) and churn (0.34x). Add component is at or near parity on Clang and MSVC — the event system overhead is effectively inlined away on those toolchains. GCC-13 shows a modest add overhead; entity creation is not a per-frame hot path.
# Local (Windows, vcpkg)
cmake -B build -DFATP_ECS_BUILD_BENCH=ON -DCMAKE_TOOLCHAIN_FILE=<vcpkg>/scripts/buildsystems/vcpkg.cmake
cmake --build build --config Release --target benchmark
build\Release\benchmark.exe
# CI (manual dispatch)
# Go to Actions > "fatp-ecs Benchmarks" > Run workflowEnvironment variables: FATP_BENCH_BATCHES (default 20), FATP_BENCH_WARMUP_RUNS (default 3), FATP_BENCH_NO_STABILIZE=1 (skip CPU wait), FATP_BENCH_VERBOSE_STATS=1 (detailed output).
fatp-ecs matches the EnTT registry API surface. Code written against EnTT compiles against fatp-ecs with minimal changes — mostly namespace and header swaps.
#include <fatp_ecs/FatpEcs.h>
using namespace fatp_ecs;
Registry registry;
// Entity lifecycle
Entity player = registry.create();
Entity restored = registry.create(saved_hint); // hint-based, for snapshot restore
// Component operations — EnTT names work directly
registry.emplace<Position>(player, 0.f, 0.f); // alias for add<T>()
registry.emplace_or_replace<Velocity>(player, 1.f, 0.5f);
registry.patch<Health>(player, [](Health& h) { h.hp -= 10; });
registry.erase<Poison>(player); // asserting remove
// Presence queries
bool alive = registry.valid(player);
bool hasCombo = registry.all_of<Position, Velocity>(player);
bool hasAny = registry.any_of<Frozen, Stunned>(player);
bool clean = registry.none_of<Dead, Disabled>(player);
Position* p = registry.try_get<Position>(player); // nullptr if missing
Position& pos = registry.get_or_emplace<Position>(player, 0.f, 0.f);
// Entity enumeration
registry.each([](Entity e) { /* all live entities */ });
registry.orphans([&](Entity e) { registry.destroy(e); });
// Bulk operations
registry.clear<Frozen>(); // remove Frozen from every entity, fires events
registry.clear(); // destroy everything
// Direct store access (for tooling / custom sorting)
if (auto* s = registry.storage<Position>()) {
std::printf("%zu entities have Position\n", s->size());
}
// Views
registry.view<Position, Velocity>().each(
[](Entity e, Position& pos, Velocity& vel) {
pos.x += vel.dx;
pos.y += vel.dy;
});
// Views with exclude filters
registry.view<Position>(Exclude<Frozen>{}).each(
[](Entity e, Position& pos) { /* skip frozen entities */ });
// Groups (contiguous layout, fastest iteration)
auto& grp = registry.group<Position, Velocity>();
if (auto* g = registry.group_if_exists<Position, Velocity>()) {
g->each([](Entity e, Position& p, Velocity& v) { /* ... */ });
}
// Signals — EnTT names
auto c1 = registry.on_construct<Health>().connect([](Entity e, Health& h) { /* ... */ });
auto c2 = registry.on_destroy<Health>().connect([](Entity e) { /* ... */ });
auto c3 = registry.on_update<Health>().connect([](Entity e, Health& h) { /* ... */ });
// Observers — watch for component combinations appearing
auto obs = registry.observe(OnAdded<Position>{}, OnAdded<Velocity>{});
obs.each([](Entity e) { /* e now has both Position and Velocity */ });
// Deferred operations (safe during iteration)
CommandBuffer cmd;
registry.view<Health>().each([&](Entity e, Health& hp) {
if (hp.hp <= 0) cmd.destroy(e);
});
cmd.flush(registry);
// Parallel system execution
Scheduler scheduler(4);
scheduler.addSystem("Physics",
[](Registry& r) { /* ... */ },
makeComponentMask<Position>(), // writes
makeComponentMask<Velocity>()); // reads
scheduler.run(registry);
// Data-driven spawning from JSON templates
TemplateRegistry templates;
templates.registerComponent("Position", myPositionFactory);
templates.addTemplate("goblin", R"({
"components": { "Position": {"x": 0, "y": 0}, "Health": {"current": 50, "max": 50} }
})");
Entity goblin = templates.spawn(registry, "goblin");
// Overflow-safe gameplay math
int hp = applyDamage(currentHp, damage, maxHp); // clamped to [0, maxHp]
int score = addScore(currentScore, points); // saturates at INT_MAX| EnTT | fatp-ecs | Notes |
|---|---|---|
registry.emplace<T>() |
registry.emplace<T>() |
✓ direct alias |
registry.replace<T>() |
registry.replace<T>() |
✓ |
registry.patch<T>() |
registry.patch<T>() |
✓ |
registry.erase<T>() |
registry.erase<T>() |
✓ asserting remove |
registry.remove<T>() |
registry.remove<T>() |
✓ returns bool |
registry.get<T>() |
registry.get<T>() |
✓ |
registry.try_get<T>() |
registry.try_get<T>() |
✓ |
registry.get_or_emplace<T>() |
registry.get_or_emplace<T>() |
✓ |
registry.contains<T>() |
registry.contains<T>() |
✓ |
registry.all_of<Ts...>() |
registry.all_of<Ts...>() |
✓ |
registry.any_of<Ts...>() |
registry.any_of<Ts...>() |
✓ |
registry.none_of<Ts...>() |
registry.none_of<Ts...>() |
✓ |
registry.valid() |
registry.valid() |
✓ |
registry.alive() |
registry.alive() |
✓ |
registry.each() |
registry.each() |
✓ |
registry.orphans() |
registry.orphans() |
✓ |
registry.clear<T>() |
registry.clear<T>() |
✓ fires events |
registry.storage<T>() |
registry.storage<T>() |
✓ |
registry.on_construct<T>() |
registry.on_construct<T>() |
✓ |
registry.on_destroy<T>() |
registry.on_destroy<T>() |
✓ |
registry.on_update<T>() |
registry.on_update<T>() |
✓ |
registry.create(hint) |
registry.create(hint) |
✓ slot-index hint |
registry.group_if_exists<Ts...>() |
registry.group_if_exists<Ts...>() |
✓ |
registry.view<Ts>(exclude<Xs>) |
registry.view<Ts>(Exclude<Xs>{}) |
syntax differs |
registry.emplace_or_replace<T>() |
registry.emplace_or_replace<T>() |
✓ |
Each FAT-P component maps directly to an ECS problem:
| FAT-P Component | ECS Role |
|---|---|
| StrongId | Type-safe 64-bit Entity handles (index + generation) |
| SparseSetWithData | O(1) component storage with cache-friendly dense iteration |
| SlotMap | Entity allocator with generational ABA safety + insert_at() for hint-based create |
| FastHashMap | Type-erased component store registry |
| SmallVector | Stack-allocated entity query results |
| Signal | Observer pattern for entity/component lifecycle events |
| ThreadPool | Work-stealing parallel system execution |
| BitSet | Component masks for archetype matching and dependency analysis |
| WorkQueue | Job dispatch (via ThreadPool internals) |
| ObjectPool | Per-frame temporary allocator with bulk reset |
| StringPool | Interned entity names for pointer-equality comparison |
| FlatMap | Sorted name-to-entity mapping for debug/editor tools |
| JsonLite | Data-driven entity template definitions |
| StateMachine | Compile-time AI state machines with context binding |
| FeatureManager | Runtime system enable/disable toggles |
| CheckedArithmetic | Overflow-safe health/damage/score calculations |
| AlignedVector | SIMD-friendly aligned component storage |
| LockFreeQueue | Thread-safe parallel command buffer |
| CircularBuffer | Deferred command queues |
The components weren't designed for an ECS. They were designed to be useful individually. The ECS is what happens when you compose them.
Header-only. Requires C++20 and FAT-P as a sibling directory or via FATP_INCLUDE_DIR.
Windows (PowerShell):
.\build.ps1 -Clean # full clean build with SDL2 visual demo
.\build.ps1 -NoVisual # skip SDL2, terminal demo + tests only
.\build.ps1 -Debug # debug buildWindows (batch):
build.bat clean :: full clean build with SDL2 visual demo
build.bat novisual :: skip SDL2
build.bat debug :: debug buildLinux / macOS:
./build.sh --clean # full clean build
./build.sh --no-visual # skip SDL2
./build.sh --debug # debug build# Tests only (no SDL2 required)
cmake -B build -DFATP_INCLUDE_DIR=../FatP/include
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure
# With SDL2 visual demo (Windows / vcpkg)
cmake -B build -DFATP_INCLUDE_DIR=../FatP/include -DFATP_ECS_BUILD_VISUAL_DEMO=ON \
-DCMAKE_TOOLCHAIN_FILE="<vcpkg-root>/scripts/buildsystems/vcpkg.cmake"
cmake --build build --config Release
# With SDL2 visual demo (Linux)
sudo apt install libsdl2-dev libsdl2-ttf-dev
cmake -B build -DFATP_INCLUDE_DIR=../FatP/include -DFATP_ECS_BUILD_VISUAL_DEMO=ON
cmake --build build
# Direct compilation
g++ -std=c++20 -O2 -I include -I /path/to/FatP/include your_code.cpp -lpthread| Option | Default | Description |
|---|---|---|
FATP_INCLUDE_DIR |
auto-detect | Path to FAT-P include directory |
FATP_ECS_BUILD_TESTS |
ON |
Build test executables |
FATP_ECS_BUILD_DEMO |
ON |
Build terminal demo |
FATP_ECS_BUILD_VISUAL_DEMO |
OFF |
Build SDL2 visual demo (requires SDL2, SDL2_ttf) |
FATP_ECS_BUILD_BENCH |
OFF |
Build benchmark suite (requires EnTT via vcpkg) |
Headless space battle simulation exercising all 19 FAT-P components:
build/Release/demo.exe
build/Release/demo.exe --wave-size 100 --turrets 8 --frames 500Real-time rendering of the space battle. The ECS ticks every frame; SDL2 draws the result. Frame time shown is the real end-to-end cost.
build/Release/visual_demo.exe
build/Release/visual_demo.exe --wave-size 100 --turrets 8| Key | Action |
|---|---|
| Space | Pause / resume |
| 1 / 2 / 3 | Speed 1x / 2x / 5x |
| F | Toggle vsync (capped 60fps vs uncapped) |
| R | Reset simulation |
| + / - | Increase / decrease wave size |
| Escape | Quit |
18 test suites, 539 tests, all passing across the full CI matrix.
Phase 1 — Core ECS: 27 passed
Phase 2 — Events & Parallelism: 37 passed
Phase 3 — Gameplay Infrastructure: 28 passed
Exclude filters: 15 passed
Patch: 15 passed
Observer: 21 passed
OwningGroup: 16 passed
Sort: 15 passed
Snapshot: 15 passed
Handle: 20 passed
EntityCopy: 16 passed
ProcessScheduler: 20 passed
Clear stress: 138 passed
RuntimeView: 17 passed
StoragePolicy: 65 passed
New API: 16 passed
NonOwningGroup: 14 passed
EnTT parity: 44 passed
Total: 539 passed
GitHub Actions runs a 12-job matrix on every push:
| Job | Configurations |
|---|---|
| Linux GCC | GCC-12 (C++20), GCC-13 (C++20 Debug+Release), GCC-14 (C++23) |
| Linux Clang | Clang-16 (C++20), Clang-17 (C++23) |
| Windows MSVC | C++20 (Debug+Release), C++23 |
| Sanitizers | AddressSanitizer, UndefinedBehaviorSanitizer |
| Gate | CI Success (aggregates all jobs) |
Benchmarks run separately via manual dispatch across GCC-13, GCC-14, Clang-16, Clang-17, and MSVC.