Retour au Blog
Game Development24 juillet 202610 min de lecture

Ce que le game dev m'a appris sur l'architecture front-end

DR

Doug Ribeiro

10 min de lecture

Je construis des applications Angular depuis 2017. Je pensais avoir un solide modèle mental de l'architecture de composants, de la gestion d'état et de la réactivité. Puis j'ai commencé à construire un RPG tactique dans Godot 4 avec C#, et tout ce que je pensais savoir a été mis à l'épreuve d'une manière qu'aucun projet d'entreprise n'avait jamais réussi à faire.

Le développement de jeux est impitoyable d'une manière que le développement web ne l'est pas. Une application Angular mal architecturée peut charger lentement ou produire des rendus inattendus. Un jeu mal architecturé plante en plein combat, corrompt l'état de sauvegarde, ou tombe à 10 FPS juste quand un joueur fait son mouvement le plus important. La boucle de retour est immédiate et brutale.

Ce qui m'a surpris n'était pas à quel point l'architecture de jeu est différente de l'architecture front-end : c'était à quel point elles sont similaires. Début 2026, un an après le début de la construction de mon RPG tactique, je voyais les mêmes patterns partout. Systèmes entité-composant, réactivité basée sur les signaux, arbres hiérarchiques, machines à états déterministes. Les noms sont différents mais les problèmes résolus sont identiques.

Systèmes entité-composant vs composants Angular

En développement de jeux, le Entity-Component System (ECS) est un pattern architectural fondamental. Une entité est juste un ID. Un composant est de la donnée pure attachée à cette entité. Les systèmes traitent les entités qui ont des combinaisons de composants spécifiques. Pas d'héritage, pas de couplage fort, pas de hiérarchies de classes profondes.

Godot lui-même n'est pas un moteur ECS. Il compose le comportement à partir de noeuds. Mais quand j'ai commencé à construire des personnages à partir de petits noeuds composants, je me suis dit : c'est juste Angular avec un vocabulaire différent. Les composants Angular ne sont pas de la donnée pure. Ce sont des unités autonomes d'UI et de comportement. Mais la philosophie est la même. Vous composez le comportement par la composition, pas l'héritage. Un personnage dans mon jeu a un MovementComponent, un HealthComponent, un AttackComponent. Un formulaire Angular peut avoir une ValidationDirective, une LoadingDirective, une AccessibilityDirective. Domaine différent, même instinct de composition.

La leçon qui s'est le plus directement transférée : arrêtez de tout mettre au même endroit. Dans mes premières scènes Godot, je construisais des God Nodes : des noeuds uniques qui géraient mouvement, combat, animations et IA d'un coup. J'avais exactement le même problème en Angular quand j'étais développeur junior : des God Components qui faisaient routing, data fetching, validation de formulaire et rendu dans un seul fichier de 800 lignes. La correction est la même dans les deux mondes. Séparer les responsabilités. Faire en sorte que chaque morceau fasse une chose. Les composer au bon niveau.

Arbres de scènes vs arbres de composants

Godot organise tout en un arbre de scènes. Chaque noeud vit quelque part dans une hiérarchie. Une scène peut être instanciée comme enfant d'une autre scène. Vous pouvez sauvegarder un sous-arbre comme sa propre scène et le réutiliser. Ça vous dit quelque chose ? C'est l'arbre de composants Angular. Chaque composant a une place dans la hiérarchie. Un composant peut contenir d'autres composants. Vous pouvez créer des bibliothèques de composants réutilisables.

L'insight pratique que ça m'a donné sur Angular : réfléchissez plus à où dans l'arbre un composant vit, pas juste ce qu'il fait. Dans Godot, si un noeud doit communiquer avec un cousin trois niveaux plus haut et deux niveaux de côté, vous sentez immédiatement la douleur. Le signal doit remonter, être attrapé par un médiateur, puis redescendre. En Angular, on lance nonchalamment des chaînes EventEmitter à travers cinq niveaux de composants et on se demande pourquoi nos applications sont difficiles à maintenir.

Godot vous apprend à concevoir l'arbre intentionnellement. En termes Angular : si deux composants se parlent constamment à travers une hiérarchie profondément imbriquée, ils devraient probablement être frères ou l'un devrait être parent. Restructurez l'arbre avant d'ajouter plus de plomberie de communication.

