knowledge/README.md
Samuel Bouchet e9f8de4b90 first commit
2026-04-23 16:58:57 +02:00

12 KiB

Programmation: Philosophie et pratiques universelles

Ce fichier est le point d'entrée pour comprendre mon approche de la programmation. Il explicite la philosophie qui guide mes décisions de code, les réflexes qui s'appliquent partout, et renvoie vers les fichiers spécialisés pour les détails.

Contexte : je suis développeur full-stack et développeur de jeux vidéo. Ces pratiques se sont forgées sur des projets commerciaux (Neoproxima chez Lonestone Studio, City Invaders, Manitou Lift and Drift), des projets indés (A Time Paradox, TheRuneMaker), du modding long-terme (mod Minecraft « Capsule », 8.6M+ téléchargements sur CurseForge) et sur les enseignements que je donne en parallèle (back-end, front-end, jeu vidéo, algorithmique). Des articles plus détaillés sur la plupart de ces sujets vivent sur samuel-bouchet.fr et les cours associés sur teachings.samuel-bouchet.fr.

Index des pratiques spécifiques

  • csharp.md — idiomes C#, nullable reference types, async, naming, serialisation
  • unity.md — MonoBehaviour, asmdef, séparation Engine/Client, cycle de vie, éditeur
  • reactive-state.md — Signal/Observable, ConnectedBehaviour, event sourcing, GameEvent
  • code-review.md — revues, pull requests, commits, collaboration
  • a-trier.md — sujets qui ne sont pas encore rangés ailleurs (tests, Docker, documentation LLM-friendly, networking, etc.)

Philosophie en une page

1. L'architecture est un outil de testabilité, pas une fin en soi

Une bonne architecture, c'est une architecture qui rend le code testable sans ouvrir l'éditeur. Concrètement, je sépare la logique pure de la présentation :

  • La logique métier (Engine/) est un projet C# pur, sans aucune dépendance à l'éditeur (Unity, Godot, UE). Elle peut être compilée en netstandard, exécutée en ligne de commande, testée avec NUnit ou xUnit.
  • La présentation (Client/) est éditeur (Unity, Godot, UE, CLI), réactive, passive. Elle lit l'état et émet des intentions, elle n'en mute jamais (sinon des états de pure présentation).
  • Les tests ne se lancent pas dans l'éditeur, ils se lancent avec dotnet test. Si un bug n'est pas reproductible sans l'éditeur, c'est que la séparation est cassée.

Cette séparation permet de faire tourner un bot, un agent LLM, un replay, un serveur autoritaire, le tout avec exactement le même code de logique. Une logique qui doit booter un éditeur éditeur (Unity, Godot, UE) pour être exécutée est prisonnière et lente.

2. Source de vérité unique, explicite, documentée

Pour chaque donnée, je désigne une source de vérité :

  • Les données statiques (définitions de runes, de blocs, de donjons) vivent dans des bases de données statiques ou des factories dans le code directement. (Unity only) J'évite les ScriptableObjects car ils ne sont pas utilisables dans un environnement hors éditeur (serveur, tests).
  • L'état dynamique vit dans un GameState minimal (pas de redondance ou de donnée calculée), serialisable, monolithique, éventuellement observable (selon besoins du projet, Cf. reactive-state.md).
  • Les données dérivées (UI, graphes rendus, animations) sont calculées à partir de l'état, jamais stockées à côté, et invalidées gràce à un système d'observabilité.

3. Mutations canalisées : command pattern + event sourcing

Mon approche rejoint le pattern Black Box Sim formalisé par Brian Cronin : la simulation ne sait rien de l'extérieur, elle reçoit des WorldCommand, mute son état, et émet des WorldEvent. Input → WorldCommand → Sim → WorldEvent → Présentation. Je l'utilisais déjà (Neoproxima, Manitou Lift and Drift, TheRuneMaker) mais le vocabulaire clarifie les discussions.

