Darkwood Blog Blog
  • Articles
  • Veille
  • Releases
  • Créateurs
fr
  • de
  • en
Connexion
  • Blog
  • Articles
  • Veille
  • Releases
  • Créateurs

🧊 Petites règles, mondes évolutifs : Strata Relay et la messagerie inter-applications avec Symfony 8.2

le 1 novembre 2026

Connectez-vous pour réagir à cet article

🚀 1

La génération procédurale est particulièrement utile lorsqu'elle remplace un contenu répétitif par des règles que les joueurs peuvent comprendre. La variété à elle seule n'est pas suffisante. Un état généré doit encore présenter une décision significative, chaque action doit avoir un champ d'application prévisible, et le système doit être capable de distinguer un puzzle valide d'un agencement aléatoire.

Nous avons construit Strata Relay autour de cette contrainte. C'est un puzzle de terrain au tour par tour pour un écran en mode portrait. Le monde logique s'inscrit dans 125 positions de voxels, mais le joueur ne déplace jamais un personnage à travers celui-ci. Au lieu de cela, chaque tour fait muter une croix locale de colonnes de terrain. Le contour résultant détermine si un signal peut voyager entre deux cristaux fixes.

La même préférence pour des règles explicites aide également lorsque des applications indépendantes échangent des événements. Un petit contrat de message versionné est plus facile à comprendre que des objets d'implémentation partagés ou un flux de chaque changement de terrain. Les fonctionnalités d'interopérabilité du Messenger de Symfony 8.2 fournissent un moyen utile d'étudier cette frontière. Les exemples de messagerie dans cet article sont hypothétiques et restent séparés du jeu fonctionnel.

Un logiciel compact est plus qu'un petit arbre source

Strata Relay génère ses niveaux à partir d'une graine, d'un stade de difficulté et d'un ensemble de règles fixes. Il dispose d'une scène Godot créée et pas d'art ou de son de gameplay importés. Cela nous donne une empreinte d'asset spécifique au jeu petite, mais cela ne rend pas toutes les autres métriques petites.

Nous suivons la compacité comme plusieurs propriétés indépendantes :

  1. Taille du code source : c'est le code que nous maintenons.
  2. Taille des assets créés : cela couvre les niveaux stockés, les images, les modèles et l'audio.
  3. Taille d'exportation : cela inclut le runtime moteur et le packaging.
  4. Mémoire à l'exécution : cela inclut les allocations moteur, navigateur, CPU et GPU.
  5. Coût de génération : cela mesure le travail requis pour créer un niveau.
  6. Profondeur du gameplay : cela décrit les décisions produites par les règles.

La génération procédurale a directement réduit les données de niveau stockées dans ce projet : il n'y a pas de catalogue de tableaux fabriqués à la main. Elle nous a également permis de représenter le tableau à partir de règles de dessin partagées plutôt qu'en important des arts de carreaux. Cela n'a pas supprimé le runtime WebAssembly de Godot, prouvé une faible utilisation de la mémoire, ou automatiquement rendu le puzzle stratégiquement intéressant. Ces questions nécessitent des preuves séparées.

Le déterminisme est la fondation. Un générateur pseudo-aléatoire local transforme la même graine et le même stade en le même chemin, masque de socle, leurres et séquence de mélange. Le redémarrage peut reconstruire le tableau initial sans stocker un niveau sérialisé. New Seed change l'entrée, pas l'algorithme de génération.

class SeedRng:
    var value: int

    func _init(seed: int) -> void:
        value = seed & 0x7fffffff
        if value == 0:
            value = 1

    func next_int() -> int:
        value = int((value * 1103515245 + 12345) & 0x7fffffff)
        return value

Le générateur est constructif. Il crée d'abord un chemin résonant valide, remplit les cellules restantes avec des leurres bas et haut déterministes, ajoute du socle dépendant du stade, puis applique des impulsions locales connues près du chemin. Comme un état de terrain avance à travers trois valeurs, appliquer la même impulsion deux fois inverse une impulsion antérieure. Inverser l'ordre de mélange et doubler chaque impulsion produit une solution témoin.

