— 7 min read
ECS vs OOP: why I chose composition to build a browser game
From renderer to engine: the path to ECS
On February 11, 2026, I started building a 4X space strategy game in the browser. That same evening, a ship was flying, shooting, and dodging enemies.
My first instinct was Three.js. A space game, 3D graphics — it seemed obvious. But as I started laying out the gameplay elements, I realized 90% of what I wanted to display was 2D: top-down ships, trajectories, interfaces. Three.js for that was overkill. YAGNI.
I went with PixiJS instead. And that’s where an interesting realization hit: PixiJS is a 2D rendering library, not a game framework. No built-in physics engine, no game loop, no entity management. Just a rendering tool. The rest — the game structure, managing hundreds of entities, inter-module communication — was mine to architect.
In enterprise, this is a familiar choice: pick an all-in-one framework that decides for you, or assemble specialized building blocks and keep control over the architecture. I chose the second option. And while figuring out how to structure this engine, I stumbled upon a pattern I had never encountered in 18 years of enterprise work: the Entity Component System.
This article explains what ECS is, why I chose it, and most importantly, why this pattern should interest software architects well beyond game dev.
graph TD
subgraph "OOP — Inheritance"
A[Entity] --> B[SpaceVehicle]
B --> C[Ship]
C --> D[CombatShip]
C --> E[MerchantShip]
D -.-> F["Armed Merchant\nShip ???"]
E -.-> F
end
subgraph "ECS — Composition"
G["Entity #42"] --- H[Position]
G --- I[Velocity]
G --- J[Health]
G --- K[Weapon]
G --- L[Cargo]
G --- M[Faction]
endThe hierarchy problem (and why OOP composition isn’t enough)
When we learn object-oriented programming, we learn inheritance. We model the world as class trees: Vehicle → SpaceVehicle → Ship → CombatShip.
It works on a whiteboard. In production, you quickly face well-known problems: the diamond problem, god objects, rigid coupling.
The diamond problem. An armed merchant ship inherits from MerchantShip and CombatShip? Both inherit from Ship. Which one wins?
The classic answer is one we all know: “prefer composition over inheritance.” And it’s true. You can compose a Ship from a ShieldModule, a WeaponModule, a CargoModule. Each carries its own data and logic. Problem solved?
Not quite. Classic OOP composition solves the inheritance problem, but leaves others open:
Data and logic remain coupled. A ShieldModule carries its data AND its methods. If two modules need to interact (the shield absorbs damage from combat), they need to know about each other or go through the parent object.
No cross-cutting queries. It’s impossible to say “give me everything that has a Position and a Velocity” and iterate over it in a single loop, whether it’s a ship, an asteroid, or debris. In OOP composition, you need interfaces or type-checking.
Data is scattered in memory. Each composed object lives somewhere on the heap. When you iterate over 1,000 entities, you’re jumping all over memory. In JavaScript, this issue is mitigated by the engine, but in systems languages like Rust or C++, an ECS with archetype-based storage keeps data contiguous in memory — a measurable advantage at 60 FPS.
In a game where hundreds of entities interact every frame — ships, projectiles, asteroids, stations, resources — I needed a model that goes further than classic composition. ECS takes composition to its logical conclusion: data becomes pure bags (components), logic lives elsewhere (systems), and querying by data composition is native.
ECS in three concepts
The Entity Component System relies on three simple building blocks.
Entity: a simple numeric identifier. No class, no methods, no data. Just a number (1, 2, 3…).
Component: a bag of data, with no logic. For example, a Position contains x, y, and a rotation. A Health component contains current and maximum hit points. That’s it.
System: a module that contains the logic. A system queries all entities that possess a certain set of components and operates on them. The movement system reads Position + Velocity. The combat system reads Health + Weapon. Each system only knows the data it cares about.
The core idea: separate data from logic, and compose entities by assembling building blocks rather than through inheritance.
My ship in 10 lines
Concretely, when the game creates an NPC ship, here’s what happens:
const ship = world.createEntity(); // just a number
// Physics
world.addComponent(ship, POSITION, { x: 100, y: 200, rotation: 0 });
world.addComponent(ship, VELOCITY, { dx: 0, dy: 0, dRotation: 0 });
// Combat
world.addComponent(ship, HEALTH, { current: 100, max: 100 });
world.addComponent(ship, COLLIDER, { radius: 12 });
// Social
world.addComponent(ship, FACTION, { factionId: "nexus-compact" });
// Sensors
world.addComponent(ship, DETECTOR, { range: 500 });
world.addComponent(ship, DETECTABLE, { signature: 1.0 });
// AI
world.addComponent(ship, AUTONOMOUS, {
roleId: "combat-patrol",
state: "PATROL",
targetEntity: null,
shouldFire: false,
roleConfig: { detectionRange: 500, weaponRange: 300 }
});
10 addComponent calls and you have a complete ship: it moves, has hit points, belongs to a faction, detects other ships, and has autonomous patrol behavior.
The most interesting part: to turn this NPC into a player ship, just remove the AUTONOMOUS component and add PLAYER_CONTROLLED. Everything else (physics, combat, sensors, faction) stays the same. No class hierarchy to touch.
30 lines for all the physics
The VelocitySystem is the simplest system in the game. It fits in 30 lines, comments included:
export class VelocitySystem implements System {
readonly name = "Velocity";
constructor(private world: World) {}
update(dt: number): void {
for (const id of this.world.query(VELOCITY, POSITION)) {
const vel = this.world.getComponent(id, VELOCITY)!;
const pos = this.world.getComponent(id, POSITION)!;
pos.x += vel.dx * dt;
pos.y += vel.dy * dt;
pos.rotation += vel.dRotation * dt;
}
}
}
This system iterates over everything that has a Position and a Velocity — ships, projectiles, asteroids, debris — in a single loop. A tick is one cycle of the main loop: the game advances one time step (often 1/60th of a second), each system runs on the data it cares about, then we render the result to screen. That’s a frame.
The VelocitySystem doesn’t know what a “ship” is. It only knows coordinates and velocities.
That’s the power of separating data from logic: an ultra-simple system that handles physics for the entire game.
AI as a plug-in
The most complex system in the game is the BehaviorSystem (~480 lines). It handles all autonomous behaviors: patrol, combat, trading, mining, station defense.
But it doesn’t hard-code these behaviors. It dispatches to pluggable RoleHandlers:
behaviorSystem.registerRole(combatPatrolHandler);
behaviorSystem.registerRole(protectHandler);
behaviorSystem.registerRole(traderHandler);
behaviorSystem.registerRole(minerHandler);
behaviorSystem.registerRole(stationDefenseHandler);
Each handler is a quasi-pure function. It receives an immutable context (position, health, nearby targets…) and returns a decision (new state, target, whether to fire). The system applies the decision.
// The handler receives a snapshot — read-only
const ctx = { entityX, entityY, hpFraction, nearestHostile, ... };
// It decides — no side effects
const output = handler.evaluate(state, roleConfig, ctx);
// The system writes the result
auto.state = output.state;
auto.targetEntity = output.targetEntity;
auto.shouldFire = output.shouldFire;
To add a new behavior (say, pirates setting ambushes), just create a new handler and register it. No need to modify the BehaviorSystem or any other existing system.
What’s next?
ECS structures the game’s logic. But one question remains open: if systems are independent and don’t know about each other, how do they communicate? And most importantly, what does this pattern say to enterprise architects familiar with message brokers and event-driven architectures?