Why I Stopped Using Entity Component Systems

ECS is the darling of game engine architecture, but after three years of building with it at scale, I moved to something different. The problem isn't performance — ECS delivers on that promise. The problem is legibility. When your game logic is scattered across dozens of systems operating on anonymous component tuples, debugging becomes archaeology.


Here's the pitch for ECS: you define components as pure data, entities as bags of components, and systems as functions that iterate over component queries. It's elegant. It's cache-friendly. It makes the game engine people on Twitter nod approvingly. And for about six months, it made me feel like a genius.
But then the codebase grew. The graph hit two hundred systems. The movement system depended on the physics system which depended on the collision system which read from the spatial index which was updated by the transform system which was triggered by the movement system. Circular dependencies, but not in a way the compiler could catch — in a way that only manifested as entities jittering at 144fps on one machine and not another. The architecture was theoretically decoupled. In practice, every system was coupled to every other system through the shared mutable state of the component store.
Therefore I did what any reasonable maintainer does: I added more abstraction. System ordering constraints. Component change detection. Event channels between systems. Each patch fixed the immediate bug and added another layer of indirection that made the next bug harder to find.

Citrine's analogy is better than she knows. The failure mode of ECS isn't that it doesn't work — it's that it works just well enough to keep you investing in it long past the point of diminishing returns. You keep adding systems, keep splitting components finer, keep believing that the next refactor will be the one that makes it all click. It's a local maximum that feels like a global one.
The breaking point wasn't a technical failure. It was an onboarding failure. I hired a senior engineer — someone with fifteen years of systems programming experience — and asked them to add a new ability to a character class. It took them three weeks. Not because the task was hard, but because understanding which systems touched which components in which order required reading essentially the entire codebase. The knowledge was there, scattered across system registration order and query filters and marker components, but it was nowhere a human could find it without a guided tour.
That's when I realized the problem wasn't ECS. The problem was that I'd optimized for the machine's ability to iterate over data and neglected the human's ability to iterate on ideas.


The replacement I landed on: Trait Objects with Event Sourcing. Every entity has a concrete type — a Player, a Projectile, a Door — with behavior defined as trait implementations. State changes are recorded as events to an append-only log. You lose the ability to slap arbitrary component combinations onto entities at runtime. You gain the ability to open a file, read it top to bottom, and understand what a thing does.
The event sourcing layer gives me something ECS never could: time travel debugging. Every state change is a recorded event with a timestamp and a cause. When something goes wrong, I don't grep through system logs hoping to reconstruct what happened — I replay the event stream and watch it happen again. The networking stack I was pushing toward the edge — close to players, not round-tripping through a single choke point — grew out of the same idea: once your state is a stream of events, fanning it out to nodes stops being a bespoke sync problem and turns into replication.

There's a deeper lesson here, and it's one I keep re-learning. The software industry has a fetish for generality. It worships the abstract, the composable, the infinitely flexible. ECS is the logical endpoint of that worship: everything is just data, behavior is just queries, and the system is maximally general. But generality has a cost that doesn't show up in benchmarks. It shows up in the three weeks your new hire spends staring at component queries, trying to figure out why a door won't open.
The trait object approach is less powerful. There are things you can express in ECS that you simply cannot express with concrete types. But that's the point. Constraints aren't limitations — they're communication. When a Door struct implements Interactable, that's a sentence a human can read. When an entity with components [Position, Rotation, Mesh, Collider, InteractionTarget, Locked, AudioSource] exists in a query matched by SystemDoorInteraction, that's a crossword puzzle.
This connects to something I've been turning over across the whole stack — the same instinct that makes me want wire formats and handshakes you can read without a proprietary decoder: systems should be legible to the people who use them, not just functional for the machines that run them. Code is read more than it's written. Architectures are debugged more than they're designed. The best system isn't the most flexible one — it's the one that tells you what went wrong at 2am when production is on fire and you haven't slept.

I still reach for ECS-like patterns in specific, bounded contexts — particle systems, for instance, where you genuinely have millions of identical entities and cache coherence matters. But for game logic? For the systems that define what your world means? Give me a concrete type with a name I can read and behavior I can trace. Every time.
The uncomfortable truth is that I didn't ditch ECS because I found something better. I ditched it because ECS forced me to admit I'd been prioritizing my own cleverness over my teammates' comprehension. The architecture was a mirror, and I didn't like what it showed me. The best code isn't the code that makes you feel smart. It's the code that makes the next person feel welcome.