Cela donne à chaque casse-tête généré au moins un chemin vérifié vers la victoire sans faire tourner un solveur généralisé. Le témoin n'est pas prétendu être le plus court ou unique. Cette distinction est importante : la génération constructive prouve la résolvabilité pour nos règles et notre budget, alors que l'optimalité nécessiterait une recherche bornée et une cible de validation différente.

Un monde 5 × 5 × 5 représenté comme un champ de hauteur

La limite logique est de cinq cellules de large, cinq cellules de profondeur et cinq voxels de haut. Nous le représentons délibérément comme 25 colonnes plutôt qu'un occupation arbitraire de toutes les 125 positions.

Chaque colonne a l'un des trois niveaux de gameplay, mappés à des hauteurs physiques de 1, 3 et 5. Le niveau du milieu est résonant. Une impulsion avance du bas au résonant, du résonant au haut, et du haut au bas. Le wrap est présenté comme un pic se fracturant.

Ce champ de hauteur exclut les grottes, surplombs et blocs flottants. En échange, chaque cellule interactive a une surface supérieure visible. Nous n'avons pas besoin de rotation de caméra, de contrôles de couche ou de règles pour sélectionner des voxels cachés. Le tableau complet reste lisible dans une vue fixe de 648 × 1152 en portrait.

Tableau Strata Relay initial : un champ de hauteur isométrique fixe avec des ancres en cristal doré et cyan

La représentation maintient également l'état compact. Le tableau autorisé contient 25 niveaux ternaires, 25 indicateurs immuables, deux coordonnées d'ancrage, une graine, un stade et des métadonnées de tour. La résonance et la charge sont dérivées au lieu d'être stockées comme des matériaux supplémentaires.

Il n'y a que deux classes de terrain stockées :

  • sol mutable, qui peut être sélectionné et changé ;
  • pierre immuable, utilisée pour les ancres et le socle dépendant du stade.

La résonance cyan est dérivée de la hauteur médiane d'une colonne. Le chemin chargé plus lumineux est dérivé de la connectivité du graphe. Cela évite un état dupliqué obsolète : lorsque les hauteurs changent, le composant connecté est recalculé à partir du tableau qui existe réellement.

La vue isométrique fixe est une géométrie de toile procédurale. Chaque colonne est dessinée à partir d'un diamant supérieur et de deux polygones latéraux, avec des coutures répétées montrant la hauteur. C'est un monde logique de voxels présenté en 2.5D, pas une scène pleine de nœuds de maillage 3D indépendants. À cette taille de tableau, le dessin direct conserve la projection, l'ordre et les polygones de contact en un seul endroit.

Le pipeline de tour : mutation locale, propagation bornée

Un tour change la colonne mutable sélectionnée et ses voisins cardinaux mutables. Au maximum, cinq colonnes peuvent changer. Le socle est ignoré. Le budget d'impulsion diminue une fois, puis le jeu recompute la connectivité et l'état terminal.

La règle autorisée est assez courte pour être inspectée directement :

static func apply_pulse(state: Dictionary, cell: Vector2i) -> Dictionary:
    if state.get("won", false) or state.get("failed", false):
        return {"ok": false, "error": "finished", "state": state, "events": []}
    if not is_inside(cell) or bool(state["immutable"][index_of(cell)]):
        return {"ok": false, "error": "immutable", "state": state, "events": []}

    var next := state.duplicate(true)
    var affected := affected_cells(next, cell)
    var events: Array = []
    for changed in affected:
        var idx := index_of(changed)
        var before := int(next["levels"][idx])
        var after := (before + 1) % 3
        next["levels"][idx] = after
        events.append({"cell": changed, "before": physical_height(before), "after": physical_height(after)})

    next["moves_left"] = int(next["moves_left"]) - 1
    next["turn"] = int(next["turn"]) + 1
    next["last_cell"] = cell
    next["won"] = is_won(next)
    next["failed"] = int(next["moves_left"]) <= 0 and not bool(next["won"])
    return {"ok": true, "error": "", "state": next, "events": events}

