Darkwood Blog Blog
  • Artikel
  • Beobachtung
  • Releases
  • Creators
de
  • en
  • fr
Anmeldung
  • Blog
  • Artikel
  • Beobachtung
  • Releases
  • Creators

🧊 Kleine Regeln, wandelbare Welten: Strata Relay und anwendungsübergreifende Kommunikation mit Symfony 8.2

vom 1. November 2026

Anmelden um auf diesen Beitrag zu reagieren

🚀 1

Die prozedurale Generierung ist besonders nützlich, wenn sie wiederholte Inhalte durch Regeln ersetzt, die die Spieler verstehen können. Vielfalt allein reicht nicht aus. Ein generierter Zustand muss weiterhin eine sinnvolle Entscheidung präsentieren, jede Aktion muss einen vorhersehbaren Rahmen haben, und das System muss zwischen einem gültigen Rätsel und einer zufälligen Anordnung unterscheiden können.

Wir haben Strata Relay um diese Einschränkung herum entwickelt. Es ist ein rundenbasiertes Gelände-Rätsel für einen Hochformatbildschirm. Die logische Welt passt in 125 Voxel-Positionen, aber der Spieler bewegt niemals einen Charakter darin. Stattdessen mutiert jede Runde ein lokales Kreuz aus Geländesäulen. Die resultierende Kontur bestimmt, ob ein Signal zwischen zwei fixen Kristallen reisen kann.

Die gleiche Vorliebe für explizite Regeln hilft auch, wenn unabhängige Anwendungen Ereignisse austauschen. Ein kleiner, versionierter Nachrichtenvertrag ist leichter nachzuvollziehen als geteilte Implementierungsobjekte oder ein Strom von allen Geländeveränderungen. Die Interoperabilitätsfunktionen von Symfony 8.2's Messenger bieten eine nützliche Möglichkeit, diese Grenze zu studieren. Die Messaging-Beispiele in diesem Artikel sind hypothetisch und bleiben von dem funktionierenden Spiel getrennt.

Kompakte Software ist mehr als ein kleiner Quellbaum

Strata Relay generiert seine Levels aus einem Seed, einem Schwierigkeitsgrad und einem festen Regelwerk. Es hat eine verfasste Godot-Szene und keine importierten Gameplay-Kunstwerke oder Audio. Das gibt uns einen kleinen spielspezifischen Asset-Footprint, macht jedoch nicht jede andere Kennzahl klein.

Wir verfolgen die Kompaktheit als mehrere unabhängige Eigenschaften:

  1. Quellgröße ist der Code, den wir warten.
  2. Verfasste Asset-Größe umfasst gespeicherte Levels, Bilder, Modelle und Audio.
  3. Exportgröße beinhaltet die Engine-Laufzeit und das Packaging.
  4. Laufzeitspeicher umfasst Zuweisungen für Engine, Browser, CPU und GPU.
  5. Generierungskosten messen den Aufwand zur Erstellung eines Levels.
  6. Gameplay-Tiefe beschreibt die Entscheidungen, die durch die Regeln erzeugt werden.

Die prozedurale Generierung reduzierte die gespeicherte Level-Datenmenge in diesem Projekt direkt: Es gibt keinen Katalog handgefertigter Bretter. Sie ermöglichte es uns auch, das Brett aus gemeinsamen Zeichnungsregeln zu rendern, anstatt Kachelkunst zu importieren. Sie entfernte nicht die Godot WebAssembly-Laufzeit, bewies einen niedrigen Speicherverbrauch oder machte das Rätsel automatisch strategisch interessant. Diese Fragen erfordern separate Beweise.

Determinismus ist die Basis. Ein lokaler pseudo-zufälliger Generator wandelt den gleichen Seed und die gleiche Stufe in denselben Pfad, Grundgesteinsmaske, Täuschungen und Mischfolge um. Ein Neustart kann das ursprüngliche Brett rekonstruieren, ohne ein serialisiertes Level zu speichern. New Seed ändert die Eingabe, nicht den Generierungsalgorithmus.

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

Der Generator ist konstruktiv. Er erstellt zuerst einen gültigen resonanten Weg, füllt die restlichen Zellen mit deterministischen niedrigen und hohen Täuschungen, fügt stufenabhängiges Grundgestein hinzu und wendet dann bekannte lokale Pulsationen in der Nähe des Weges an. Da ein Gelände-Zustand durch drei Werte voranschreitet, kehrt die Anwendung des gleichen Pulses zweimal einen früheren Puls um. Die Umkehrung der Mischreihenfolge und das Verdoppeln jedes Pulses ergeben eine Beweislösung.

