ECS vs OOP : pourquoi j'ai choisi la composition pour construire un jeu dans le navigateur

— 7 min de lecture

ECS vs OOP : pourquoi j'ai choisi la composition pour construire un jeu dans le navigateur

Read in English


Le 11 février 2026, j’ai commencé à développer un jeu de stratégie spatiale 4X dans le navigateur. Le soir même, un vaisseau volait, tirait, et esquivait des ennemis.

Mon premier réflexe avait été Three.js. Un jeu spatial, de la 3D, ça semblait évident. Sauf qu’en posant les premiers éléments de gameplay, je me suis rendu compte que 90% de ce que je voulais afficher était en 2D : des vaisseaux vus du dessus, des trajectoires, des interfaces. Three.js pour ça, c’était overkill. YAGNI.

Le choix s’est porté sur PixiJS. Et c’est là qu’un constat intéressant s’est imposé : PixiJS est une librairie de rendu 2D, pas un framework de jeu. Pas de moteur physique intégré, pas de game loop, pas de gestion d’entités. Juste un outil de rendu. Le reste (la structure du jeu, la gestion des entités, la communication entre modules), c’était à moi de l’architecturer.

En entreprise, c’est un choix qu’on connaît bien : prendre un framework tout-en-un qui décide pour vous, ou assembler des briques spécialisées et garder la main sur l’architecture. J’ai choisi la deuxième option. Et c’est en cherchant comment structurer ce moteur que je suis tombé sur un pattern que je n’avais jamais croisé en 18 ans d’entreprise : l’Entity Component System.

Cet article explique ce qu’est l’ECS, pourquoi je l’ai choisi plutôt que l’approche classique orientée objet (OOP), et surtout pourquoi ce pattern devrait intéresser les architectes logiciels bien au-delà du game dev.

Dans cet article :

Cet article est le premier d’une série de deux.

graph TD
  subgraph "OOP — Héritage"
    A[Entité] --> B[VéhiculeSpatial]
    B --> C[Vaisseau]
    C --> D[VaisseauDeCombat]
    C --> E[VaisseauMarchand]
    D -.-> F["VaisseauMarchand\nArmé ???"]
    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]
  end
OOP : le diamond problem. ECS : des composants librement attachés.

Le problème de la hiérarchie

Quand on apprend la programmation orientée objet, on apprend l’héritage. On modélise le monde en arbres de classes : Véhicule → VéhiculeSpatial → Vaisseau → VaisseauDeCombat.

Ça fonctionne sur un tableau blanc. En production, on se retrouve vite face à des problèmes connus : le diamond problem, les god objects, le couplage rigide.

Le diamond problem. Un vaisseau marchand armé hérite de VaisseauMarchand et de VaisseauDeCombat ? Les deux héritent de Vaisseau. Lequel gagne ? Spoiler : personne ne gagne. Sauf le développeur qui debug à 23h un vendredi.

La réponse classique, on la connaît tous : “préférez la composition à l’héritage”. Et c’est vrai. On peut composer un Vaisseau à partir d’un ShieldModule, d’un WeaponModule, d’un CargoModule. Chacun porte ses données et sa logique. Problème résolu ?

Pas tout à fait. La composition OOP classique résout le problème de l’héritage, mais elle en laisse d’autres ouverts :

Les données et la logique restent couplées. Un ShieldModule porte ses données ET ses méthodes. Si deux modules doivent interagir (le bouclier absorbe des dégâts qui viennent du combat), il faut qu’ils se connaissent ou passent par l’objet parent.

Pas de querying transversal. Impossible de dire “donne-moi tout ce qui a une Position et une Velocity” et d’itérer dessus en une seule boucle, qu’il s’agisse d’un vaisseau, d’un astéroïde ou d’un débris. En composition OOP, il faut des interfaces ou du type-checking.

Les données sont dispersées en mémoire. Chaque objet composé vit quelque part dans le heap. Quand tu itères sur 1000 entités, tu sautes partout en mémoire. En JavaScript, ce problème est atténué par le moteur, mais dans des langages système comme Rust ou C++, un ECS avec un stockage par archetype permet de garder les données contiguës en mémoire, un avantage mesurable à 60 FPS.

Dans un jeu où des centaines d’entités interagissent à chaque frame (vaisseaux, projectiles, astéroïdes, stations, ressources), il fallait un modèle qui aille plus loin que la composition classique. L’ECS pousse la composition à son terme : les données deviennent des sacs purs (les composants), la logique vit ailleurs (les systèmes), et le querying par composition de données est natif.

L’ECS en trois concepts

L’Entity Component System repose sur trois briques simples.

Entity : un simple identifiant numérique. Pas de classe, pas de méthode, pas de données. Juste un nombre (1, 2, 3…).

