Technical Deep-Dive
Grinder FSM
Une machine multiblock pilotée par une machine à états modulaire, conçue pour un serveur Minecraft multijoueur.
Présentation
J'ai conçu une machine appelée Grinder basée sur une architecture multiblock + machine à états modulaire.
Le Grinder n'est pas un simple bloc : c'est une structure complète de plusieurs blocs validée dynamiquement, avec des modules spécialisés, un système de fuel, des animations d'entités et des transitions d'état.
L'objectif était de créer une mécanique de forge immersive et maintenable sur un serveur multijoueur, où plusieurs joueurs peuvent interagir avec la machine en même temps, sans provoquer d'états incohérents.
Les contraintes
Structure multiblock → valider dynamiquement l'intégrité d'une structure de blocs en monde ouvert
Beaucoup d'états métier → idle, en attente, en cours de forge, résultat prêt, manque de fuel…
Fiabilité multijoueur → plusieurs joueurs interagissent, les transitions doivent rester cohérentes
Évolutif → ajouter de nouveaux modules ou états sans casser l'existant
Léger serveur → pas de polling, uniquement des timers et des événements ciblés
Lisibilité gameplay → le joueur comprend ce qui se passe grâce à des sons, particules et animations
Résultat en jeu
Le grinder en action
Avant de plonger dans l'architecture, voici le grinder en jeu
Partie 1
⚙️ Backend C#La structure multiblock
Le Grinder n'est pas un seul bloc : c'est une structure de plusieurs blocs que le joueur doit construire dans le bon ordre. Si un bloc manque ou est mal placé, la machine s'éteint. Dès que la structure est complète et valide, elle s'active automatiquement.

