What I'm struggling with is right now my components seem like global
variables in that every system has access to them and can change their
state.
This was my biggest struggle too with ECS and one which I tried to avoid (bastardizing my first implementation of ECS by making components into objects with functions that went beyond just setting/getting data).
It didn't help that I came from working in a legacy C codebase where I was the annoying guy on the team insisting on proper engineering standards. In that codebase, I frequently ran into the dumbest bugs like some Array struct
(similar to std::vector
) having a size
field which was a negative value, with so much of the codebase directly touching that size
field along with calling realloc
on the data. I had to set memory breakpoints on that field and spend hours just going from one breakpoint to the next to find which line of code set that raw field to a negative value... something which could have been outright avoided or at least caught so much sooner if this was simply a class with private fields that required the system to go through a public interface in a way that allowed it to maintain invariants, like size
never being allowed to go into the negatives.
And that wasn't even an unusual occurrence. It was a daily one where I had to lean on memory breakpoints in the debugger to see which, among millions of lines of code, set this raw data field to a value that it should never have. Refactoring these towards classes as a way to reduce the time to spot bugs, fix them, and then prevent them once and for all was forbidden. When I joined that team I inherited a bug report list that had over 5,000 bugs reported in spite of the QA team very carefully filtering out duplicates, and 90% of the bugs were just violations of basic invariants like these which information hiding would have prevented along with other stupid preventable things like uninitialized variables (and initializing uninitialized variables at the time they were defined was also forbidden in the existing code until we could prove that a variable was used prior to initialization, for which it didn't help that the codebase generated over 100,000 warnings with many of the warnings about variables potentially being uninitialized being benign). That codebase actually made me hate C and embrace the C++ mindset to the fullest in spite of being a C coder originally. It took some years for me to reverse the brain damage and get back to loving C again.
So anyway, as you can imagine it was very, very difficult for me to embrace the idea that components should just be raw data with publicly accessible fields, so I stubbornly resisted that in my first attempt at ECS by still making components into objects with functionality with separate public/private interfaces, but it soon proved to be overkill and a PITA.
With ECS, you tend to have so few broad systems, and all generally completely decoupled from each other, to reason about as opposed to, say, hundreds of different and interdependent subtypes in an OOP hierarchy or countless procedural functions calling each other in a procedural legacy codebase like the above. A lot of the appeal from a maintenance standpoint is having so few places in your codebase that have logic associated with them, and therefore so few places in the codebase that can do something wrong. Raw data can never misbehave, only code that tampers with them. If you end up adding functionality to components, now you end up having way more places in your system which have actual code, and not data. The reason that legacy codebase I described above was a nightmare to maintain and one that was hopeless bug hive was because of the monstrous amount of code that could and often would touch a given piece of data.
Maintaining ECS Invariants
I found it easy enough to maintain invariants over components given how few systems there are that access any given component type and how quickly you can deduce and diagram which systems access which component types or rapidly find which systems do that with a simple text search through the codebase. By design, a system in an ECS has to explicitly request to access a component type before it can touch it. If an invariant is violated at the component level, which I've found to very rare in my experience at least, you end up having an extremely narrow list of suspects right away without having to wade through monstrous seas of code as I had to in my legacy codebase where everything was potentially touching everything. It's not so complicated with ECS. If you find a motion component with fields set to values they should never have, then you can immediately deduce that only your physics and animation system tamper with motion components and therefore narrow down the suspects dramatically right away.
And you can, just as you would for the implementation details of a class, sprinkle sanity assertions for debug to make sure each system is dealing with valid states and not setting any component fields to values they should never have.
In fact, I find it easier to maintain the most difficult system-wide invariants in ECS. There are granular invariants of the kind like a data structure should never have a size
that goes to zero -- simple enough, and OOP with its encapsulation and information hiding was always brilliant at helping with that. I wasted so much time dealing with violations of those granular invariants in the legacy codebase. But in a software that isn't sprawling with stupid bugs, there are broader system-wide invariants like making sure GPU rendering data is in sync with CPU scene data, and those are much harder to maintain and reason about and require so much more brain power in ways where trying to dice everything up into objects and then working towards bigger and bigger objects doesn't always help, since you still end up having a boatload of interdependent, albeit loosely-coupled simple code to wade through if something goes wrong with such complex interactions between the code.
Meanwhile ECS yields very flat code modeling logic at a very broad scale with minimal code interactions (bare minimum number of couplings even if the coupling from system to component is tight), often making it easier to reason about those broad, higher-level, system-wide invariants. I never benefited quite as much from the ridiculous flexibility of ECS to adapt to new design ideas with minimal code changes (I use ECS for VFX, not games, which is rather unusual), but I benefited tremendously over how they made it easier to write and maintain systems and reason about their correctness without getting lost in a sea of small but interdependent abstract objects calling abstract functions that lead to who knows where.
If you know for sure that some components are only going to be accessed by a handful of systems, you can treat them as opaque types (declared but not defined) to the rest of the codebase and just define the component struct
in a place that's only visible to those few systems. It'll also start to click in the future why components are deliberately made to be just raw data. It's like having position in poker. It's hard to see why that's so advantageous until you record your winnings over a hundred thousand hands and see, indeed, that you won the most money always in position. ECS was like that for me. I couldn't comprehend how it would make maintaining complex codebases so much easier until I actually created one properly and started to maintain it, and I still find myself at odds about explaining exactly why it's so much easier to maintain except that it's modeling human ideas at a coarser level and bringing the number of couplings to a minimum, even if the couplings are tighter than you'd get with a SOLID OOP design.
Also raw data doesn't have to mean you have to avoid functions completely, but the functions should only be concerned with accessing data. For example, it's not necessarily a violation of this type of model of ECS to have a vector
field in your ECS, even though vector
has functions. The difference is that vector
only has functions related to accessing and manipulating raw data. Likewise it's arguably fine to have constructors and destructors. It's just, conceptually, components are still raw data.
MoveCharacter()
? What are its parameters, and what does it return?