Dies gibt jedem generierten Rätsel mindestens einen bestätigten Weg zum Sieg, ohne einen verallgemeinerten Löser auszuführen. Der Beweis wird nicht als kürzester oder einzigartiger Beansprucht. Diese Unterscheidung ist wichtig: Konstruktive Generierung beweist die Lösbarkeit unserer Regeln und unseres Budgets, während Optimalität eine begrenzte Suche und ein anderes Validierungsziel erfordern würde.

Eine 5 × 5 × 5 Welt als Höhenfeld dargestellt

Die logische Grenze ist fünf Zellen breit, fünf Zellen tief und fünf Voxel hoch. Wir stellen sie absichtlich als 25 Säulen dar, anstatt die beliebige Belegung aller 125 Positionen zu verwenden.

Jede Säule hat eines von drei Gameplay-Niveaus, die physischen Höhen von 1, 3 und 5 zugeordnet sind. Die mittlere Ebene ist resonant. Ein Impuls schreitet von niedrig zu resonant, von resonant zu hoch und von hoch zurück zu niedrig. Die Umwicklung wird als ein höhenbrechender Gipfel präsentiert.

Dieses Höhenfeld schließt Höhlen, Überhänge und schwebende Blöcke aus. Im Gegenzug hat jede interaktive Zelle eine sichtbare obere Fläche. Wir benötigen keine Kamerarotation, keine Ebenenkontrollen oder Regeln zur Auswahl versteckter Voxel. Das gesamte Brett bleibt in einer festen 648 × 1152 Hochformatansicht lesbar.

Erstes Strata Relay-Brett: ein festes isometrisches Höhenfeld mit goldenen und cyanfarbenen Kristallankern

Die Darstellung hält auch den Zustand kompakt. Das autoritative Brett enthält 25 ternäre Ebenen, 25 unveränderliche Flags, zwei Ankerkoordinaten, einen Seed, eine Stufe und Metadaten zur Runde. Resonanz und Ladung werden abgeleitet, anstatt als zusätzliche Materialien gespeichert zu werden.

Es gibt nur zwei gespeicherte Terrain-Klassen:

  • veränderlicher Boden, der ausgewählt und verändert werden kann;
  • unveränderlicher Stein, der sowohl für Anker als auch für stufenabhängiges Grundgestein verwendet wird.

Cyanfarbene Resonanz wird aus der mittleren Höhe einer Säule abgeleitet. Der hellere geladene Pfad wird aus der Graph-Konnektivität abgeleitet. Dies vermeidet veraltete Duplikatszustände: Wenn sich Höhen ändern, wird die verbundene Komponente von dem Brett neu berechnet, das tatsächlich existiert.

Die feste isometrische Ansicht ist prozedurale Canvas-Geometrie. Jede Säule wird aus einem oberen Diamanten und zwei seitlichen Polygonen gezeichnet, wobei wiederholte Nähte die Höhe anzeigen. Dies ist eine logische Voxel-Welt, die in 2.5D präsentiert wird, kein Szenen voller unabhängiger 3D-Mesh-Knoten. In dieser Brettgröße bleibt das direkte Zeichnen in einem Ort für Projektion, Anordnung und Touch-Polygonen.

Der Runden-Pipeline: lokale Mutation, begrenzte Ausbreitung

Eine Runde ändert die ausgewählte veränderliche Säule und ihre veränderlichen kardinalen Nachbarn. Höchstens fünf Säulen können sich ändern. Grundgestein wird übersprungen. Das Pulsbudget sinkt einmal, dann berechnet das Spiel die Konnektivität und den Endzustand neu.

Die autoritative Regel ist kurz genug, um sie direkt zu inspectieren:

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}

Die zurückgegebenen Ereignisse treiben keine weitere Simulation voran. Sie teilen der Ansicht mit, welche Säulen animiert werden sollen, und liefern ihre vorherigen und nächsten physischen Höhen. Der Zustandübergang bleibt vollständig, bevor das Rendering beginnt. Diese Trennung verhindert, dass das Timing der Animation die Regeln ändert.

