Technical Deep-Dive

Grinder FSM

Une machine multiblock pilotée par une machine à états modulaire, conçue pour un serveur Minecraft multijoueur.

C#
Architecture
Gameplay
Art
Multijoueur
Scroll

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.

image du grinder
Le Grinder

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ôleur

Partie 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.

image de la table sans pattern avec et sans et avec du liquide dedans
La table dans ses différents états

Technique

Trois briques fondamentales :

IGrinderModule

Contrat d'un module, expose sa map d'états IReadOnlyDictionary<int, IGrinderModuleState>

IGrinderModuleState

Contrat 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

HookDéclencheur
OnEnterEntrée dans l'état
OnExitSortie de l'état
OnUpdateTick serveur planifié
OnUseOnInteraction joueur
OnBreakDestruction du bloc
OnGrinderStructureActiveStructure devient valide
OnGrinderStructureUnActiveStructure devient invalide
GetNextStateDé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}
Rollback automatique : si 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.

image blockbench du faucet avec animation
Le robinet (faucet) modélisé dans Blockbench avec ses animations

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 ticks

Partie 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.

image de la table avec un objet forgé
La table avec un item forgé prêt à être récupéré

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.

1
🧑 Pose le pattern Épée sur la table

EmptyTableState.OnUseOn

SetPattern(PatternType.Sword)
Table → WaitInput
2
🧑 Clique sur le robinet

IdleFaucetState.OnUseOn

Grinder trouvé et actif
OnEnter InUse → CreateUseAnimation()Entité « robinet qui tourne » — 20 ticks · Son : MetalEquip
Robinet → InUse
3
Timer 20 ticks — robinet

InUseFaucetState.OnUpdate

TryGetGrinder → actif
TryGetTable → trouvée
GetCost(Sword) = 2 · GetFuelAmount() ≥ 2
GetState(table) == WaitInput
OnEnter Working → CreateWorkingAnimation()Entité « coulée de lave » — 50 ticks
HandleReduceFuel(grinder) → UI mise à jour
Son lave + particule Working
Table → WorkingRobinet → Idle
4
Timer 80 ticks — table

WorkingTableState.OnUpdate

OnExit IsWorkingSon Fizz · Particule ForgedSmoke
Table → WaitOutput
5
🧑 Clique sur la table

WaitingForOutputState.OnUseOn

HandleDrop → drop HeadSword + PatternSword rendu au joueur
SetPattern(None) → réinitialise la block state
Table → WaitInput

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.

image du grinder quand on ajoute du fuel
Feedback visuel lors de l'ajout de fuel

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

PatternPourquoi
StateIGrinderModuleState + AbstractGrinderModuleStateEncapsule les comportements par état, transitions propres
StrategyIGrinderModule.States[int]Chaque état est une stratégie interchangeable
Template MethodAbstractGrinderModuleState<T>Hooks avec implémentations par défaut, évite le boilerplate
BuilderStructureBuilderDéfinition déclarative et chainée de la structure multiblock
ObserverOnGrinderStructureActive / UnActiveNotification de tous les modules lors d'un changement de structure
CommandGrinderModuleContextState (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.

© 2026 Nathanaël Rallo. All rights reserved.