Les événements retournés ne déclenchent pas une autre simulation. Ils indiquent à la vue quelles colonnes animer et fournissent leurs hauteurs physiques précédentes et suivantes. La transition d'état reste complète avant que le rendu ne commence. Cette séparation empêche le timing de l'animation de changer les règles.

La propagation des signaux est un remplissage par inondation sur des cellules résonantes orthogonalement adjacentes. Elle commence à la source dorée et réussit lorsque la cible cyan appartient au même composant connecté.

static func charged_cells(state: Dictionary) -> Array[Vector2i]:
    var source: Vector2i = state["source"]
    if int(state["levels"][index_of(source)]) != RESONANT_LEVEL:
        return []

    var found: Array[Vector2i] = [source]
    var queue: Array[Vector2i] = [source]
    while not queue.is_empty():
        var current: Vector2i = queue.pop_front()
        for next in neighbors(current):
            if found.has(next):
                continue
            if int(state["levels"][index_of(next)]) == RESONANT_LEVEL:
                found.append(next)
                queue.append(next)
    return found

C'est la seule étape de propagation. Elle ne peut pas créer une chaîne de mutation illimitée parce qu'elle ne change jamais le terrain. Le parcours examine un maximum de 25 cellules et atteint un résultat stable immédiatement.

La vue met à jour uniquement les colonnes affectées. present() garde les hauteurs d'affichage actuelles, calcule les hauteurs cibles à partir du nouvel état et enregistre les cellules d'événements. _process() interpole ces valeurs et redessine le chemin dérivé. Le monde logique n'est pas régénéré après un tour ; la continuité provient de l'application d'une seule transition déterministe à l'état précédent.

Construction de niveaux solvables sans niveaux stockés

Le générateur construit un chemin de source à cible depuis le bord gauche jusqu'au bord droit. Les stades ultérieurs introduisent des courbes, plus d'impulsions de mélange et des cellules immuables hors du chemin valide. Les cellules hors chemin deviennent des leurres bas ou haut, de sorte qu'un tableau puisse contenir des fragments résonants convaincants mais déconnectés.

Les cinq stades ajoutent chacun une conséquence :

  1. Premier signal : introduit le cycle à trois états avec une impulsion de mélange.
  2. Relais plié : fait tourner le chemin écrit.
  3. Socle : introduit des colonnes immuables que croise une impulsion.
  4. Échos faux : ajoutent plus de leurres et d'effets qui se chevauchent.
  5. Strates profondes : combine cinq opérations de mélange avec six colonnes de socle.

La limite de mouvements est égale à la longueur du témoin : deux impulsions inverses pour chaque impulsion de mélange. Nous vérifions le témoin via apply_pulse(), la même règle publique utilisée par l'entrée du joueur. Il n'y a pas de chemin de mutation réservé aux solveurs alternatifs qui pourrait s'éloigner du jeu.

Le flux de génération résultant est petit et explicite :

seed + stage
    │
    ├─ construire un chemin résonant de source à cible
    ├─ ajouter des leurres et du socle déterministes
    ├─ appliquer des impulsions locales réversibles
    ├─ rejeter un candidat qui est encore résolu
    └─ rejouer le témoin inverse via la règle publique
             │
             └─ état initial jouable

Cette approche était mieux adaptée que la recherche en largeur ou A* pour l'objectif actuel. Vingt-cinq cellules ternaires impliquent déjà jusqu'à 3²⁵ affectations de niveaux bruts avant masques immuables et métadonnées terminales. Une recherche serait raisonnable si nous avions besoin d'une preuve de solutions minimales. Pour cinq courts niveaux d'enseignement, la construction réversible fournit la garantie dont nous avons réellement besoin avec moins de machinerie.

Tableau Strata Relay complété : le composant connecté cyan atteint désormais le cristal cible

Performances et validation