Die Signalverbreitung ist eine Flutfüllung über orthogonal benachbarte resonante Zellen. Sie beginnt an der goldenen Quelle und gelingt, wenn das cyanfarbene Ziel zur gleichen verbundenen Komponente gehört.

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

Dies ist der einzige Verbreitungsschritt. Er kann keine unbegrenzte Mutationskette erzeugen, da er das Terrain niemals ändert. Die Traversierung untersucht maximal 25 Zellen und erreicht sofort ein stabiles Ergebnis.

Die Ansicht aktualisiert nur die betroffenen Säulen. present() hält die aktuellen Anzeigeshöhen, berechnet Zielhöhen aus dem neuen Zustand und zeichnet die Ereigniszellen auf. _process() interpoliert diese Werte und zeichnet den abgeleiteten Pfad neu. Die logische Welt wird nach einer Runde nicht neu generiert; Kontinuität ergibt sich aus der Anwendung eines deterministischen Übergangs auf den vorherigen Zustand.

Bau von lösbaren Stufen ohne gespeicherte Levels

Der Generator konstruiert einen Pfad von der linken Kante zur rechten Kante. Spätere Phasen führen Biegungen, mehr Mischimpulse und unveränderliche Zellen außerhalb des gültigen Pfades ein. Off-Path-Zellen werden zu niedrigen oder hohen Täuschungen, sodass ein Brett überzeugende, aber nicht verbundene resonante Fragmente enthalten kann.

Die fünf Phasen fügen jeweils eine Konsequenz hinzu:

  1. Erstes Signal führt den Drei-Zustands-Zyklus mit einem Mischimpuls ein.
  2. Geknickter Relais lässt den verfassten Pfad abbiegen.
  3. Grundgestein führt unveränderliche Säulen ein, die ein Impuls überquert.
  4. Falsche Echos fügt weitere Täuschungen und sich überlappende Effekte hinzu.
  5. Tiefe Schichten kombiniert fünf Mischoperationen mit sechs Grundgesteinsäulen.

Das Bewegungslimit entspricht der Länge des Zeugen: zwei inverse Pulse für jeden Mischimpuls. Wir verifizieren den Zeugen durch apply_pulse(), die gleiche öffentliche Regel, die auch durch die Eingabe des Spielers verwendet wird. Es gibt keinen alternativen Mutationspfad, der sich vom Spiel abdriften könnte.

Der resultierende Generierungsfluss ist klein und explizit:

seed + stage
    │
    ├─ baue einen resonanten Pfad von der Quelle zum Ziel
    ├─ füge deterministische Täuschungen und Grundgestein hinzu
    ├─ wende umkehrbare lokale Pulse an
    ├─ lehne einen Kandidaten ab, der immer noch gelöst ist
    └─ spiele den inversen Zeugen durch die öffentliche Regel zurück
             │
             └─ spielbarer anfänglicher Zustand

Dieser Ansatz war besser geeignet als eine Breiten- oder A*-Suche für das aktuelle Ziel. Fünfundzwanzig ternäre Zellen implizieren bereits bis zu 3²⁵ rohe Levelzuweisungen, bevor unveränderliche Masken und terminale Metadaten berücksichtigt werden. Eine Suche wäre sinnvoll, wenn wir einen Beweis über minimale Lösungen benötigen würden. Für fünf kurze Lehrstufen bietet die umkehrbare Konstruktion die Garantie, die wir tatsächlich mit weniger Aufwand benötigen.

Abgeschlossenes Strata Relay-Brett: Die cyanfarbene verbundene Komponente erreicht nun den Zielkristall

Leistung und Validierung

Die gemessene Laufzeitimplementierung besteht aus rules.gd, board_view.gd und main.gd: 686 GDScript-Zeilen und 22,202 Bytes. Mit zwei Prüfprogrammen und dem Benchmark ergibt sich eine Gesamtzahl von sechs Skripten, 816 Zeilen und 26,354 Bytes. Es gibt eine verfasste Szene und keine importierten Gameplay-Assets.

Auf einem Apple M4-Entwicklungsrechner, der Godot 4.7.2 ausführt:

  • 5.000 deterministische Generierungen hatten durchschnittlich 80.564 Mikrosekunden pro Stück;
  • 50.000 lokale Geländeveränderungen hatten durchschnittlich 12.346 Mikrosekunden pro Stück.