Concept GodotArbre de scènes (hiérarchie de …Signaux (système d'événements i…Boucle de jeu (_process / _phys…Machine à états (enum + match)Composition de noeuds plutôt qu…Singletons autoloadÉquivalent AngularArbre de composants (hiérarchie…RxJS Subjects / Angular SignalsDétection de changements (Zone.…Reducer NgRx (action → nouvel é…Composition de composants (ng-c…Services injectables (providedI…VS
typescript
// The Angular pattern that game dev instilled in me
// Instead of this: a parent relaying events down and up through 4 levels
@Component({
  selector: 'app-battle-board',
  imports: [UnitGridComponent, UnitRowComponent],
  template: `
    <app-unit-grid
      [units]="units"
      (unitSelected)="onUnitSelected($event)"
    >
      @for (row of rows; track row.id) {
        <app-unit-row
          [row]="row"
          (cellClicked)="onCellClicked($event)"
        ></app-unit-row>
      }
    </app-unit-grid>
  `
})
export class BattleBoardComponent {
  // units, rows, and the handlers relaying events through every level
}

// Do this: hoist shared state to a service and let the tree breathe
@Injectable({ providedIn: 'root' })
export class BattleStateService {
  selectedUnit = signal<Unit | null>(null);
  highlightedCells = signal<Cell[]>([]);

  selectUnit(unit: Unit): void {
    this.selectedUnit.set(unit);
    this.highlightedCells.set(this.calculateMovementRange(unit));
  }
}

Patterns de signaux dans les jeux vs Angular Signals

Godot 4 a un système de signaux de première classe. Les noeuds émettent des signaux. D'autres noeuds s'y connectent. La connexion est découplée. L'émetteur ne sait pas qui écoute. Quand j'ai commencé à utiliser Angular Signals sérieusement, j'ai ressenti une familiarité immédiate. Le modèle mental était déjà câblé par des mois de développement Godot.

Mais le game dev m'a appris quelque chose que la documentation Angular ne souligne pas assez : les signaux sont les plus puissants quand ils représentent de l'état, pas des événements. Dans Godot, j'ai initialement utilisé des signaux pour tout. Une unité bougeait, j'émettais un signal. Un tour se terminait, j'émettais un signal. Une valeur de santé changeait, j'émettais un signal. Le problème, c'est que les abonnés tardifs n'avaient aucun moyen de connaître l'état courant. Ils pouvaient seulement réagir à l'événement, pas interroger la vérité.

Le pattern sur lequel je me suis arrêté dans Godot, puis que j'ai immédiatement appliqué à mon SaaS Angular, a été de faire des signaux la source de vérité et de laisser les valeurs calculées en dériver. Ne signalez pas que la santé a changé. Signalez la valeur de santé actuelle. Laissez tout ce qui se soucie de la santé dériver de cette valeur réactive. C'est exactement ce qu'Angular Signals encourage avec computed() et le graphe réactif. Le game dev m'a fait comprendre pourquoi, pas juste comment.

typescript
// Angular Signals approach informed by game dev thinking
@Injectable({ providedIn: 'root' })
export class UnitStore {
  // Source of truth: state, not events
  private _units = signal<Unit[]>([]);
  private _selectedUnitId = signal<string | null>(null);
  private _currentPhase = signal<BattlePhase>('selection');

  // Derived state: computed automatically, no manual subscriptions
  readonly selectedUnit = computed(() =>
    this._units().find(u => u.id === this._selectedUnitId()) ?? null
  );

  readonly activeUnits = computed(() =>
    this._units().filter(u => u.health > 0)
  );

  readonly canAct = computed(() =>
    this._currentPhase() === 'player-turn' && this.selectedUnit() !== null
  );

  // Actions mutate state, not broadcast events
  selectUnit(id: string): void {
    this._selectedUnitId.set(id);
  }

  advancePhase(): void {
    const phases: BattlePhase[] = ['selection', 'player-turn', 'enemy-turn'];
    const current = this._currentPhase();
    const next = phases[(phases.indexOf(current) + 1) % phases.length];
    this._currentPhase.set(next);
  }
}

Boucles de jeu vs détection de changements

Chaque jeu a une boucle de jeu. Elle vise généralement 60 images par seconde. Chaque frame, elle traite les entrées, met à jour l'état et rend. La question fondamentale que les développeurs de jeux se posent est : quel travail puis-je éviter cette frame ? La performance dans les jeux, c'est savoir sauter des calculs inutiles.

La détection de changements d'Angular est son équivalent de boucle de jeu. Dans une application Zone.js, elle tourne après chaque événement, chaque timer et chaque réponse HTTP. La question fondamentale que les développeurs Angular devraient se poser est la même : quelle détection de changements puis-je sauter ? La réponse en Angular est la stratégie OnPush, les Signals ou les async pipes. Le modèle mental est identique à l'optimisation de boucle de jeu : ne traitez pas ce qui n'a pas changé.

Le game dev m'a rendu impitoyable sur OnPush. Avant, j'utilisais la détection Default parce que c'était plus facile. Après des mois de réflexion sur les budgets de frame et ce que coûte le travail par frame, je ne peux pas écrire un composant sans au moins me demander s'il a besoin d'être en stratégie Default. Presque toujours, la réponse est non. Presque toujours, OnPush avec un store basé sur les signaux est plus propre et plus rapide.

Il y a une autre leçon de boucle de jeu qui s'applique : séparez votre tick de mise à jour de votre tick de rendu. Les jeux mettent à jour la physique et l'IA à un pas de temps fixe, puis interpolent le rendu au rythme que l'écran permet. En termes Angular : séparez votre logique métier de vos déclencheurs de rendu. Ne mettez pas à jour l'état et ne déclenchez pas les mises à jour DOM dans le même bloc synchrone quand vous pouvez les regrouper. C'est exactement ce que fait l'ordonnancement d'effets d'Angular, et pourquoi vous ne devriez pas appeler de mises à jour de signaux dans des boucles serrées sans comprendre comment le batching fonctionne.

Machines à états dans les jeux vs NgRx

Les machines à états finis sont non négociables en développement de jeux. Un personnage a des états : inactif, en mouvement, en attaque, étourdi, mort. Les transitions entre états sont explicites et gardées. Vous ne pouvez pas passer de mort à en attaque. Vous ne pouvez pas passer de en mouvement à en mouvement. La machine à états rend les états impossibles non représentables.

Quand je suis revenu à NgRx après avoir construit la machine à états pour le système de combat de mon jeu, j'ai compris les reducers d'une manière complètement nouvelle. Un reducer est une fonction de transition de machine à états. L'action est l'événement. L'état courant est l'entrée. Le prochain état est la sortie. Le fait que NgRx vous encourage à modéliser tous les états possibles explicitement, loading, loaded, error, c'est exactement la même discipline que le game dev impose avec les états de combat.

La plus grande leçon : modélisez vos états explicitement d'abord, puis écrivez les transitions. En game dev, si vous ne connaissez pas tous vos états d'avance, vous obtenez des bugs impossibles à reproduire parce que vous touchez une transition d'état non modélisée. En Angular, l'équivalent est un comportement indéfini quand un composant reçoit des données dans un ordre que vous n'aviez pas anticipé. Modélisez les états. Rendez les états impossibles impossibles.

csharp
// Game state machine in C# (Godot)
public enum BattlePhase
{
    PlayerSelection,
    PlayerMovement,
    PlayerAttack,
    EnemyTurn,
    Victory,
    Defeat
}

public partial class BattleManager : Node
{
    [Signal]
    public delegate void PhaseChangedEventHandler(int phase);

    private BattlePhase _currentPhase = BattlePhase.PlayerSelection;

    public void TransitionTo(BattlePhase newPhase)
    {
        if (!IsValidTransition(_currentPhase, newPhase))
        {
            GD.PrintErr($"Invalid transition: {_currentPhase} -> {newPhase}");
            return;
        }
        _currentPhase = newPhase;
        EmitSignal(SignalName.PhaseChanged, (int)newPhase);
    }

    private bool IsValidTransition(BattlePhase from, BattlePhase to) => (from, to) switch
    {
        (BattlePhase.PlayerSelection, BattlePhase.PlayerMovement) => true,
        (BattlePhase.PlayerMovement, BattlePhase.PlayerAttack) => true,
        (BattlePhase.PlayerAttack, BattlePhase.EnemyTurn) => true,
        (BattlePhase.EnemyTurn, BattlePhase.PlayerSelection) => true,
        (_, BattlePhase.Victory) => true,
        (_, BattlePhase.Defeat) => true,
        _ => false
    };
}

Budgets de performance comme contrainte de design

En jeux, vous avez 16.67ms par frame à 60 FPS. C'est votre budget. Chaque système, chaque calcul, chaque draw call sort de ce budget. La contrainte est réelle, immédiate et non négociable. Si vous dépassez le budget, le joueur voit un saccadement. Impossible de le cacher.

Le développement web impose rarement cette discipline aussi directement. On a les scores Lighthouse et les Core Web Vitals, mais ce sont des métriques agrégées. Même INP, celui qui mesure les interactions, rapporte une seule valeur par visite de page, pas une contrainte par frame sur l'interaction en cours. Construire un jeu m'a fait commencer à penser à mes applications Angular avec une mentalité similaire de budget par frame. Quel est le coût de ce signal computed ? Quel est le coût de ce pipe qui tourne à chaque cycle de détection de changements ? Quel est le coût de matérialiser cette liste de 10 000 éléments dans le DOM ?

Virtual scrolling, OnPush, lazy loading, deferrable views : ce ne sont pas juste des fonctionnalités Angular. Ce sont des outils de gestion de budget. Le game dev m'a appris à les traiter comme tels.

Séparation des données et de la présentation

L'une des leçons les plus claires du game dev : votre logique de jeu devrait être complètement découplée du rendu. La simulation physique ne se soucie pas de l'apparence du sprite. Le pathfinding de l'IA ne sait pas si le jeu s'affiche en 2D ou 3D. Vous pouvez faire tourner tout le jeu en mode headless pour les tests. La présentation est une couche, pas le coeur.

C'est une discipline qu'Angular supporte bien via les services et les stores réactifs, mais que les développeurs violent souvent en mettant trop de logique dans le TypeScript du composant. Le composant devrait être principalement présentationnel. Il devrait traduire l'état du store en bindings de template. La logique appartient aux services, aux fonctions pures et aux stores.

Ma règle maintenant, dans Godot et Angular : si je ne peux pas tester un morceau de logique sans rien rendre, je l'ai mis au mauvais endroit. Fonctions pures. Services injectés. Machines à états testables. La couche de rendu ne fait que lire et refléter.

Ce que j'ai ramené à mon travail de jour

En travaillant chez BMO sur des applications Angular complexes, j'aborde maintenant les questions d'architecture différemment. Quand une fonctionnalité semble compliquée à implémenter, je demande si l'arbre de composants est structuré pour la supporter. Quand la gestion d'état semble emmêlée, je demande si j'ai modélisé tous les états explicitement. Quand la performance est lente, je pense en termes de budgets et de travail inutile par cycle.

Le développement de jeux est l'une des disciplines d'ingénierie logicielle les plus exigeantes précisément parce que la boucle de retour est tellement impitoyable. Si votre architecture est mauvaise, le jeu vous le dit immédiatement : via des bugs, via des problèmes de performance, via l'impossibilité d'ajouter de nouvelles fonctionnalités sans casser les existantes.

Je ne m'attendais pas à devenir un meilleur développeur Angular en construisant un RPG tactique. Mais la pression inter-domaines d'avoir à prendre de vraies décisions architecturales, pas juste suivre les conventions du framework, s'est avérée exactement ce dont j'avais besoin. Si vous êtes un développeur front-end qui n'a jamais construit de jeu, même petit, je recommande fortement d'essayer. Vous reviendrez avec un vocabulaire différent pour des problèmes que vous résolviez par habitude.

Les meilleures leçons d'architecture viennent des domaines où la mauvaise architecture est immédiatement et douloureusement visible. Le développement de jeux ne pardonne pas la dette architecturale comme les projets web le peuvent parfois.

Références

Game DevelopmentAngularArchitectureGodot