21 KiB
a-trier.md — Sujets à redistribuer plus tard
Ce fichier est volontairement un dépotoir organisé. Il contient des pratiques qui n'ont pas encore trouvé leur place dans coding.md, csharp.md, unity.md, reactive-state.md ou code-review.md, mais qui méritent d'être formalisées.
Quand un paragraphe devient trop gros ici, il est temps de le promouvoir dans son propre markdown.
Tests : philosophie et organisation
Ce qu'on teste, ce qu'on ne teste pas
Je teste par ordre de priorité :
- La logique métier pure (Engine, Shared) — obligatoire. Chaque GameEvent a au moins un test. Chaque règle de balance non triviale a son test. Les calculs déterministes (pipeline de bonus, résolution de combat, tri topologique) ont leurs tests.
- Les invariants d'état — qu'un GameState après N events reste cohérent. Tests de fixtures partagées qui valident qu'on ne peut pas finir dans un état interdit.
- Les formats de sérialisation — un round-trip
Serialize → Deserializesur des samples représentatifs, plus des tests de rétrocompatibilité pour les Union versions. - Le code de networking — validation serveur (que les événements impossibles sont rejetés), rollback client.
Je teste peu ou pas :
- Les Display Unity (couverts implicitement par le fait que l'état est testé et que l'UI est passive).
- Les getters/setters triviaux.
- Le code généré (MessagePack formatters, migrations EF).
- Le code tiers.
Structure de test
- Projet
Tests/séparé, référence l'Engine/Shared en tant que projet C# pur. - Lancement en
dotnet test, jamais via Unity Test Runner pour la logique métier. - Fixtures partagées dans une classe
Fixturesstatique :CreateInitialState,TestRunes,ShallowPool. - Convention de nommage :
MethodOrEvent_Scenario_ExpectedResult. - Structure AAA : Arrange, Act, Assert séparés par commentaires.
[TestFixture]
public class GameEventTests {
[Test]
public void PickLootGameEvent_AddsGoldToPlayer() {
// Arrange
var state = Fixtures.CreateInitialState(Fixtures.ShallowPool, Fixtures.TestRunes);
state.CurrentRun.CurrentPhase = Phase.Encounter;
var loot = new Loot(50, false);
state.CurrentRun.LootBox.Add(loot);
// Act
new PickLootGameEvent(loot).Apply(state, null);
// Assert
Assert.That(state.CurrentRun.Gold, Is.EqualTo(initialGold + 50));
}
}
Filtres de test
Pour itérer rapidement :
dotnet test --filter "FullyQualifiedName~CombatTests"
dotnet test --filter "FullyQualifiedName~CombatTests.TestBasicCombat_HeroVsTwoEnemies"
Je profite de TestCase et TestCaseSource (NUnit) pour grouper les variations.
Tests Unity Play Mode
Rares. Uniquement pour ce qui est intrinsèquement Unity (animations chainées, physics, prefabs). Un test Play Mode qui pourrait être un test NUnit est un test mal rangé.
Documentation projet : README, CLAUDE.md, GDD
Je distingue trois types de docs :
README.md
- Pitch du projet en 2-3 lignes.
- Prérequis techniques (.NET, Unity, Node, outils CLI).
- Comment cloner et lancer (
git config core.symlinks,NuGet > Restore,dotnet tool install,mpc -i ...). - Structure de dossiers haut niveau.
- Liens vers les docs plus détaillées.
CLAUDE.md (ou équivalent agent)
Écrit pour qu'un agent (humain ou LLM) débarquant sur le projet puisse contribuer. Contient :
- Project Overview : une ou deux phrases sur ce que fait le projet.
- Commands : les commandes clés (build, test, run, generation).
- Architecture : la structure de dossiers annotée, les couches, les sources de vérité.
- Core Patterns : les patterns centraux (GameEvent, ConnectedBehaviour, pipeline de bonus, etc.).
- Workflow diagrams : mermaid state diagrams pour les machines d'état complexes.
- Coding Standards : les conventions spécifiques au projet (naming, attributs, composition).
- Key Dependencies : la liste des libs et pourquoi elles sont là.
Une règle : si un agent ne peut pas comprendre l'architecture en 15 minutes via ce fichier, c'est le fichier qui est à revoir.
GDD.md / GAME_DESIGN.md (pour un jeu)
- Mécaniques de gameplay (combat, progression, économie).
- Balance (formules, courbes, constantes).
- Systèmes thématiques (arcanes, classes, factions).
- Musique / rythme / animation specs quand c'est intégré au gameplay (comme les 120 BPM ternaires de TheRuneMaker).
Diagrammes Mermaid
J'inline les diagrammes dans le markdown. Ils sont :
- versionés avec le code — pas sur Miro ou LucidChart.
- diffables — un changement de state diagram apparaît clairement en PR.
- mis à jour par celui qui casse l'invariant — si tu modifies la state machine, tu modifies le diagramme.
Exemple type pour une state machine de jeu :
stateDiagram-v2
[*] --> Build : StartRunGameEvent
state Build {
[*] --> Deckbuilding
Deckbuilding --> Deckbuilding : RerollShopGE / BuyFromShopGE / MoveSlotGE
Deckbuilding --> PickSpec : PendingSpecialisationLoot?
}
Build --> Encounter : StartCombatGE
Autre usage : flux client/serveur, pipeline de build, hiérarchie de classes.
Protocoles CLI LLM-friendly
Quand un jeu / système peut tourner en headless, je l'expose via un protocole stdin/stdout structuré pour qu'un LLM (ou un test automatisé) puisse le piloter.
Forme typique
Blocs délimités explicitement :
---STATE---
Phase: Build
String: 0 Encounter: 0
Lives: 3 Gold: 15
Hero: HP=100/100 ATK=10
Rune Graph:
[slot0] FireRune (action=Attack, arcane=Fire, rarity=1) -> [slot3:ActionRune]
Effects: +10 Power
Bench (2 runes, 3 empty):
[b0] EarthRune (action=Value, arcane=Earth, rarity=1)
Shop:
[0] FrostRune (cost=2, action=Value, arcane=Frost)
---END_STATE---
---ACTIONS---
[0] BUY FrostRune (cost=2)
[1] PLACE EarthRune -> slot3
[2] REROLL (cost=10)
[3] FIGHT
---END_ACTIONS---
---PROMPT---
Build action
Enter a number [0-3]:
---END_PROMPT---
L'agent répond par un entier sur stdin. Simple, testable, loggable.
Garde-fous
Un protocole doit avoir des limites dures :
- Timeout : 60 secondes par run.
- Max actions : 2000 décisions avant exit forcé.
- Output cap : 500 KB pour éviter les loops qui spamment.
- Défaut sur EOF : si stdin ferme, l'agent prend automatiquement l'action
0.
Documenté dans le CLAUDE.md du projet à côté du protocole.
Pourquoi ça vaut le coup
- Tests de régression automatisés sur des runs complètes.
- Démo rapide sans client graphique.
- Intégration avec des bots d'évaluation (balance auto-testée).
- Forçage architectural : pour qu'un jeu soit pilotable en CLI, il faut que la logique soit déjà séparée du rendu.
Networking et multijoueur
Strategy de base
Référence que je suis : State Synchronization — Gaffer On Games. Résumé :
- Le serveur est l'autorité ultime sur l'état.
- Les clients appliquent localement leurs actions optimistiquement.
- Le serveur valide et re-broadcast ; en cas de désaccord, client rollback + rejoue.
- La latence est masquée par l'optimistic update, les divergences sont corrigées discrètement.
Types de messages
Trois familles, toutes serialisées en MessagePack, toutes implémentent INetworkMessage :
- GameEvent : mutation bidirectionnelle (peut venir du client ou du serveur), validée par le serveur, applicable optimistiquement côté client.
- Command : client → serveur, demande d'opération (ex. login, demande de blueprint). Serveur répond par un
AckResponse. - Query : client → serveur, demande de lecture sans side-effect. Serveur répond par la donnée.
- Response : serveur → client, réponse à une Query.
Tous les messages dans Shared/Net/Messages/. Un seul endroit, serializable, partagé entre client et serveur.
Architecture serveur
Stack typique :
- ASP.NET Core en .NET 6+ (pour .NET Core Web, pour le DI, pour les middlewares).
- EntityFramework Core pour la DB (SQLite en dev, Postgres en prod).
- MessagePack côté transport.
- Services injectés via
IServiceScopeFactorypour les scopes par requête / par session. UserSessionDatapar utilisateur connecté, indexé parushortshort ID.- Queues thread-safe (
ConcurrentQueue<InputMessage>,ConcurrentQueue<OutputMessage>) entre la couche réseau et la boucle de jeu. - PeriodicTimer pour le tick réseau (1ms) et le backup périodique (5s).
- ServerClock pour la référence temporelle authoritative.
Extrait illustratif :
public class VoxelsEngineServer {
private readonly GameState _state = new();
private readonly GameState _stateBackup = new();
private readonly ConcurrentDictionary<ushort, UserSessionData> _userSessionData = new();
private readonly ConcurrentQueue<InputMessage> _inbox = new();
private readonly ConcurrentQueue<OutputMessage> _outbox = new();
private readonly ConcurrentDictionary<ChunkKey, Chunk> _dirtyChunks = new();
private PeriodicTimer networkingTime = new(TimeSpan.FromMilliseconds(1));
private PeriodicTimer backupTimer = new(TimeSpan.FromSeconds(5));
}
Versioning des messages
Obligatoire. Un client ancien doit pouvoir coexister avec un serveur récent, ou au minimum recevoir un message d'erreur clair plutôt qu'une désérialisation cassée. Voir la section MessagePack dans csharp.md pour le pattern Union.
Docker et déploiement
Dockerfile
Pour un serveur .NET type, un Dockerfile multi-stage (build + runtime) :
- Stage 1 : image
sdkpour restore + build + publish. - Stage 2 : image
aspnet(runtime seule) avec juste les artefacts et les assets nécessaires.
Objectif : image finale légère (~100-200 MB), pas de SDK dans l'image de prod.
docker-compose
J'ai au minimum deux fichiers séparés :
docker_compose_xxx.devtest.yml: pour le staging interne (test, dev partagé).docker_compose_xxx.prod.yml: pour la prod.
Différences typiques : volumes persistants (saves, DB), variables d'environnement (connection strings, ports), replicas, healthchecks.
Les secrets ne sont jamais dans le docker-compose versionné — variables d'environnement, fichiers .env non-commit, ou secret manager.
Déploiement client web
Un build Unity WebGPU + un script .bat qui rsync/scp vers le serveur de staging :
deploy_web_build.bat
Versionné, relu, pas un script bash à part dans le wiki. Si c'est reproductible, c'est dans le repo.
Unity Cloud Build pour iOS
Quand iOS est une cible et que je ne veux pas maintenir un Mac de build :
- Unity Cloud Build (Build Automation) configure un target iOS qui tourne sur les machines mac fournies par Unity.
- Clé App Store Connect API (Key ID + Issuer ID +
.p8) convertie en JSON fastlane-compatible et stockée en variable d'environnement Unity Cloud. - Script post-build qui appelle
fastlane deliverpour pousser l'IPA vers App Store Connect. - Piège : Unity réécrit les
\nde la clé privée en espaces à l'injection, il faut restaurer les newlines dans le script avant de passer la clé à fastlane. - Gestion de version : un
BuildVersionScriptableObject dériveMajor.Minor.Patchdepuis les tags git (v1.0), et le build number depuis le nombre de commits. - Coût : environ 1 €/build iOS — OK pour une cadence humaine (quelques pushs par semaine).
Voir samuel-bouchet.fr/posts/2025-03-24-unity-automated-appstoreconnect-upload pour le détail.
Serialization : au-delà de MessagePack
Même si MessagePack est mon défaut, je choisis selon le contexte :
- MessagePack binaire : réseau, saves, AOT-compatible, versioning via Union. Défaut pour presque tout.
- JSON : config humaine éditable (
appsettings.json), API externe, debug. Newtonsoft pour Unity, System.Text.Json côté .NET pur. .bytesfiles : données pré-calculées (layouts de grid) serializées en binaire, chargées viaResourceLoaderouFile.ReadAllBytes. Plus rapides que JSON, moins lisibles — uniquement pour des données générées..dblog/.dbconf: formats spécifiques à un outil (DebugView++), pas du code maison.
Règle : chaque format sérialisé a sa raison documentée dans le CLAUDE.md du projet.
DebugView++ et logs structurés
Sur les projets serveur ou multijoueur, je configure DebugView++ avec un .dblog versionné pour visualiser les logs en temps réel sur Windows. Le .dbconf contient la configuration des filtres et colonnes.
Les logs eux-mêmes suivent quelques règles :
- Préfixes systématiques :
[Server],[Client],[Net],[GameEvent]— permet le filtrage rapide. - Niveaux explicites :
Debug,Info,Warn,Error— jamais deConsole.WriteLineanonyme en prod. - Contexte dans le message : IDs, counters, valeurs observées. Un log "something went wrong" n'a aucune valeur.
Côté Unity, Debug.Log + un filtre custom de niveau dans la console. Je désactive le spam avec [Conditional("UNITY_EDITOR")] ou via niveaux runtime.
Outillage : Rider, DotSettings, hot-reload
Rider comme IDE
.DotSettingsversionnés : conventions de format, nommage, inspections désactivées, patterns de refactor.- Un fichier
.sln.DotSettingspour les prefs de la solution. - Des
.csproj.DotSettingspar projet quand il y a des règles spécifiques.
Bénéfice : n'importe qui clone et a les bonnes règles dès le premier Ctrl+Alt+L. Pas de débat de formatage en PR.
Unity Hot Reload
Quand possible. Permet de modifier du code pendant le play mode sans recompile complète. Utile pour itérer rapidement sur la balance ou l'UI.
Commandes récurrentes
Je garde à portée dans le README.md / CLAUDE.md :
# Build et test serveur
dotnet build
dotnet test
dotnet test --filter "FullyQualifiedName~CombatTests"
# MessagePack AOT generation
mpc -i D:\projets\TheRuneMaker\Assets -o "Assets/Client/Scripts/MessagePackGenerated.cs"
# EF migrations
dotnet tool install --global dotnet-ef
dotnet ef migrations add <Name>
dotnet ef database update
dotnet ef database drop
# LLM runner
cd LlmRunner
dotnet run -- [seed]
Patterns de performance
Dans l'ordre : mesurer, optimiser le hot path, laisser le reste lisible.
Mesurer d'abord
Stopwatchautour des sections suspectes avant de toucher au code.- Unity Profiler pour les frames hiccups.
BenchmarkDotNetcôté .NET pur quand un algo a vraiment besoin d'être micro-optimisé.
Anti-allocations
Quand c'est un hot path identifié :
forplutôt queforeachsur les collections concrètes.List<T>.GetEnumerator()ne boxe pas, maisIEnumerable<T>.GetEnumerator()si.- ZLinq ou LINQ manuel quand c'est critique.
- Pool d'objets pour les struct fréquentes.
Span<T>/Memory<T>/stackallocpour les buffers temporaires.StringBuilderpour les concaténations en boucle.
Mais : ne pas optimiser avant mesure
95% du code n'est pas hot-path. La lisibilité et la testabilité priment jusqu'au moment où le profiler montre un problème réel.
Cancellation et gestion de durée de vie
Pattern récurrent que je réutilise :
CancellationTokenSourceau niveau d'un lifecycle (GameObject, session, session réseau).CancellationTokenpassé à toutes les tâches async qui peuvent dépasser ce lifecycle.cancellationToken.Register(disposable.Dispose)pour chainer des disposables sur le cancel.gameObject.GetCancellationTokenOnDestroy()pour lier une tâche au destroy Unity.GetCancellationTokenOnEnable()/GetCancellationTokenOnDisable()pour les toggles actifs/inactifs.
Helper DisposeOnReset(IDisposable disposable) dans ConnectedBehaviour pour inscrire automatiquement un cleanup au prochain reset.
Snippets utiles que je réutilise
Throttle
public static Action CreateThrottled(Action action, UniTask delayTask) {
bool throttling = false;
async void ThrottledEventHandler() {
if (throttling) return;
action();
throttling = true;
await delayTask.SuppressCancellationThrow();
throttling = false;
}
return ThrottledEventHandler;
}
Generic EventBus dispatcher
public EventDispatcher<T> For<T>() {
if (_all.TryGetValue(typeof(T), out EventDispatcher obs))
return (EventDispatcher<T>) obs;
var newObs = new EventDispatcher<T>();
_all.Add(typeof(T), newObs);
return newObs;
}
Broadcast filtré (côté serveur)
public void BroadcastBut<T>(Func<T, bool> shouldSkip, T message)
where T : INetworkMessage {
foreach (var session in _userSessionData.Values) {
if (shouldSkip(message)) continue;
_outbox.Enqueue(new OutputMessage(session.ShortId, message));
}
}
VFX fullscreen URP : distortion, secondary camera, RenderTexture
Pattern utilisé sur Powered by Geometry (GMTK 2024) pour un effet de distortion de projectiles, et réutilisable pour tout effet fullscreen en URP (Unity 6).
Le problème
Depuis URP, OnRenderImage n'existe plus. Les effets fullscreen passent par un FullScreenPassRendererFeature configuré dans l'asset Renderer (Renderer2DData en 2D, équivalent en 3D).
Setup
- Ajouter un
FullScreenPassRendererFeaturesur le Renderer asset. - Injection Point :
Before Rendering Post Processing— en amont du bloom, sinon la distortion produit des artefacts dans les zones bloomées. - Fetch Color Buffer : activé pour accéder aux pixels déjà rendus.
- Pass Material : un material shader qui consomme le
URP Sample Buffer(nodeBlitSourcecomme entrée).
Pattern « Secondary Camera + RenderTexture »
Pour localiser l'effet (distortion seulement autour des projectiles, pas sur toute l'image) :
- Créer un layer
Secondary. - Une caméra secondaire configurée avec un Renderer dédié sans post-processing, qui ne voit que le layer
Secondary, et qui sort vers unRenderTextureasset. - La main camera exclut
Secondaryde son culling mask. - Le shader de distortion consomme ce
RenderTextureen paramètre_Secondary.
Encodage de la distortion
Chaque projectile sur le layer Secondary a deux éléments :
- ProjectileMask : sprite noir de la taille du projectile (→ pas de distortion là-dessus).
- Shrink : sprite de distortion, ~3x plus large que le projectile, qui encode les offsets UV dans les canaux R/G :
R = 0.5etG = 0.5= pas de distortion.< 0.5ou> 0.5= offset UV négatif / positif.- Le shader soustrait 0.5 pour normaliser en
[-0.5 ; 0.5], multiplie par unDistortionStrengthparamétrable.
Particle emitters avec couleurs inversées pour les effets de « push » d'explosion. Article détaillé : samuel-bouchet.fr/posts/unity-6-wave-distortion-fullscreen-vfx.
Orthogonal design : combinaisons > additions
Concept repris de vidéos de game design (Dishonored, Left 4 Dead) : au lieu d'empiler des features indépendantes, cibler des mécaniques orthogonales qui se combinent entre elles pour générer de la diversité émergente. N mécaniques orthogonales → N² situations ; N mécaniques additives → N situations.
Règle pragma : quand je conçois un nouveau système, je me demande « avec quelles autres mécaniques déjà en place va-t-il interagir, et ces interactions produiront-elles des situations intéressantes ? ». Si la réponse est « aucune », soit la mécanique est prématurée, soit l'architecture est trop silotée.
Déterminisme en physique 2D (Box2D)
Pour les jeux multijoueurs ou replays où la physique doit être identique sur toutes les machines :
- Désactiver fast-math dans les options du compilateur.
- Désactiver les instructions FMA (Fused Multiply-Add) — elles diffèrent entre CPU et cassent le déterminisme cross-platform.
- Fixer le timestep, éviter les
deltaTimevariables dans les équations de physique.
Ce n'est pas gratuit (~ perte de perf mesurable), à activer uniquement sur les builds où c'est nécessaire.
Pistes à formaliser plus tard
Quand ces sujets auront accumulé suffisamment de matière, ils mériteront leur propre fichier :
testing.md— tests, fixtures, test data builders, philosophy TDD/BDD.networking.md— INetworkMessage, protocole serveur, state sync, latency handling.llm-workflow.md— comment je designe pour l'accessibilité LLM, CLAUDE.md templates, protocoles CLI, garde-fous.serialization.md— MessagePack détaillé, Union versioning, AOT, migrations.deployment.md— Docker, EF migrations, déploiement client web, secrets.documentation.md— structure README/CLAUDE.md/GDD, mermaid, commits liés à la doc.debugging.md— DebugView++, Unity Profiler, logs structurés, reproductibilité.performance.md— profiling, allocations, hot paths, quand optimiser.editor-tooling.md— Odin, Artificetoolkit, custom editors, validators, Rider DotSettings.