DEV Community

Cover image for 🧊 Petites rĂšgles, mondes Ă©volutifs : Strata Relay et la messagerie inter-applications avec Symfony 8.2
Mathieu Ledru
Mathieu Ledru

Posted on

🧊 Petites rĂšgles, mondes Ă©volutifs : Strata Relay et la messagerie inter-applications avec Symfony 8.2

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)