Cat Game Research
Frontier
Dev-log transmissions from the expedition

ECS Architecture: Lessons from Component Design

How we designed the ECS architecture for CatGame, the mistakes we made, and why pure data components won over hybrid approaches.

The First Mistake

Our first ECS design had components with methods. Update() on the player component. Render() on the mesh component. It felt natural — object-oriented thinking dies hard.

Within two weeks, we had circular dependencies, unclear ownership, and test setups that required instantiating half the engine. The architecture was supposed to solve these problems, not create them.

Pure Data, Pure Functions

The redesign was radical: components became pure data structures. No methods. No inheritance. No hidden logic. Just fields, serialized to and from TOML.

// Before: component with behavior struct PlayerComponent { void Update(float dt); float speed; float jumpForce; }; // After: pure data struct PlayerComponent { float speed = 5.0f; float jumpForce = 7.5f; };

All behavior moved into systems — BaseSystem subclasses with explicit lifecycles, registered with a Singleton<T> pattern. Systems query components, transform data, and write results. Components don't know systems exist. Systems don't know about each other unless they explicitly depend on each other's output.

The TOML Contract

One of the best decisions we made was requiring every component to be TOML-serializable. This wasn't just about saving levels — it was about enforcing the pure-data contract. If a component has methods that mutate state in non-obvious ways, it can't be cleanly serialized. The serialization requirement became a design constraint that kept us honest.

If you can't serialize it, it's not pure data. If it's not pure data, it doesn't belong in a component.

Scene Tags: The Other Revelation

Scenes in CatGame are tag-first. Every scene requires at least one tag — the name is optional. This was a late change that simplified the scene management code dramatically. Instead of looking up scenes by string name (fragile, typo-prone), we query by tag. Multiple scenes can share a tag. The scene loader doesn't care about names; it cares about what kind of scene it is.

What We'd Do Differently

The Singleton<T> pattern for systems works, but it makes testing harder than it should be. If we were starting today, we'd consider an explicit dependency injection container instead. The singleton pattern is convenient — Engine::get_system<T>() is one line — but it creates hidden coupling that only shows up when you try to isolate a system for unit testing.

That said, the core principle — pure data components, behavior in systems, TOML serialization — has held up for over a year of development. It's the architectural backbone of the engine, and it's one of the few early decisions we haven't had to fundamentally revisit.

← All transmissions