L'implémentation mesurée à l'exécution se compose de rules.gd, board_view.gd et main.gd : 686 lignes de GDScript et 22,202 octets. L'inclusion de deux programmes de vérification et de la benchmark fournit six scripts, 816 lignes et 26,354 octets. Il y a une scène autorisée et aucun asset de gameplay importé.

Sur un hôte de développement Apple M4 fonctionnant sous Godot 4.7.2 :

  • 5,000 générations déterministes ont en moyenne 80.564 microsecondes chacune ;
  • 50,000 mises à jour de terrain locales ont en moyenne 12.346 microsecondes chacune.

Le benchmark de mise à jour inclut la copie complète de l'état des 25 cellules avant d'appliquer une impulsion. Il exclut le rendu et le travail GPU. Ces chiffres décrivent une machine et une construction ; ils ne sont pas des garanties de performance mobile.

La validation a couvert plus que le benchmark. La suite de règles sans tête a complété 557 assertions avec zéro échec, vérifiant les limites, le déterminisme, les limites de hauteur, la localité, les états terminaux et la relecture de témoins. Un test séparé de stress a généré 5,000 casse-têtes à travers les cinq stades. Chaque tableau échantillonné a commencé non résolu et son témoin a atteint la victoire. La boucle d'exécution réelle a également vérifié l'identité de redémarrage et la relecture complète de la solution.

Le test de validation dans le navigateur a chargé l'exportation finale, observé une toile peinte de 648 × 1152, injecté une entrée et détecté un cadre rendu modifié. Les tests de production ont ensuite repéré un problème d'intégration de pointeur que les vérifications de niveau de code avaient raté : l'entrée de la souris de bureau était déjà convertie en contact par le projet, donc le traitement des deux chemins appliquait une impulsion deux fois. La vue publiée accepte le chemin de contact une fois, et les chemins de souris et de contact utilisent maintenant ce chemin.

L'export Web illustre pourquoi la compacité de la source et la taille de la livraison doivent rester des revendications séparées :

  • index.wasm : 39,514,754 octets bruts, 10,084,297 octets avec gzip niveau 9 ;
  • index.pck : 130,544 octets bruts, 113,539 octets avec gzip niveau 9 ;
  • répertoire de construction complet : 39,100 KiB sur disque.

Le moteur WebAssembly domine l'export brut. Le PCK n'est pas non plus un chiffre pur de Strata Relay : il contient du code et des ressources de projet Darkwaar partagés. Nous n'avons pas isolé la mémoire d'exécution spécifique au jeu des allocations de Godot et de navigateur, donc cet article ne fait aucune revendication sur la mémoire.

Les messages inter-applications appartiennent en dehors de la boucle de terrain

Strata Relay fonctionne hors ligne. Sa simulation de terrain, générateur, validation et condition de victoire vivent dans Godot. Envoyer chaque impulsion à travers une frontière d'application introduirait de la latence, des modes de défaillance et un ordre distribué dans une règle qui est déjà déterministe localement.

Une session terminée est un fait d'un genre différent. Dans un système hypothétique multi-application, une application pourrait publier un événement de completion concis et une autre pourrait le consommer pour des statistiques ou de l'automatisation. Cette frontière devrait transporter un contrat explicite, pas une copie sérialisée des internals de chaque application.

Le flux proposé est délibérément grossier :

simulation de jeu local
        │
        │ résumé de session optionnel
        v
frontière d'application ── événement versionné ──> consommateur indépendant

Par exemple, un type de message hypothétique sur le réseau pourrait être game.session.completed.v1. Sa charge utile pourrait contenir un identifiant de jeu, graine, niveau, résultat, nombre de tours et durée. Il ne contiendrait pas l'état des 25 cellules après chaque tour. Le suffixe .v1 rend la compatibilité une décision de contrat explicite : un changement de schéma rompant obtient un nouveau type au lieu de changer silencieusement le sens d'un événement existant.

Le Messenger de Symfony enveloppe normalement un message dans une Envelope et utilise des timbres pour des préoccupations de cadre telles que le routage, les réessais et la sélection de bus. Cela est utile dans un déploiement Symfony unique, mais les classes de timbres sérialisés et les noms de bus sont de mauvais contrats entre des applications indépendantes.

