Technical Deep-Dive

Voxel Structure System

Un système de détection événementielle de structures multibloc, conçu pour un monde voxel multijoueur.

C#
Math
Architecture
Performance
Scroll
💡

Exemple concret → Grinder (FSM)

Le Grinder repose entièrement sur ce système pour détecter et valider sa structure multibloc. C'est le cas d'usage réel présenté en fil rouge.

Présentation

J'ai conçu un système qui permet d'ajouter des constructions fonctionnelles dans un monde voxel. Le joueur pose des blocs dans le bon ordre, et dès que la structure est complète, elle s'active automatiquement. Si un bloc est cassé, elle s'éteint. Tout ça sans jamais faire de vérification en boucle.

Le système est pensé pour être utilisé par d'autres développeurs : définir une nouvelle structure tient en quelques lignes, la validation et le cache sont entièrement automatiques. Un développeur n'a pas besoin de comprendre l'intérieur du système pour s'en servir.

L'objectif était de rendre possible du gameplay basé sur la construction dans un environnement multijoueur avec zéro impact perceptible sur les performances du serveur.

Les contraintes

🧱

Détecter n'importe quelle forme de structure, quelle que soit sa taille ou son orientation

Aucun polling la validation ne se déclenche qu'au moment d'un placement ou d'un cassage de bloc

👥

Thread-safe les événements peuvent arriver en parallèle depuis plusieurs joueurs simultanément

🌍

Compatible chunk-based les structures doivent survivre au chargement et déchargement des chunks

📐

Support des rotations une structure valide dans les 4 orientations sans la redéfinir 4 fois

🛠️

API développeur définir une nouvelle structure doit être simple, lisible et sans boilerplate

Vue d'ensemble

Cinq composants s'enchaînent en pipeline, du schéma déclaratif jusqu'au snapshot immuable de la structure validée.

StructureBuilder Définit le schéma d'une structure (API fluente)
StructurePattern Stocke les blocs + calcule le hash + génère les rotations
StructureValidator Compare les blocs du monde avec le pattern
StructureManager Gère deux caches (potentiel / validé)
ValidatedStructure Snapshot immuable d'une structure confirmée

Partie 1

⚙️ Backend C#

Décrire une structure

Pour qu'un développeur puisse définir une structure sans se noyer dans des tableaux de coordonnées, j'avais besoin d'un format lisible à voix haute. L'idée : décrire la structure comme on la dessinerait mentalement une ligne de blocs, un rectangle, un cadre 3D sans jamais écrire une coordonnée à la main.

Exemple de structure multibloc en jeu
Une structure définie en quelques lignes, lisible d'un coup d'œil

Technique

StructureBuilder expose une API fluente : chaque méthode retourne le builder lui-même, ce qui permet de chaîner les appels. Des helpers mathématiques génèrent les formes courantes (AddRectangle, Add3DRectangleOutline…). SetController désigne le voxel entity le bloc qui porte la logique de la structure et sert de point d'ancrage pour la validation.

1// ExampleStructure.cs  définition déclarative d'une structure en quelques lignes
2private static readonly StructureBuilder Structure = new StructureBuilder("example_structure")
3    .AddRectangleOutline(new Vector3Int(-1, -1,  1), new Vector3Int(1, -1, -1), Blocks.FrameBlock)
4    .AddBlock(new Vector3Int( 0, -1,  0), Blocks.CoreBlock)
5    .AddBlock(new Vector3Int( 0,  0,  0), Blocks.SpecialBlock)
6    .Add3DRectangleOutline(
7        new Vector3Int(-1, 0, 1), new Vector3Int(1, 0, -1),
8        Blocks.CoreBlock,
9        Blocks.FrameBlock
10    )
11    .AddBlock(new Vector3Int(-1,  0,  0), Blocks.ControllerBlock)
12    .AddRectangleOutline(new Vector3Int(-1,  1,  1), new Vector3Int(1,  1, -1), Blocks.FrameBlock)
13    .AddBlock(new Vector3Int( 0,  1,  0), Blocks.CoreBlock)
14    .SetController(new Vector3Int(-1,  0,  0));  // ControllerBlockEntity = point d'ancrage
Aucune coordonnée absolue, aucune boucle manuelle. La définition complète de la structure tient en un bloc cohérent, lisible et modifiable en une passe.

Partie 2

⚙️ Backend C#

Détecter les changements

Quand un joueur pose ou casse un bloc, comment savoir si ça touche une structure ? La mauvaise réponse : vérifier en boucle toutes les N ticks. La bonne : écouter exactement les bons événements, et ne déclencher la validation qu'au moment où c'est pertinent.