Technique
La structure est définie de façon déclarative avec un StructureBuilder. À chaque changement dans le monde, le système valide si la structure est toujours intègre via AbstractStructureBlockEntity. Si la validation échoue, OnStructureUnActive() est appelé sur GrinderBlockEntity et tous les modules sont notifiés.
1// Définition déclarative de la structure multiblock
2private static readonly StructureBuilder Structure = new StructureBuilder("grinder")
3 .AddRectangleOutline(new Vector3Int(-1, -1, 1), new Vector3Int(1, -1, -1), Blocks.GrinderFrame)
4 .AddBlock(new Vector3Int(0, -1, 0), Blocks.GrinderCasing)
5 .AddBlock(new Vector3Int(0, 0, 0), Blocks.Lava)
6 .Add3DRectangleOutline(
7 new Vector3Int(-1, 0, 1), new Vector3Int(1, 0, -1),
8 Blocks.GrinderCasing, Blocks.GrinderFrame)
9 .AddBlock(new Vector3Int(-1, 0, 0), Blocks.GrinderForge)
10 .AddRectangleOutline(new Vector3Int(-1, 1, 1), new Vector3Int(1, 1, -1), Blocks.GrinderFrame)
11 .SetController(new Vector3Int(-1, 0, 0)); // bloc contrôleurPartie 2
⚙️ Backend C#Le système de fuel
La machine a besoin d'un carburant pour fonctionner. Le joueur dépose un item compatible en cliquant sur le bloc central. La machine refuse un carburant différent de celui déjà chargé, et affiche une barre de progression dans son interface.
Technique
GrinderBlockEntity.OnActivate(...) capte le clic joueur. Il appelle TryAddFuel(...) qui vérifie si l'item possède un composant GrinderFuelItemComponent. Le carburant est validé (bon type, pas plein), puis AddFuel(...) met à jour la quantité et envoie un packet ciblé à l'UI (barre + label). L'interface ne se reconstruit jamais entièrement : seuls les composants concernés sont mis à jour.
1// Validation du carburant déposé par le joueur
2private AddFuelResult TryAddFuel(ItemStack itemStack, int amount) {
3 if (!itemStack.HasComponent(GrinderFuelComponent.Id))
4 return AddFuelResult.NotFuel;
5
6 var fuel = itemStack.GetComponent<GrinderFuelComponent>(GrinderFuelComponent.Id);
7 int fuelAmount = amount * fuel.FuelAmount;
8
9 if (fuelAmount + _fuelAmount > GetComponent.MaxFuel)
10 return AddFuelResult.MaxFuelReached;
11
12 if (_fuelIdentifier != Items.Air && fuel.FuelIdentifier != _fuelIdentifier)
13 return AddFuelResult.InvalidFuel;
14
15 AddFuel(fuelAmount);
16 return AddFuelResult.Success;
17}
18
19// Mise à jour UI ciblée — 1 packet par composant
20private void AddFuel(int amount) {
21 _fuelAmount = Math.Clamp(_fuelAmount + amount, 0, GetComponent.MaxFuel);
22 _ui.FuelProgress.SeveralContext.StringValue = GetFuelProgress().ToString();
23 _ui.UpdateComponent(_ui.FuelProgress);
24 _ui.FuelAmount.SeveralContext.StringValue = _fuelAmount + "/" + GetComponent.MaxFuel;
25 _ui.UpdateComponent(_ui.FuelAmount);
26}Partie 3
⚙️ Backend C#Les modules et la state machine
Pour gérer des comportements complexes (robinet, table, et potentiellement d'autres modules à venir), j'ai utilisé une machine à états. L'idée : chaque module a des "modes" (au repos, activé, en train de travailler…). Quand un événement arrive (clic joueur, timer serveur, structure cassée), le module passe proprement d'un mode à un autre.

Technique
Trois briques fondamentales :
IGrinderModuleContrat d'un module, expose sa map d'états IReadOnlyDictionary<int, IGrinderModuleState>
IGrinderModuleStateContrat d'un état, expose les hooks du cycle de vie
AbstractGrinderModuleState<T>Classe de base avec implémentations par défaut, évite le boilerplate
Les hooks disponibles dans chaque état
| Hook | Déclencheur |
|---|---|
| OnEnter | Entrée dans l'état |
| OnExit | Sortie de l'état |
| OnUpdate | Tick serveur planifié |
| OnUseOn | Interaction joueur |
| OnBreak | Destruction du bloc |
| OnGrinderStructureActive | Structure devient valide |
| OnGrinderStructureUnActive | Structure devient invalide |
| GetNextState | Décision de transition |
1// Base commune de tous les états
2public abstract class AbstractGrinderModuleState<T>(T module) : IGrinderModuleState
3 where T : IGrinderModule {
4
5 public T Module { get; } = module;
6 public abstract int State { get; }
7
8 // Implémentations par défaut neutres — seules les méthodes utiles sont surchargées
9 public virtual bool OnUpdate(...) => true;
10 public virtual bool OnEnter(...) => true;
11 public virtual void OnExit(...) { }
12 public virtual void OnBreak(...) { }
13 public virtual bool OnUseOn(...) => false;
14 public virtual bool OnGrinderStructureActive(...) => true;
15 public virtual bool OnGrinderStructureUnActive(...) => true;
16
17 // Par défaut : pas de transition
18 public virtual int GetNextState(GrinderModuleContextState ctx, bool context) => State;
19}Partie 4
⚙️ Backend C#Le moteur de transition
Quand un événement arrive (clic, timer, structure inactive), qui décide du prochain mode ? C'est le moteur de transition. Il demande à l'état courant : "étant donné cet événement, quel est le prochain état ?". Si la réponse est différente de l'état actuel, il effectue le changement proprement.
Technique
GrinderModuleBlockComponent est l'orchestrateur. Il capte tous les événements, exécute l'action de l'état courant, puis appelle TryNextState(...). Ce point central garantit que toutes les transitions passent par le même chemin, sans logique dispersée dans le code.
1// Moteur centralisé de transition
2public void TryNextState(IGrinderModuleState moduleState, GrinderModuleContextState ctx,
3 bool context, ILevel level, Vector3Int position, Block block) {
4
5 if (block.Identifier != level.GetBlock(position).Identifier) return; // sécurité monde dynamique
6
7 int next = moduleState.GetNextState(ctx, context);
8 if (next == moduleState.State) return;
9
10 SetState(next, level, block, position, newState: true);
11}
12
13private void ChangeState(int next, int old, ILevel level, Vector3Int position, Block block) {
14 GetModule().States[old].OnExit(level, position, block);
15 bool result = GetModule().States[next].OnEnter(level, position, block);
16
17 // OnEnter peut échouer → relance immédiate (rollback vers Idle possible)
18 TryNextState(GetModule().States[next], GrinderModuleContextState.OnEnter, result, level, position, block);
19}
20
21// Interaction joueur
22public override void OnInteract(..., Player player) {
23 int state = GetState(block);
24 var current = GetModule().States[state];
25 bool result = current.OnUseOn(block, level, position, item, slot, face, player);
26 TryNextState(current, GrinderModuleContextState.OnUseOn, result, level, position, block);
27}OnEnter retourne false, le moteur relance immédiatement une passe de TryNextState — ce qui permet à un état de se "rejeter" lui-même et de tomber sur un état de secours (ex: rollback vers Idle).Partie 5
⚙️ Backend C#Le système d'animations d'entités
Pour rendre la machine vivante, les modules utilisent des entités animées invisibles. Quand le robinet s'active, une entité "rotation" apparaît à sa position. Quand il verse, une entité "coulée de lave" apparaît en dessous. Ces entités durent un certain nombre de ticks, puis disparaissent automatiquement.

Technique
GrinderData.CreateAnimation(...) spawn une entité à une position précise et enregistre un timer dans _moduleTimer. Quand le timer expire, OnUpdate de GrinderBlockEntity déclenche un BlockUpdateScheduled sur le module, ce qui peut provoquer une nouvelle transition d'état. RemoveAnimation(...) détruit l'entité et retire le timer. Aucun polling : tout est piloté par événements.
FaucetModule expose deux animations distinctes :
🔧 CreateUseEntityAnimation
Robinet qui tourne — durée 20 ticks, entité positionnée à +0.86f en Y
🌋 CreateWorkingEntityAnimation
Coulée de lave dans la table — durée 50 ticks, entité à 0.01f en Y
1// Spawn d'une entité animée + timer module
2public void CreateAnimation(Vector3Int position, Vector3 worldPos, Identifier entityId, long ticks) {
3 SetModuleTimer(position, ticks);
4 var entity = GetLevel().SpawnEntity(entityId, new Location(worldPos));
5 _moduleActualAnimation[position] = (entityId, entity.Id);
6}
7
8// Nettoyage propre
9public void RemoveAnimation(Vector3Int position) {
10 _moduleTimer.TryRemove(position, out _);
11 if (!_moduleActualAnimation.TryGetValue(position, out var data)) return;
12 if (GetLevel().GetEntityManager().TryGetEntity(data.EntityId, out var entity)
13 && entity.Identifier == data.Identifier)
14 entity.DespawnFromAll();
15 _moduleActualAnimation.TryRemove(position, out _);
16}
17
18// Les deux animations du robinet
19public static void CreateUseAnimation(GrinderBlockEntity grinder, Vector3Int pos) =>
20 grinder.CreateAnimation(pos, pos + new Vector3(0.5f, 0.86f, 0.5f),
21 Entities.FaucetUseAnimation, FaucetAnimationUseTick); // 20 ticks
22
23public static void CreateWorkingAnimation(GrinderBlockEntity grinder, Vector3Int pos) =>
24 grinder.CreateAnimation(pos, pos + new Vector3(0.5f, -0.01f, 0.5f),
25 Entities.FaucetFlowingAnimation, FaucetAnimationWorkingTick); // 50 ticksPartie 6
⚙️ Backend C#Le système de patterns (table)
La table est le bloc qui reçoit les patterns. Un pattern est un moule : le joueur pose un pattern "Épée" sur la table, le robinet verse la lave, et la machine produit une tête d'épée. Chaque pattern a un coût en fuel différent selon la difficulté de fabrication.

Technique
PatternDescriptor lie un PatternType à son item pattern, son item résultat et son coût en fuel. La liste est déclarée dans TableModule.Patterns. Quand un pattern est posé sur la table, TableModule.SetPattern(...) modifie la block state (PalaModBlockProperties.Pattern) directement dans le monde — pas de NBT nécessaire pour un entier. GetCost(block) lit la block state pour retrouver le descripteur et retourner le coût. HandleDrop(...) adapte ce qu'il drop selon l'état actuel (pattern seul si annulation, output si forge terminée).
1// Donnée complète d'un pattern
2public sealed record PatternDescriptor(
3 PatternType Type, // Enum : Sword, Axe, Hammer…
4 Identifier PatternItem, // Item posé par le joueur
5 Identifier OutputItem, // Item produit par la machine
6 int Cost // Coût en fuel
7);
8
9// Liste des patterns disponibles
10public readonly List<PatternDescriptor> Patterns = [
11 new(PatternType.Sword, Items.PatternSword, Items.HeadSword, Cost: 2),
12 new(PatternType.Pickaxe, Items.PatternPickaxe, Items.HeadPickaxe, Cost: 3),
13 new(PatternType.Hammer, Items.PatternHammer, Items.HeadHammer, Cost: 12),
14 // ...
15];
16
17// Pose du pattern → block state (pas de NBT nécessaire)
18public static void SetPattern(ILevel level, Vector3Int pos, Block block, PatternType pattern) {
19 var newBlock = block.ComputeBlockState(BlockProps.Pattern, (int)pattern).GetBlock();
20 level.SetBlock(newBlock, pos, 0, false);
21}
22
23// Drop adaptatif selon l'état courant
24public void HandleDrop(Block block, ILevel level, Vector3 pos, bool all = false) {
25 if (!TryGetPatternDescriptor(block, out var pattern)) return;
26 bool dropped = false;
27 if (GrinderModuleBlockComponent.GetState(block) == WaitOutput) {
28 level.DropItem(pos + OffsetDrop, new ItemStack(pattern.OutputItem));
29 dropped = true;
30 }
31 if (!dropped || all)
32 level.DropItem(pos + OffsetDrop, new ItemStack(pattern.PatternItem));
33 level.SendChunkSound(pos, Sounds.Pop, 0.8f);
34}Partie 7
⚙️ Backend C#Scénario complet bout-en-bout
Voici le flow réel d'une forge de tête d'épée, du premier clic jusqu'au drop.
EmptyTableState.OnUseOn
IdleFaucetState.OnUseOn
InUseFaucetState.OnUpdate
WorkingTableState.OnUpdate
WaitingForOutputState.OnUseOn
Les feedbacks joueur
Pour que le joueur comprenne ce qui se passe sans interface textuelle, chaque action déclenche un son ou une particule. La machine allumée fait un son de chaudron. La lave qui coule fait un son de lave. Une forge ratée joue un son d'erreur avec une particule rouge.

Technique
GrinderFeedback.cs centralise les feedbacks globaux de la machine. Les feedbacks de modules sont portés par chaque état. Tous utilisent level.SendChunkSound(...) et level.SendChunkParticle(...) qui diffusent aux joueurs proches sans cibler un joueur spécifique.
Sons ciblés par zone
SendChunkSound diffuse le son à tous les joueurs proches, sans paquet individualisé.
Particules contextuelles
Chaque état métier déclenche sa propre particule : lave, fumée, splash Paladium, erreur rouge…
Feedbacks sans interface
Le joueur lit l'état de la machine par les sons et particules, sans texte ni HUD.
GrinderFeedback.cs centralisé
Les feedbacks globaux sont regroupés dans un seul fichier. Les modules portent leurs propres feedbacks.
Design patterns utilisés
| Pattern | Où | Pourquoi |
|---|---|---|
| State | IGrinderModuleState + AbstractGrinderModuleState | Encapsule les comportements par état, transitions propres |
| Strategy | IGrinderModule.States[int] | Chaque état est une stratégie interchangeable |
| Template Method | AbstractGrinderModuleState<T> | Hooks avec implémentations par défaut, évite le boilerplate |
| Builder | StructureBuilder | Définition déclarative et chainée de la structure multiblock |
| Observer | OnGrinderStructureActive / UnActive | Notification de tous les modules lors d'un changement de structure |
| Command | GrinderModuleContextState (enum) | Encapsule le type d'événement déclencheur pour TryNextState |
Ce que ce système m'a appris
- 🧩
La FSM comme outil de maintenabilité
Séparer les comportements par état rend le code lisible : chaque état est autonome, et ajouter un nouvel état ne touche pas aux existants.
- 🏗️
La déclaration structurelle plutôt que l'impératif
Le StructureBuilder permet de définir la structure multiblock en une seule fois, lisible et modifiable sans toucher à la logique de validation.
- ⚡
Événements plutôt que polling
Aucun tick permanent : chaque action est déclenchée par un événement ciblé (clic, timer, notification de structure). Le serveur ne fait rien à vide.
- 🔄
Le rollback comme mécanisme de sécurité
Si OnEnter échoue, le moteur relance TryNextState immédiatement. Cela garantit qu'un état invalide ne persiste jamais, même en cas de race condition.
- 🎮
La lisibilité gameplay par le feedback
Sons et particules remplacent avantageusement une interface textuelle. Le joueur comprend intuitivement l'état de la machine sans lire un seul mot.