Der Update-Benchmark umfasst das tiefe Kopieren des Zustands von 25 Zellen, bevor ein Puls angewendet wird. Er schließt Rendering- und GPU-Arbeiten aus. Diese Zahlen beschreiben eine Maschine und einen Build; sie sind keine Garantien für die mobile Leistung.

Die Validierung umfasste mehr als den Benchmark. Die headless Regel-Suite schloss 557 Assertions mit null Fehlern ab, die Grenzen, Determinismus, Höhenlimits, Lokalität, terminale Zustände und Zeugenwiederholung überprüften. Ein separater Stresslauf generierte 5.000 Rätsel über alle fünf Phasen. Jedes ausgewählte Brett begann ungelöst, und sein Zeuge erreichte den Sieg. Die echte Szenenschleife überprüfte auch die Identität des Neustarts und die vollständige Lösungswiederholung.

Der Browser-Rauchtest lud den Release-Export, beobachtete eine bemalte 648 × 1152 Leinwand, injizierte Eingaben und erkannte einen sich ändernden gerenderten Frame. Die Produktionstests entdeckten anschließend ein Problem mit der Integration von Zeigern, das von Codeprüfungen übersehen wurde: Desktop-Maus-Eingaben wurden bereits vom Projekt in Touch umgewandelt, sodass die Verarbeitung beider Pfade einen Puls doppelt anwendete. Die veröffentlichte Ansicht akzeptiert den Touch-Pfad einmal, und sowohl Maus als auch Touch verwenden jetzt diesen Pfad.

Der Webexport veranschaulicht, warum Quellkompaktheit und Liefergröße getrennte Ansprüche bleiben müssen:

  • index.wasm: 39,514,754 Bytes raw, 10,084,297 Bytes mit gzip Level 9;
  • index.pck: 130,544 Bytes raw, 113,539 Bytes mit gzip Level 9;
  • vollständiges Build-Verzeichnis: 39,100 KiB auf der Festplatte.

Die WebAssembly-Engine dominiert den Rohexport. Der PCK ist auch keine reine Zahl von Strata Relay: Er enthält gemeinsamen Darkwaar-Code und Projektressourcen. Wir haben keine spielspezifischen Laufzeitspeicher von Godot und Browser-Zuweisungen isoliert, daher macht dieser Artikel keine Speicheransprüche.

Kreuzanwendungs-Nachrichten gehören außerhalb der Terrain-Schleife

Strata Relay funktioniert offline. Seine Geländesimulation, der Generator, die Validierung und die Gewinnbedingung leben in Godot. Das Senden jedes Pulses über eine Anwendungen-Grenze würde Latenz, Ausfallmodi und verteilte Reihenfolgen in eine Regel einführen, die bereits lokal deterministisch ist.

Eine abgeschlossene Sitzung ist eine andere Art von Fakt. In einem hypothetischen Multi-Anwendungssystem könnte eine Anwendung ein prägnantes Abschlussereignis veröffentlichen und eine andere könnte es für Statistiken oder Automatisierung konsumieren. Diese Grenze sollte einen expliziten Vertrag tragen und nicht eine serialisierte Kopie der internen Strukturen einer der Anwendungen.

Der vorgeschlagene Fluss ist absichtlich grob:

lokale Spielsimulation
        │
        │ optionale Sitzungszusammenfassung
        v
Anwendungsgrenze ── versioniertes Ereignis ──> unabhängiger Verbraucher

Ein hypothetischer Nachrichtentyp könnte beispielsweise game.session.completed.v1 sein. Payload könnte eine Spiel-ID, Seed, Stufe, Ergebnis, Rundenanzahl und Dauer enthalten. Es würde nicht den 25-Zellen-Zustand nach jeder Runde enthalten. Der Suffix .v1 macht die Kompatibilität zu einer expliziten Vertragsentscheidung: Eine brechende Schemaänderung erhält einen neuen Typ, anstatt die Bedeutung eines bestehenden Ereignisses stillschweigend zu ändern.

Symfony Messenger umschließt normalerweise eine Nachricht in einem Envelope und verwendet Stempel für frameworkeigenen Belange wie Routing, Wiederholungen und Busauswahl. Das ist nützlich innerhalb eines Symfony-Deployments, aber serialisierte Stempelklassen und Busnamen sind schwache Verträge zwischen unabhängigen Anwendungen.