Il y a deux points d'entrée distincts. Le voxel entity le bloc contrôleur réagit à son propre placement et au chargement du chunk. Les blocs ordinaires de la structure réagissent via un composant dédié qui localise le contrôleur le plus proche et lui délègue la validation.

Technique

AbstractStructureBlockEntity expose OnPlace et OnLoad tous les deux appellent TryValidateStructure(). Le StructureBlockComponent est accroché sur chaque bloc ordinaire de la structure : à chaque placement ou cassage, il localise le contrôleur le plus proche et lui délègue la logique de validation ou d'invalidation.

1// StructureBlockEntity.cs  le contrôleur réagit à son propre placement
2public override bool OnPlace(Player? player) {
3    bool result = TryValidateStructure();
4    if (!result) {
5        // Structure incomplète → mémoriser comme "potentielle"
6        StructureManager.TryAddPotentialStructure(...);
7    }
8    SetStructureActive(result);
9    return base.OnPlace(player);
10}
11
12// StructureBlockComponent.cs  les blocs ordinaires délèguent au contrôleur
13public sealed class StructureBlockComponent : AbstractBlockComponent {
14    public override void OnPlace(ILevel level, Vector3Int position, ...) {
15        CheckStructure(level, position);           // localise le contrôleur + valide
16    }
17
18    public override void OnBreak(ILevel level, Vector3Int position, ...) {
19        CheckValidatedStructures(level, position); // invalide le cache si nécessaire
20        CheckStructure(level, position);           // re-tente la validation
21    }
22}
✅ Aucun tick, aucune boucle permanente la validation ne se déclenche que quand un bloc change.

Partie 3

⚙️ Backend C#

Valider la structure

Une fois un événement détecté, il faut vérifier si la structure est complète. Le principe : aligner le pattern sur la position du contrôleur, puis comparer bloc par bloc avec ce qui existe réellement dans le monde. Dès qu'un bloc ne correspond pas, on s'arrête pas besoin de tout parcourir.

Alignement du pattern sur le contrôleur
Le pattern est aligné sur le contrôleur, puis comparé bloc par bloc avec le monde

Le hash de structure

Pour éviter de comparer tous les blocs à chaque validation, chaque pattern a un hash polynomial calculé une seule fois au démarrage. Ce hash est normalisé par rapport au contrôleur : deux structures identiques placées à des endroits différents dans le monde produisent le même hash ce qui permet de les reconnaître sans recalcul.

1private long CalculateHash() {
2    // Normalisation : positions relatives au contrôleur → indépendant de la position monde
3    var normalizedBlocks = Blocks.ToDictionary(
4        kvp => kvp.Key - ControllerPosition,
5        kvp => kvp.Value
6    );
7    // Tri déterministe → hash stable quel que soit l'ordre d'insertion
8    var sortedBlocks = normalizedBlocks
9        .OrderBy(kvp => kvp.Key.X)
10        .ThenBy(kvp => kvp.Key.Y)
11        .ThenBy(kvp => kvp.Key.Z)
12        .ToList();
13
14    long hash = 17;
15    foreach (var block in sortedBlocks) {
16        hash = hash * 31 + block.Key.GetHashCode();
17        hash = hash * 31 + block.Value.GetHashCode();
18    }
19    return hash;
20}

La génération des rotations

Un Grinder orienté vers le nord et un Grinder orienté vers l'est ont des coordonnées différentes et donc des hashs différents. Sans prise en charge des rotations, il faudrait décrire la structure 4 fois. Le système génère automatiquement les 4 rotations au démarrage et les indexe par hash, de façon totalement transparente pour le développeur.

1// StructurePattern.cs  génération automatique des 4 rotations au démarrage
2public List<StructurePattern> GenerateRotations() {
3    var rotations = new List<StructurePattern> { this };
4
5    for (int rotation = 1; rotation < 4; rotation++) {
6        var rotatedBlocks = new Dictionary<Vector3Int, Identifier>();
7        var rotatedController = RotatePosition(ControllerPosition, rotation);
8
9        foreach (var block in Blocks) {
10            var rotatedPos = RotatePosition(block.Key, rotation);
11            rotatedBlocks[rotatedPos] = block.Value;
12        }
13
14        rotations.Add(new StructurePattern(
15            $"{Name}_rot{rotation * 90}",
16            Size, rotatedBlocks, rotatedController
17        ));
18    }
19    return rotations;
20}
La structure est définie une seule fois. Les 4 rotations sont générées et indexées automatiquement au démarrage du serveur zéro duplication de code.
Deux patterns identiques à des positions différentes
Même structure, orientations différentes → hashs distincts gérés automatiquement