Le sérialiseur d'interopérabilité de Symfony 8.2 aborde cette frontière. messenger.transport.interop_serializer envoie le message JSON sans timbres spécifiques à l'application et ignore les timbres sérialisés entrants. Un nom de type sérialisé stable peut remplacer le nom de classe PHP du producteur. Les applications qui n'utilisent pas PHP peuvent lier la même chaîne de type à leur propre schéma.

# Symfony 8.2+ — configuration illustrative générique
framework:
    messenger:
        identity_stamps: true
        transports:
            shared_events:
                dsn: '%env(EVENT_TRANSPORT_DSN)%'
                serializer: messenger.transport.interop_serializer

Le contrat PHP correspondant serait également versionné explicitement :

use Symfony\Component\Messenger\Attribute\AsMessage;

#[AsMessage(serializedTypeName: 'game.session.completed.v1')]
final readonly class GameSessionCompleted
{
    public function __construct(
        public string $gameId,
        public int $seed,
        public int $stage,
        public string $outcome,
        public int $turns,
        public int $durationMs,
    ) {}
}

Avec identity_stamps activé, le Messenger mappe trois identités utiles à des en-têtes simples :

  • message_id identifie un événement logique à travers des tentatives de livraison ;
  • correlation_id regroupe les messages appartenant à une session ou un flux de publication ;
  • causation_id identifie la commande ou l'événement antérieur qui a produit cet événement.

Ces identifiants résolvent différents problèmes d'observabilité. Réutiliser une valeur pour les trois cacherait la relation entre un événement, son flux de travail et sa cause. Ils ne remplacent également pas les consommateurs idempotents : un récepteur doit toujours décider comment gérer la livraison dupliquée.

L'indépendance du transport ne signifie pas indifférence au transport. JSON et des en-têtes simples empêchent les noms de classes PHP de devenir le contrat public, mais la production a toujours besoin de rétention, d'authentification, d'autorisation, de politique de reprise et d'évolution de schéma. Si la signature des messages est activée, des en-têtes personnalisés doivent être écrits par le sérialiseur pour que la signature les couvre. Les producteurs et consommateurs ont également besoin de niveaux de correctifs Symfony compatibles avant l'introduction de nouvelles formes d'en-têtes signés.

Cet exemple ne décrit pas une intégration de Strata Relay déployée. Le jeu n'émet pas game.session.completed.v1 et ne dépend pas d'un transport de messages. Le contrat existe ici uniquement pour démontrer comment une simulation hors ligne et un événement interopérable peuvent rester des responsabilités distinctes.

Jouez à Strata Relay sur Darkwaar
Découvrez comment chaque mutation de terrain change le puzzle. Jouez directement dans votre navigateur.

Conclusion

Strata Relay est compact car ses règles portent un travail utile. Une graine déterministe reconstruit un plateau. Un brouillage constructif remplace les niveaux stockés tout en préservant une solution témoin. Une règle de mutation locale pilote le jeu, la validation et les démonstrations automatisées. Un parcours en largeur borné dérive le signal visible sans créer une seconde source de vérité.

Les mesures gardent ce succès en proportion. Le GDScript maintenu est petit, la génération et les mises à jour locales sont peu coûteuses sur la machine mesurée, et l'empreinte des ressources créées est minimale. Le build Web est toujours dominée par le moteur, la mémoire d'exécution reste non mesurée, et la résolvabilité ne prouve ni l'optimalité ni la difficulté.

La frontière hypothétique de messagerie suit la même discipline. La simulation locale possède l'évolution du terrain. Une application distincte pourrait échanger un fait de session complétée via un contrat versionné sans prendre en charge la boucle de jeu. De petites règles, un comportement déterministe et des frontières explicites rendent à la fois le jeu et une future intégration plus faciles à tester, expliquer et changer.

Connectez-vous pour réagir à cet article

🚀 1

Site

  • Plan du Site
  • Contact
  • Mentions légales

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, tous droits réservés