Symfony 8.2's Messenger Interoperabilitätsserializer adressiert diese Grenze. messenger.transport.interop_serializer sendet die JSON-Nachricht ohne anwendungsspezifische Stempel und ignoriert eingehende serialisierte Stempel. Ein stabiler serialisierter Typname kann den PHP-Klassennamen des Produzenten ersetzen. Anwendungen, die PHP nicht verwenden, können denselben TypString an ihr eigenes Schema binden.

# Symfony 8.2+ — generische illustrative Konfiguration
framework:
    messenger:
        identity_stamps: true
        transports:
            shared_events:
                dsn: '%env(EVENT_TRANSPORT_DSN)%'
                serializer: messenger.transport.interop_serializer

Der entsprechende PHP-Vertrag wäre ebenfalls ausdrücklich versioniert:

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,
    ) {}
}

Mit aktivierten identity_stamps mappt Messenger drei nützliche Identitäten auf einfache Header:

  • message_id identifiziert ein logisches Ereignis über Zustellversuche hinweg;
  • correlation_id gruppiert Nachrichten, die zu einer Sitzung oder Publikationsfluss gehören;
  • causation_id identifiziert den früheren Befehl oder das Ereignis, das dieses Ereignis erzeugte.

Diese Identifikatoren lösen unterschiedliche Beobachtbarkeitsprobleme. Die Wiederverwendung eines Wertes für alle drei würde die Beziehung zwischen einem Ereignis, seinem Arbeitsablauf und seiner Ursache verbergen. Sie ersetzen auch keine idempotenten Verbraucher: Ein Empfänger muss immer noch entscheiden, wie mit doppelter Lieferung umgegangen wird.

Transportunabhängigkeit beseitigt die Eigenschaften des Transports nicht. JSON und einfache Header verhindern, dass PHP-Klassennamen zum öffentlichen Vertrag werden, aber die Produktion benötigt dennoch Aufbewahrung, Authentifizierung, Autorisierung, Wiederholungsrichtlinien und Schemaevolution. Wenn die Nachrichtensignierung aktiviert ist, müssen benutzerdefinierte gemappte Header vom Serializer geschrieben werden, damit die Signatur sie abdeckt. Produzenten und Verbraucher müssen auch kompatible Symfony-Patchlevel haben, bevor neue signierte Headerformen eingeführt werden.

Dieses Beispiel beschreibt keine implementierte Integration von Strata Relay. Das Spiel gibt weder game.session.completed.v1 aus, noch ist es von einem Nachrichten-Transport abhängig. Der Vertrag existiert hier nur, um zu demonstrieren, wie eine Offline-Simulation und ein interoperables Ereignis getrennte Anliegen bleiben können.

Spielen Sie Strata Relay auf Darkwaar
Entdecken Sie, wie jede Geländemutation das Rätsel verändert. Spielen Sie direkt in Ihrem Browser.

Fazit

Strata Relay ist kompakt, weil seine Regeln nützliche Arbeit leisten. Ein deterministischer Seed rekonstruiert ein Spielfeld. Eine konstruktive Pulsfolge ersetzt gespeicherte Levels und bewahrt eine Lösungssequenz. Eine lokale Mutationsregel steuert Spiel, Validierung und automatisierte Demonstrationen. Eine begrenzte Breitensuche leitet das sichtbare Signal ab, ohne eine zweite Quelle der Wahrheit zu erschaffen.

Die Messungen halten diesen Erfolg im Verhältnis. Der gepflegte GDScript-Code ist klein, Generierung und lokale Updates sind auf dem gemessenen Rechner kostengünstig, und der Umfang der erstellten Assets ist minimal. Der Web-Build wird weiterhin von der Engine dominiert, der Laufzeitspeicher bleibt ungemessen, und Lösbarkeit beweist weder Optimalität noch Schwierigkeit.

Die hypothetische Messaging-Grenze folgt der gleichen Disziplin. Die lokale Simulation besitzt die Terrain-Entwicklung. Eine separate Anwendung könnte einen Fakt zur abgeschlossenen Sitzung durch einen versionierten Vertrag austauschen, ohne das Eigentum an der Spielschleife zu übernehmen. Kleine Regeln, deterministisches Verhalten und explizite Grenzen machen sowohl das Spiel als auch eine zukünftige Integration einfacher zu testen, zu erklären und zu ändern.

Anmelden um auf diesen Beitrag zu reagieren

🚀 1

Site

  • Sitemap
  • Kontakt
  • Impressum

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, alle Rechte vorbehalten