Partie 4

⚙️ Backend C#

Gérer l'état le cache à deux niveaux

La validation ne se déclenche pas dans le vide : le système doit savoir quelles structures sont déjà actives, et quelles structures sont en cours de formation. Pour ça, il maintient deux caches distincts l'un pour les structures potentielles (pas encore complètes), l'autre pour les structures validées.

Cache Potentiel

Mémorise les structures en cours de formation certains blocs sont posés, mais la structure n'est pas encore complète. Quand un nouveau bloc est posé à proximité, le système sait exactement quoi re-vérifier.

Cache Validé

Stocke les snapshots immuables des structures confirmées. Permet de retrouver instantanément une structure active pour le gameplay sans recalcul, sans re-parcourir le monde.

Technique

StructureManager gère les deux caches via des ConcurrentDictionary indexés par (Vector3Int, string) position du contrôleur et nom du niveau. La clé composite isole correctement les structures par dimension. Pas de lock manuel : les accès concurrents sont gérés nativement.

1// StructureManager.cs  deux caches thread-safe, clé composite par dimension
2ConcurrentDictionary<(Vector3Int, string), Vector3Int>         PotentialStructureCache = new();
3ConcurrentDictionary<(Vector3Int, string), ValidatedStructure> ValidatedStructuresCache = new();
4
5// ValidatedStructure  snapshot immuable, peut circuler librement entre composants
6public string StructureName { get; }          // Nom du pattern validé
7public Vector3Int ControllerPosition { get; } // Position dans le monde
8public long Hash { get; }                     // Empreinte du pattern
9public StructurePattern Pattern { get; }      // Référence au pattern d'origine
10public long ValidationTime { get; }          // Timestamp Unix (ms) de la validation
Pourquoi immuable ? Le ValidatedStructure peut être passé entre systèmes sans risque de modification inattendue. Pas d'effet de bord, pas de condition de course.

Cycle de vie d'une structure

[Bloc posé]
├── TryValidateStructure() Succès
└── ValidatedStructuresCache.TryAdd(position, structure)
└── TryValidateStructure() Échec
└── PotentialStructureCache.TryAdd(positions_voisines, controllerPos)
[Autre bloc posé à proximité]
└── TryAddPotentialStructure() vérifie qu'aucune position n'est déjà occupée
évite les conflits entre deux structures partageant des blocs
[Bloc cassé]
├── ValidatedStructuresCache.TryRemove(position)
└── PotentialStructureCache nettoyé pour toutes les positions du pattern

Récapitulatif technique

ProblèmeSolutionRésultat
Vérifier les structures en permanenceModèle événementiel (OnPlace / OnBreak)Zéro vérification inutile
Comparer tous les blocs à chaque foisHash polynomial pré-calculéComparaison en O(1)
Définir 4 orientations manuellementGenerateRotations() automatique×4 moins de code
Conflits entre threads joueursConcurrentDictionary natifThread-safe sans lock
Chercher dans tout le mondeCache (position, level) indexéLookup direct en O(1)
Coordonnées à la mainAPI StructureBuilder fluente~80 % moins de code par structure

Ce que ce système m'a appris

  • Penser en événements

    Remplacer une boucle permanente par un modèle événementiel réduit drastiquement la charge serveur, sans compromis sur la réactivité. Le serveur ne fait rien à vide.

  • 🏗️

    L'API fluente comme interface développeur

    Une interface bien pensée transforme une définition complexe en code lisible et maintenable et facilite l'adoption par d'autres développeurs sans documentation supplémentaire.

  • 🔑

    Normaliser avant de comparer

    Sans normalisation par rapport au contrôleur, deux structures identiques placées à des endroits différents auraient des hashs différents. La normalisation est la clé pour une comparaison juste et indépendante de la position.

  • 📦

    Le cache à deux niveaux

    Séparer 'est-ce que cette structure pourrait exister ?' de 'est-ce qu'elle existe vraiment ?' clarifie les responsabilités et évite les recalculs inutiles à chaque placement de bloc.

  • 🔒

    L'immuabilité comme contrat

    Un snapshot immuable peut être partagé librement entre composants sans risque d'effet de bord ni de condition de course. Un détail d'implémentation qui simplifie considérablement le reste du système.

© 2026 Nathanaël Rallo. All rights reserved.