ECS Architecture in Practice: When It Actually Helps (and When It's Overkill)

425 views 10 replies

After spending a few months migrating a mid-sized action game from a traditional OOP hierarchy to an Entity Component System, I have some thoughts — and they're more mixed than the ECS evangelism you usually see online.

Where ECS genuinely shines

  • Cache coherency at scale — once you have thousands of entities, iterating tightly packed component arrays instead of chasing pointers through a deep object graph is a real win. We saw a ~40% speedup in our physics tick just from this.
  • Compositional flexibility — adding a Poisoned or Stunned component to any entity without touching a class hierarchy is genuinely liberating. Status effects went from a nightmare to trivial.
  • Testability — systems are pure functions over component data. Writing unit tests for movement logic or damage calculation becomes almost too easy.

Where it fights you

Relationships between entities get awkward fast. Parent-child transforms, inventory ownership, targeting — these feel natural in OOP and feel like you're swimming upstream in ECS. Libraries like flecs have relationship queries now, and Bevy's ECS handles this reasonably well, but it's still cognitive overhead.

Also: if your game has under a few hundred entities, you're adding architecture complexity for zero measurable benefit. A well-structured OOP design beats a poorly-understood ECS every time.

Practical starting point

If you're in Godot 4, I'd suggest not going full ECS — Godot's node system fights it. Instead, use a hybrid: nodes for scene structure, but store hot game-state data (health, velocity, status flags) in plain dictionaries or typed arrays you iterate manually in a manager node. You get most of the cache benefit without abandoning the engine's strengths.

# Godot 4 - poor man's ECS for status effects
var entities = []
var health_components = PackedFloat32Array()
var status_flags = PackedInt32Array()

func _physics_process(delta):
    for i in entities.size():
        if status_flags[i] & STATUS_POISONED:
            health_components[i] -= POISON_TICK * delta

Curious whether anyone here has gone full ECS in Godot or Unity DOTS for a shipped project — was the complexity worth it at your game's scale?

Replying to PrismCaster: coyote time and input buffering as ECS components is genuinely awkward. I tried ...

yeah exactly. the tell for me is: how many systems actually query this component? if the answer is "one, just the player controller," that's not a component, it's a private variable with extra steps and worse ergonomics. ECS earns its keep on shared state that multiple independent systems care about. single-owner state is just OOP and that's fine, that's correct actually.

The OOP-to-ECS migration pain is real, but I think the framing of "ECS vs. OOP" misses where the actual value comes from. In my experience, the wins aren't about performance uniformly — they're specifically about cache-coherent iteration over large uniform populations: projectiles, particles, ambient NPCs, loot drops.

Where I've seen ECS actively hurt productivity is complex agent behavior. When an entity needs to coordinate 6–8 systems to produce one logical action, you end up with implicit ordering dependencies between systems that are much harder to reason about than a single update() method. Debugging "why did this enemy do that" turns into chasing data across five separate system files.

The pattern I've settled on: ECS for anything you'll have hundreds of (bullets, debris, crowd NPCs), traditional component objects for anything with complex authored behavior (bosses, puzzle objects, player). Bevy's approach of mixing the two is interesting but I haven't shipped with it yet — would be curious if anyone has thoughts on where that boundary sits in practice with Bevy specifically.

Replying to StormByte: the pattern I've landed on: ECS for the "many identical things" systems — projec...

this is basically exactly the split I landed on too. player controller in ECS always felt weird to me. there's so much one-off stateful logic (coyote time, input buffering, wall jump state) that fighting the ECS model to express it just isn't worth it. let the player be a normal class. ECS for the 400 enemies that all behave the same, OOP for the one weirdo that doesn't.

Replying to IronBolt: this is basically exactly the split I landed on too. player controller in ECS al...

coyote time and input buffering as ECS components is genuinely awkward. I tried it, ended up with like 8 single-purpose components that only the player controller system ever queries. at that point it's just OOP with extra steps and way more boilerplate for zero benefit. the hybrid split you're describing isn't a compromise, it's just the right call. not everything needs to be a query.

one thing I haven't seen mentioned: ECS really shines when you need to serialize or inspect game state externally, replays, network sync, debugging tools. when your entities are just data, writing a replay system is almost trivial compared to trying to snapshot a tangled OOP hierarchy. that alone justified the architecture shift for me on a project with deterministic rollback. outside of that though yeah, I'd never use it for a simple single-player game.

the pattern I've landed on: ECS for the "many identical things" systems — projectiles, particles, AI agents, loot drops — and plain OOP for one-of-a-kind objects like the player controller, quest manager, UI. no ideological commitment either way, just use whatever's less annoying for that specific thing. the projects where I forced ECS onto everything including the player character were miserable. the projects where I used it selectively were fine

ECS-for-everything is a trap. ECS-for-the-right-things is actually great.

Replying to CrystalVale: yeah exactly. the tell for me is: how many systems actually query this component...

"private variable with extra steps" is the most accurate description of over-engineered ECS components I've ever read. stealing this immediately.

Really appreciate the honest take here — the ECS evangelism gets exhausting. One thing I'd add: a lot of the pain you're describing often comes down to archetype vs. sparse-set ECS implementations behaving very differently in practice.

Archetypal ECS (Unity DOTS, Flecs in default mode) gives you great cache performance on bulk iteration but makes frequent component add/remove operations surprisingly costly due to archetype moves. If your game has lots of dynamic state changes per entity per frame, you end up with a lot of archetype fragmentation and the cache wins evaporate.

Sparse-set implementations (EnTT being the classic example) handle structural changes much more gracefully at the cost of slightly less ideal iteration patterns. For an action game with lots of dynamic entity state, sparse-set might actually be the better fit architecturally.

The ECS pitch is most honest when the problem is specifically "I have thousands of similar entities doing similar things." For complex, unique-behavior game objects, a well-structured OOP hierarchy with composition is genuinely fine.

the thing that finally made ECS click for me was stopping thinking about it as an architecture and starting to think about it as a query language for your game state. once you frame it that way, I want all entities that have a Velocity + Collider + not Frozen, the value becomes obvious. but yeah if you don't have queries that look like that, you probably don't need ECS. OOP with some composition is fine and ships faster.

ECS absolutely earns its keep in bullet hell / top-down shooter territory. When you have 800 projectiles on screen and each one is just a velocity + position + lifetime component, iterating over contiguous memory with zero virtual dispatch is a real, measurable win. Profiled it myself. Same logic in OOP with polymorphic Update() calls was dropping frames at around 600 entities, ECS version didn't hiccup until well past 2000.

But yeah for a 30-entity RPG with complex AI trees it's honestly just overhead.

Moonjump
Forum Search Shader Sandbox
Sign In Register