Technical Deep-Dive
Voxel Structure System
Un système de détection événementielle de structures multibloc, conçu pour un monde voxel multijoueur.
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.

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'ancragePartie 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}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.

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}
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 validationValidatedStructure 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ème | Solution | Résultat |
|---|---|---|
| Vérifier les structures en permanence | Modèle événementiel (OnPlace / OnBreak) | Zéro vérification inutile |
| Comparer tous les blocs à chaque fois | Hash polynomial pré-calculé | Comparaison en O(1) |
| Définir 4 orientations manuellement | GenerateRotations() automatique | ×4 moins de code |
| Conflits entre threads joueurs | ConcurrentDictionary natif | Thread-safe sans lock |
| Chercher dans tout le monde | Cache (position, level) indexé | Lookup direct en O(1) |
| Coordonnées à la main | API 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.