Component : un sac de données, sans logique. Par exemple, une Position contient x, y, et une rotation. Un composant Health contient des points de vie courants et maximum. C’est tout.

System : un module qui contient la logique. Un système query toutes les entités qui possèdent un certain ensemble de composants et opère dessus. Le système de mouvement lit Position + Velocity. Le système de combat lit Health + Weapon. Chaque système ne connaît que les données qui le concernent.

L’idée centrale : séparer les données de la logique, et composer les entités par assemblage de briques plutôt que par héritage.

Mon vaisseau en 10 lignes

Concrètement, quand le jeu crée un vaisseau NPC, voilà ce qui se passe :

const ship = world.createEntity();  // un simple nombre

// Physique
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" });

// Senseurs
world.addComponent(ship, DETECTOR, { range: 500 });
world.addComponent(ship, DETECTABLE, { signature: 1.0 });

// IA
world.addComponent(ship, AUTONOMOUS, {
  roleId: "combat-patrol",
  state: "PATROL",
  targetEntity: null,
  shouldFire: false,
  roleConfig: { detectionRange: 500, weaponRange: 300 }
});

10 appels addComponent et on a un vaisseau complet : il se déplace, il a des points de vie, il appartient à une faction, il détecte d’autres vaisseaux, et il a un comportement de patrouille autonome.

Le plus intéressant : pour transformer ce NPC en vaisseau joueur, il suffit de retirer le composant AUTONOMOUS et d’ajouter PLAYER_CONTROLLED. Le reste (physique, combat, senseurs, faction) ne change pas. Pas besoin de toucher à une hiérarchie de classes. En OOP, cette opération aurait probablement nécessité un diagramme UML, une réunion d’équipe, et un café.

Anatomie d'une entité ECS : même ID, composants différents selon le rôle (NPC vs joueur)
Même entité, rôle différent : on swap un composant, pas une hiérarchie.

30 lignes pour toute la physique

Le VelocitySystem est le système le plus simple du jeu. Il tient en 30 lignes, commentaires inclus :

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;
    }
  }
}

Ce système itère sur tout ce qui a une Position et une Velocity (vaisseaux, projectiles, astéroïdes, débris) en une seule boucle. Un tick, c’est un cycle de la boucle principale : le jeu avance d’un pas de temps (souvent 1/60e de seconde), chaque système s’exécute sur les données qui le concernent, puis on rend le résultat à l’écran. C’est la frame.

Le VelocitySystem ne sait pas ce qu’est un “vaisseau”. Il ne sait même pas qu’il fait tourner un jeu spatial. Il connaît juste des coordonnées et des vitesses — et c’est exactement pour ça qu’il marche.

C’est la puissance de la séparation données/logique : un système ultra-simple qui gère la physique de tout le jeu.

L’IA en plug-in

Le système le plus complexe du jeu, c’est le BehaviorSystem (~480 lignes). Il gère tous les comportements autonomes : patrouille, combat, commerce, extraction minière, défense de station.

Mais il ne code pas ces comportements en dur. Il dispatche vers des RoleHandler pluggables :

behaviorSystem.registerRole(combatPatrolHandler);
behaviorSystem.registerRole(protectHandler);
behaviorSystem.registerRole(traderHandler);
behaviorSystem.registerRole(minerHandler);
behaviorSystem.registerRole(stationDefenseHandler);

Chaque handler est une fonction quasi-pure. Il reçoit un contexte immutable (position, santé, cibles à proximité…) et retourne une décision (nouvel état, cible, tirer ou non). Le système applique la décision.

// Le handler reçoit un snapshot — lecture seule
const ctx = { entityX, entityY, hpFraction, nearestHostile, ... };

// Il décide — pas d'effet de bord
const output = handler.evaluate(state, roleConfig, ctx);

// Le système écrit le résultat
auto.state = output.state;
auto.targetEntity = output.targetEntity;
auto.shouldFire = output.shouldFire;

Pour ajouter un nouveau comportement, disons des pirates qui tendent des embuscades près des routes commerciales (parce que tout bon 4X a besoin de pirates) : il suffit de créer un nouveau handler et de l’enregistrer. Pas besoin de modifier le BehaviorSystem, ni aucun autre système existant.

Et ensuite ?

L’ECS structure la logique du jeu. Mais une question reste ouverte : si les systèmes sont indépendants et ne se connaissent pas, comment communiquent-ils ? Et surtout, qu’est-ce que ce pattern dit aux architectes d’entreprise habitués aux message brokers et aux architectures event-driven ?

La suite explore l’EventBus, la testabilité des règles pures, et le pont entre ECS et architecture d’entreprise.