21 KiB
reactive-state.md — État réactif et event sourcing
Ce fichier couvre ma façon de gérer l'état dans une application : comment il est stocké, comment il se mute, comment l'UI se synchronise dessus, comment les effets réseau s'intègrent. C'est la mécanique centrale de presque tous mes projets.
Le sujet recoupe deux traditions que j'utilise ensemble : la UI réactive (TinkStateSharp, signals, AutoRun), et l'architecture Black Box Sim formalisée par Brian Cronin (Roguelike Celebration 2024) — commande in, événements out, sim isolée de la présentation. J'ai présenté un état précoce de ce pattern à ADDON 2022 sous l'angle « Rendre réactive sa UI Unity », et je l'ai éprouvé sur Neoproxima, Manitou Lift and Drift, TheRuneMaker, et plusieurs prototypes. Article de référence : samuel-bouchet.fr/posts/2026-04-08-black-box-sim.
Trois couches travaillent ensemble :
- L'état observable —
Signal<T>,SignalList<T>,SignalDictionary<K,V>(TinkStateSharp). Source de vérité. - Les mutations canalisées —
GameEvent/ command pattern. Toute écriture passe par là. - Les souscriptions passives —
AutoRun,ConnectedBehaviour,EventBus. L'UI et les side-effects réagissent aux changements.
Pourquoi ce modèle
J'ai adopté ce pattern après avoir souffert de problèmes classiques :
- État dupliqué qui se désynchronise entre composants.
- Mutations sauvages qui rendent un bug impossible à reproduire.
- UI en décalage parce qu'un appel à
Refresh()est oublié quelque part. - Multijoueur impossible parce qu'il n'y a pas de format canonique pour une action.
- Replay impossible parce qu'on ne sait pas ce qui s'est passé.
Le pattern réactif + event-sourcé résout les cinq d'un coup :
- Un seul état, observable. Pas de cache, pas de copie.
- Toutes les mutations passent par des GameEvent typés. Elles sont validables, traçables, rejouables.
- L'UI est passive, elle réagit automatiquement aux changements via des souscriptions.
- Un GameEvent est serialisable (MessagePack) : il transite en réseau, se sauvegarde, se rejoue.
- Un changelist produit une trace des mutations, qui sert à animer, auditer, synchroniser.
L'état observable : Signal, SignalList, SignalDictionary
J'utilise TinkStateSharp (port C# de tink_state de Haxe). Les primitives clés :
public readonly Signal<string?> Level = new(null);
public readonly Signal<byte> SelectedTool = new(0);
public readonly SignalDictionary<BlockId, int> BlocsInventory = new();
public readonly SignalList<Rune> Inventory = new();
Un Signal<T> est une référence mutable observable. On l'utilise comme une variable, mais tout lecteur abonné est notifié à chaque changement. Idem pour SignalList<T> (ajout/suppression/réorganisation) et SignalDictionary<K,V> (ajout/suppression de clés, mutation de valeurs).
Règles que je m'impose :
- Signals immuables structurellement : je les déclare
readonlypour que la référence ne change jamais. Seul leur contenu évolue. Côté ConnectedBehaviour,Observable<T>expose la version read-only.private Signal<LoadingStage> _currentLoadingStage = new(LoadingStage.NotStarted); public Observable<LoadingStage> CurrentLoadingStage => _currentLoadingStage; - Pas de
Signal<T>dans les payloads des GameEvent — un GameEvent est un message serialisable, pas un conteneur réactif. - Pas d'abonnement hors lifecycle — chaque souscription est enregistrée dans un
CancellationTokenqui se déclenche au reset ou au destroy.
AutoRun : la souscription qui se gère toute seule
AutoRun est le pattern clé : on passe un Action qui lit des Signal, et tink_state track automatiquement les dépendances. Dès qu'un Signal lu change, l'action est rejouée.
this.AutoRun(() => {
StageText.text = GetStageDescription(CurrentLoadingStage.Value);
});
C'est l'équivalent d'un useEffect React sans avoir à déclarer les dépendances. Le tracking est automatique, le cleanup est géré par l'owner (this = un ConnectedBehaviour).
Pattern typique dans un Display :
protected override void OnSetup(GameState state) {
this.AutoRun(() => {
var gold = state.CurrentRun.Gold.Value;
GoldText.text = gold.ToString();
GoldIcon.SetActive(gold > 0);
});
this.AutoRun(() => {
var phase = state.CurrentRun.CurrentPhase;
ShopPanel.SetActive(phase == Phase.Build);
});
}
Chaque AutoRun est indépendant, ne lit que ce qui l'intéresse, et se réexécute uniquement quand ses dépendances changent. Le couplage UI ↔ état est totalement déclaratif.
ConnectedBehaviour : le glue côté Unity
Côté Unity, j'ai une classe de base qui encapsule la plomberie :
- découverte du
GameManager(et donc duGameStateet de l'EventBus) ; - gestion d'un
CancellationTokenreset-able ; - API
ListenEvent<T>,TriggerEvent<T>,DisposeOnReset; OnSetup(GameState)abstrait : point d'entrée pour poser lesAutoRun.
Version générique ConnectedBehaviour<T> pour les composants configurés de l'extérieur :
public abstract class ConnectedBehaviour<T> : ConnectedBehaviour {
public T? Data { get; private set; }
public void Set(T data) {
Data = data;
_resetSource?.Cancel(false);
_resetSource = new CancellationTokenSource();
OnSet(GameManager.State, data);
}
protected abstract void OnSet(GameState state, T element);
}
Chaque appel à .Set(data) cancel les anciennes souscriptions et en crée de nouvelles. Un RuneDisplay reçoit une Rune via .Set(rune), s'abonne aux Signals de cette rune, et quand on le re-Set avec une autre rune, tout se reconnecte proprement.
Voir unity.md pour le détail du cycle de vie Unity de ConnectedBehaviour.
GameEvent : les mutations canalisées
Chaque mutation d'état est un objet qui implémente IGameEvent :
public interface IGameEvent {
Guid GetId();
void Apply(GameState gameState, EventBus? sideEffectManager);
List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus);
}
Une classe abstraite GameEvent s'occupe de la plomberie :
public abstract class GameEvent : IGameEvent {
private readonly Guid _id = Guid.NewGuid();
public Guid GetId() => _id;
public void Apply(GameState gameState, EventBus? eventBus) {
#if UNITY_EDITOR
AssertApplicationConditions(gameState);
#endif
gameState.ApplyEvent(DoApply, eventBus);
}
public List<ChangeEntry> ApplyWithChangelist(GameState gameState, EventBus? eventBus) {
#if UNITY_EDITOR
AssertApplicationConditions(gameState);
#endif
var changelist = new List<ChangeEntry>();
gameState.ApplyEventWithChangelist(
(gs, cl) => DoApplyWithChangelist(gs, cl),
changelist,
eventBus);
return changelist;
}
protected abstract void DoApply(GameState gameState, EventBus? eventBus);
protected virtual void DoApplyWithChangelist(GameState gameState, List<ChangeEntry> changelist) {
DoApply(gameState, null);
}
public abstract void AssertApplicationConditions(in GameState gameState);
}
Les composants ne font que créer un event et appeler .Apply(state) ou passer par l'EventBus. Ils ne mutent jamais state directement.
Pourquoi cette abstraction paye
AssertApplicationConditionsvalide en amont que l'event est applicable (assez d'or, phase correcte, rune connectée, etc.). En#if UNITY_EDITORpour ne pas payer en prod, mais ça attrape les bugs tôt.DoApplyest le seul endroit qui mute l'état. Si un bug d'état apparaît, je sais où chercher : dans lesDoApplydes events pertinents.- Le Guid identifie l'event, utile pour le reporting, le debug, la déduplication en réseau.
- Le changelist produit une trace structurée des mutations pour l'animation / sync.
Conventions de nommage
- Suffixe
GameEventpour chaque classe :PickLootGameEvent,MoveSlotGameEvent,BuyFromShopGameEvent,BackToTitleGameEvent. Verbeux mais jamais ambigu. - Un verbe à l'impératif au début du nom :
Pick,Buy,Move,Apply,Quit. C'est une commande. - Paramètres immuables : champs
readonlyou propriétésinit. Un event une fois créé ne change pas.
ApplyEvent : atomicité et side-effects
GameState.ApplyEvent(...) est le point central d'écriture. Il garantit :
- pas de réentrance — un GameEvent ne peut pas en appliquer un autre pendant son
DoApply; - un lock sur la mutation, pour multithread safe ;
- un callback
OnEventAppliedqui déclenche les side-effects via l'EventBus, après la mutation.
public void ApplyEvent(Action<GameState, EventBus?> apply, EventBus? eventBus) {
lock (_lockObject) {
if (_isApplyingEvent)
throw new ApplicationException("ApplyEvent cannot be nested");
_isApplyingEvent = true;
try {
apply(this, eventBus);
OnEventApplied(eventBus);
} finally {
_isApplyingEvent = false;
}
}
}
Règle que ce pattern impose : pas de logique métier dans les souscriptions aux Signals. Si un changement d'état doit déclencher une autre mutation, c'est un GameEvent qui le fait au niveau supérieur, pas une réaction en chaîne dans un AutoRun.
Changelist : la trace rejouable
Un GameEvent peut produire un changelist : une liste d'entrées typées décrivant ce qui s'est passé. Exemples d'entrées : GoldChangeEntry, RuneMovedEntry, CombatStepEntry, CombatTargetEntry, CombatActionEntry.
protected override void DoApplyWithChangelist(GameState state, List<ChangeEntry> changelist) {
var before = state.CurrentRun.Gold;
state.CurrentRun.Gold -= Cost;
changelist.Add(new GoldChangeEntry(before, state.CurrentRun.Gold, reason: "BuyFromShop"));
// ... autres mutations et entrées
}
Pourquoi c'est utile :
- Animation : le
GameManagerrécupère le changelist et orchestre les animations synchrones au beat. Un combat entier est résolu instantanément côté logique, puis rejoué visuellement.private void HandleChangelist(IReadOnlyList<ChangeEntry> changelist) { var combatEntries = changelist .Where(e => e is CombatStepEntry or CombatTargetEntry or CombatActionEntry or ...) .ToList(); if (combatEntries.Count > 0) StartCoroutine(Orchestrate(combatEntries, token)); } - Synchronisation réseau : le serveur applique, produit le changelist, le broadcast aux clients.
- Debug et replay : on peut rejouer une partie en appliquant les events dans l'ordre, ou inspecter les changelist pour comprendre un état final.
- UI diff : au lieu de tout redessiner, on peut n'animer que ce qui a changé.
EventBus : side-effects découplés
EventBus est un dispatcher typé :
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;
}
Usage :
// Dans un ConnectedBehaviour
ListenEvent<CombatStartedEvent>(e => PlayCombatMusic(e.EncounterIndex));
// Depuis un GameEvent, pendant DoApply
eventBus?.For<CombatStartedEvent>().Trigger(new CombatStartedEvent(encounter.Index));
Distinction avec les Signals :
- Signal = état continu, lu à tout moment, déclenche des réactions quand il change.
- EventBus = événement ponctuel, n'a pas d'état persistant, déclenche des side-effects one-shot (son, particule, notification).
Un CharacterDied est un événement (EventBus). HP est un état (Signal). On ne joue pas un son à chaque tick de HP, on en joue un au CharacterDied.
Sources de vérité : ce qui vit où
Discipline que je m'impose et que je documente en tête de chaque projet :
- Données statiques (définitions, balance) → factories C# pures (
RuneFactory,RuneDungeon,BlockConfigJsonregistry). Immuables. - État dynamique →
GameState(serialisable, observable via Signals). Source unique. - État client local (caméra, sélection UI, préférences) →
ClientStateséparé, non-serialisé. Ne doit jamais contenir de donnée dérivable du GameState. - Vues (UI, rendu) → dérivées au runtime depuis les Signals. Jamais stockées à part.
Une règle concrète qui émerge : si tu peux te passer d'un champ parce qu'il est déjà calculable depuis un autre, supprime-le. Une source de vérité dupliquée est un bug à venir.
State machine : stricte ou lâche
Selon le domaine, j'implémente la machine d'état de deux façons :
State machine stricte
Pour les transitions bien définies (connexion réseau, chargement de jeu, cycle de vie session) : un enum d'états + une méthode Transition(State from, State to) qui valide explicitement.
State machine lâche
Pour les phases de gameplay complexes avec des exceptions et des branchements conditionnels : une Phase enum indicative + des flags implicites (PendingSpecialisationLoot, NecklaceStringBox, LootBox). Les transitions sont déclenchées par des GameEvents ; le client/UI enforce le sequencing, pas l'engine.
Exemple documenté dans le CLAUDE.md de TheRuneMaker :
stateDiagram-v2
[*] --> Build : StartRunGameEvent
state Build {
Deckbuilding --> Deckbuilding : RerollShopGE / BuyFromShopGE / MoveSlotGE
Deckbuilding --> PickSpec : PendingSpecialisationLoot?
}
Build --> Encounter : StartCombatGE
state Encounter {
Loot --> Loot : PickLootGE (items remain)
}
Les "guards" réels sont les flags d'état, pas la Phase elle-même. La Phase aide à la compréhension, mais c'est RunData.Value = null qui signale "partie finie", pas la valeur de Phase.
Ce choix lâche évite la combinatoire des enums de phase mais demande plus de rigueur à la documentation. Je l'assume quand ça reflète fidèlement la complexité du domaine.
Pipelines de calcul : étapes explicites
Quand un calcul combine plusieurs effets (bonus, modifiers, multipliers), je pipeline explicitement en étapes nommées. Exemple : le 4-stage bonus pipeline de TheRuneMaker :
- RECEIVE (baseValue = 0) : collecte des bonus entrants des runes contributrices.
- GENERATE (baseValue = ReceivedValue) : additions flat (+10 Power), mods reçus (+50% Burn).
- TRANSFORM (baseValue = GeneratedValue) : effets multiplicatifs (Efficiency %), critical rolls.
- AMPLIFY (baseValue = TransformedValue) : multiplicateurs finaux.
Chaque étape opère sur la baseValue produite par la précédente. L'ordre est documenté, testable, explicable à un designer.
Pour les DAG de dépendances (graphe de runes), tri topologique pour garantir l'ordre de résolution :
var queue = new Queue<RuneSlotState>(
Edges.Keys.Where(node => inDegree[node] == 0));
var topoOrder = new List<RuneSlotState>();
// ... process queue, decrementing inDegree of neighbors
Le déterminisme du tri + le pipeline nommé = débuggabilité totale.
Animer une UI réactive : le pattern « animation queue »
Une UI purement réactive bindée en AutoRun brille tant que les changements sont ponctuels et indépendants. Dès qu'il faut animer les transitions — a fortiori une séquence "damage → heal → damage" enchaînée dans un même tick — le binding naïf ne suffit plus. Trois techniques, par complexité croissante :
1. Transition depuis la valeur courante
Cas simple : on accepte que la valeur finale soit la seule « vraie », l'animation est une transition entre l'état précédent et le nouvel état. Kill l'ancienne animation avant de lancer la nouvelle :
this.AutoRun(() => {
var hp = state.Hero.Hp.Value;
DOTween.Kill(this);
DOTween.Sequence()
.Append(HealthFill.DOPunchScale(Vector3.one * 0.1f, 0.15f))
.Append(HealthFill.DOAnchorMax(new Vector2(hp / maxHp, 1f), 0.3f));
});
Ça couvre 70% des besoins d'UI feedback.
2. Animation queue + WorldEvents (changelist)
Cas multi-étapes : le GameEvent produit une suite ordonnée de changements qu'on veut jouer dans le temps, pas collapser vers l'état final. Le GameEvent écrit dans un changelist (CombatStepEntry, DamageChange, HealChange) ; le GameManager orchestre le rejeu :
- Le changelist est déjà matérialisé (combat entièrement résolu instantanément côté engine).
- Les components s'abonnent via l'EventBus aux entries qui les concernent.
- L'orchestrateur avance pas à pas, attendant la fin de l'animation la plus lente à chaque step (les subscribers rendent un
UniTaskterminant quand leur animation est finie). - Les animations au sein d'un step sont parallèles entre components ; les steps sont séquentiels.
C'est le pattern nécessaire dès qu'il y a une temporalité dans les mutations (combat au tour par tour, chaînes de dégâts, effets déclenchés). Article : samuel-bouchet.fr/posts/2024-12-10-animating-reactive-ui.
3. Merge strategies et cancellation
Améliorations que j'ajoute quand le besoin apparaît :
- Merge : deux animations ciblant la même propriété dans le même tick sont fusionnées (ou la plus récente gagne, avec un petit DOTween
.Kill()). - Groupement visuel : des entries liées (tous les dégâts d'un coup critique) peuvent être jouées simultanément plutôt que séquentiellement.
- CancellationToken propagé : un rush du joueur (« passer l'animation ») cancel toute la queue et skippe à l'état final.
Observable seul vs WorldEvents : quand choisir quoi
| Besoin | Observable (Signal) | WorldEvent / changelist |
|---|---|---|
| UI bindée 1:1 sur un état courant | ✅ | inutilement lourd |
| Animation entre deux valeurs | ✅ (kill + tween) | overkill |
| Séquence temporelle multi-étapes | ❌ (perd les étapes intermédiaires) | ✅ |
| Replay, networking, bots | ❌ | ✅ |
| Chargement d'un snapshot | ✅ | nécessite rejouer depuis 0 |
Règle pratique : les Signals décrivent « où on en est », les WorldEvents décrivent « ce qui vient de se passer ». Les deux coexistent dans mes projets, avec des responsabilités claires.
Intégration réseau (client-serveur)
Le même event-sourcing fonctionne en multijoueur :
- Les GameEvent sont serialisés en MessagePack et broadcastés.
- Le client applique optimistiquement ses events et les envoie au serveur.
- Le serveur valide via
AssertApplicationConditionspuis re-broadcast. - En cas de désaccord, le client rollback + rejoue dans l'ordre.
Trois types de messages (documentés dans INetworkMessage) :
- GameEvent : mutation bidirectionnelle (client ou serveur), validée par serveur, optimistiquement appliquée côté client.
- Command : client → serveur, demande d'opération. Serveur répond par
AckResponse. - Query : client → serveur, demande de donnée pure (pas de side-effect). Serveur répond par la response appropriée.
Référence : State Synchronization - Gaffer On Games.
Voir aussi a-trier.md pour les détails de la couche InputMessage/OutputMessage et la structure multijoueur.
Règles d'usage au quotidien
- Écrire dans l'état = créer un GameEvent. Jamais d'affectation directe depuis un Display, un Editor script, un bouton UI.
- Lire l'état = souscrire via AutoRun (ou consultation ponctuelle si on est sûr qu'on n'a pas besoin de réactivité).
- Chaque Signal a une raison d'être observable. Si personne ne s'abonne jamais, c'est un champ normal.
- Un
ConnectedBehaviourpar concept affiché, pas par écran. Composition > centralisation. - Toutes les souscriptions sont cancellées au reset ou au destroy. Jamais de leak.
- Un GameEvent est pur dans son
DoApply: il ne fait pas d'I/O, pas d'attente, pas deRandomnon-seedé.
Anti-patterns à éviter
- Abonner un callback Signal sans
CancellationTokenou equivalent — leak garanti. - Muter un Signal depuis un
AutoRunqui observe ce même Signal — boucle infinie. - Dupliquer une valeur de Signal dans un champ local "pour optimiser" — les deux se désynchronisent.
- Appliquer un GameEvent depuis un autre GameEvent's
DoApply— l'atomicité est cassée, l'exception saute. - Mettre de la logique de balance ou de règles dans une callback EventBus — c'est un side-effect, pas une mutation.
- Créer un Signal par valeur dérivable d'un autre — préférer
Observable.Transformou un AutoRun qui met à jour un Signal "vue". - Passer des
Signal<T>comme payload d'un GameEvent — les events sont serialisables, pas les signals.