Je ne laisse jamais un composant muter l'état directement. Toute mutation passe par un événement typé WorldCommand qui :

  • peut être validé avant application (AssertApplicationConditions),
  • produit une trace (WorldEvent ou modification du state observable) qui doit contenir exhaustivement les infos nécessaires à la présentation,
  • est atomique (un lock empêche d'appeler un WorldCommand dans un WorldCommand),
  • est serialisable et donc transportable sur le réseau ou dans un save.

Si je peux écrire state.Gold -= 10 depuis une UI, c'est que j'ai raté quelque chose. Ça doit être un BuyFromShopWorldCommand appliqué via state.ApplyEvent(...). Voir reactive-state.md pour le détail.

4. Sécurité par défaut : le compilateur est mon allié

  • Nullable reference types activés partout (<Nullable>enable</Nullable> ou csc.rsp avec -nullable). Chaque ? et chaque ! est une décision consciente, pas un oubli.
  • Readonly / immutable quand possible : readonly sur les champs, init sur les propriétés, records pour les value objects.
  • Interfaces comme contrats : IGameEvent, INetworkMessage, IUpdatable<T> ne sont pas là pour faire beau, elles sont là pour que le compilateur vérifie l'usage.
  • Attributs de validation runtime pour ce que le compilateur ne peut pas vérifier : [Required] (Odin/Artificetoolkit) sur les références injectées via inspecteur, Odin Validator pour les scènes.

Je préfère une erreur à la compilation qu'une erreur au lancement, et une erreur au lancement qu'une erreur en production.

5. Explicite > implicite

C'est une règle générale qui se décline partout :

  • Nommer pour être lu : un nom long et clair vaut mieux qu'un nom court et cryptique. PickNecklaceStringGameEvent est verbeux mais ambigu pour personne.
  • Conventions strictes et visuelles : public CamelCased, privé _camelCased. L'underscore est un signal visuel de "interne, ne pas toucher depuis l'extérieur".
  • Documenter les intentions, pas les mécaniques : le code dit quoi, le commentaire dit pourquoi. XML doc (///) sur les APIs publiques, commentaires en prose sur la logique métier non triviale.
  • Pas de magic numbers, pas de magic strings : const nommées, enums, factories.
  • État explicitement observable : un champ Signal<T> est plus clair qu'un champ + un événement OnChanged à câbler à la main.

6. Lisibilité et simplicité priment

Extrait de mes propres guidelines : "Code should be as simple as possible, performant, robust, and readable." Dans cet ordre, avec une subtilité : simple et lisible d'abord, performant quand c'est mesuré, robuste par construction.

Concrètement :

  • Je préfère public string Name; à public string Name { get; set; } quand les accessors n'apportent rien.
  • J'évite les abstractions prématurées. Une interface qui n'a qu'une seule implémentation est une interface à supprimer, sauf si elle existe pour la testabilité ou le mock.
  • J'évite les patterns "à la Java" (abstract factory abstract builder, etc.). Un constructor et un static readonly vont plus loin 90% du temps.
  • Je garde les fichiers courts, les classes responsables d'une chose, les méthodes qui tiennent à l'écran.

7. Déterminisme pour pouvoir déboguer

Un bug qu'on ne peut pas reproduire est un bug qu'on ne peut pas corriger. Je construis mes systèmes pour qu'à entrée identique, la sortie soit identique :

  • RNG explicite et seedée : le RNG est passé dans les fonctions, jamais un Random.Range global. Les logs et les replays exposent la seed.
  • Ordre de résolution déterministe : tri topologique d'un DAG (runes), tick ordonné (combat), pipeline à étapes nommées (RECEIVE → GENERATE → TRANSFORM → AMPLIFY).
  • Event sourcing : rejouer la liste d'événements depuis le début doit reproduire exactement le même état.
  • MessagePack avec Union pour le versioning : une sauvegarde d'il y a 6 mois doit pouvoir être rechargée sans perdre de données.

8. Le bon outil pour la bonne couche

Je ne suis pas dogmatique sur la techno, mais j'ai des choix qui s'enchaînent bien :

  • UniTask plutôt que Task côté Unity — zéro-allocation, intégration propre avec le PlayerLoop, cancellation via GetCancellationTokenOnDestroy().
  • MessagePack pour la sérialisation — binaire, rapide, supporte Union pour le versioning.
  • TinkStateSharp (Signal, SignalList, SignalDictionary) pour l'état réactif — observables typées, AutoRun pour auto-subscribe.
  • DOTween Pro pour le tweening d'animations, combiné à TinkStateSharp dans le pattern « animation queue » décrit dans reactive-state.md.
  • Moq + Castle.Core pour les mocks en tests unitaires (voir csharp.md — c'est le setup que j'utilise pour Neoproxima).
  • Odin / Artificetoolkit pour la validation éditeur.
  • Docker pour déploiement serveur, EntityFramework Core pour la DB, .NET 6+ pour le serveur, netstandard 2.1 pour la couche partagée.
  • Unity Cloud Build + fastlane quand iOS est une cible (évite le Mac dédié) — voir a-trier.md.
  • Rider comme IDE — les .DotSettings versionnés encodent les conventions d'équipe.

Le point commun : chaque outil gère bien son rôle et s'arrête là.

9. Documentation qui suit le code

Je documente dans le dépôt, pas ailleurs. Typiquement :

  • Un README.md pour le démarrage et la structure.
  • Un CLAUDE.md qui décrit l'architecture pour un agent (humain ou LLM) qui débarque : où sont les sources de vérité, quels sont les patterns clefs, comment lancer les tests, quelles sont les conventions. Ce fichier est aussi de la doc pour moi dans 6 mois.
  • Un GDD.md / GAME_DESIGN.md pour les décisions de gameplay.
  • Des diagrammes Mermaid inline pour les state machines et les flux — ils vivent dans le markdown, ils se mettent à jour quand le code change.
  • Des specs de protocole (CLI LLM, format de messages) écrites noir sur blanc.

Principe : si un nouveau développeur (ou LLM) ne peut pas comprendre l'architecture en 15 minutes avec le CLAUDE.md, c'est le CLAUDE.md qui est à revoir, pas le développeur.

10. Penser accessibilité par les agents

Depuis quelques projets, je conçois en gardant en tête qu'un LLM peut avoir à lire, modifier, ou piloter mon code :

  • Les CLAUDE.md sont écrits pour les agents autant que pour les humains.
  • Quand un jeu peut tourner en headless (LlmRunner), je l'expose via un protocole stdin/stdout bien délimité (---STATE---, ---ACTIONS---, ---PROMPT---).
  • Les logs sont structurés et utiles, pas du spam.
  • Les seeds, les timeouts, les limites d'actions sont explicites pour que l'exécution soit reproductible et bornée.

C'est un bon forçage : ce qui est lisible par un LLM est lisible par un humain.

Les réflexes au quotidien

Quelques questions que je me pose avant de commit :

  • Est-ce que ça passe sans Unity ? Si la logique dépend d'un GameObject, est-ce qu'elle devrait ?
  • Est-ce que ça mute l'état directement ? Si oui, ça devrait être un GameEvent.
  • Est-ce qu'il y a un nullable warning supprimé sans raison ? Si oui, c'est une dette.
  • Est-ce que le nom reflète l'intention ? Si j'hésite à le lire à voix haute, c'est mauvais signe.
  • Est-ce que je duplique une source de vérité ? Un state.Foo plus un champ local qui le cache = bug potentiel.
  • Est-ce qu'il y a un test qui prouve que ça marche ? Si non, est-ce que je peux en écrire un sans lancer Unity ?
  • Est-ce qu'un LLM pourrait comprendre ce fichier tout seul ? Si non, manque-t-il un commentaire, un lien, un doc ?

Les anti-patterns que je refuse

  • Les mutations d'état depuis la couche présentation.
  • Les singletons statiques mutables (hors DI/factories contrôlées).
  • Les Find* appelés en boucle dans Update().
  • Les tests qui dépendent de l'éditeur Unity quand ils pourraient être purement C#.
  • Les #if UNITY_EDITOR dans la logique métier (tolérés dans du code de debug/assertion).
  • Les commentaires qui paraphrasent le code au lieu d'expliquer le pourquoi.
  • Les classes "Manager" fourre-tout.
  • Les tâches async sans cancellation token quand elles survivent à un lifecycle.
  • Les breaking changes silencieux sur un format sérialisé (saves, réseau) — il faut versionner.