đ§ Kleine Regeln, wandelbare Welten: Strata Relay und anwendungsĂŒbergreifende Kommunikation mit Symfony 8.2
vom 1. November 2026
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:
- QuellgröĂe ist der Code, den wir warten.
- Verfasste Asset-GröĂe umfasst gespeicherte Levels, Bilder, Modelle und Audio.
- ExportgröĂe beinhaltet die Engine-Laufzeit und das Packaging.
- Laufzeitspeicher umfasst Zuweisungen fĂŒr Engine, Browser, CPU und GPU.
- Generierungskosten messen den Aufwand zur Erstellung eines Levels.
- 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.

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:
- Erstes Signal fĂŒhrt den Drei-Zustands-Zyklus mit einem Mischimpuls ein.
- Geknickter Relais lÀsst den verfassten Pfad abbiegen.
- Grundgestein fĂŒhrt unverĂ€nderliche SĂ€ulen ein, die ein Impuls ĂŒberquert.
- Falsche Echos fĂŒgt weitere TĂ€uschungen und sich ĂŒberlappende Effekte hinzu.
- 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.

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_ididentifiziert ein logisches Ereignis ĂŒber Zustellversuche hinweg;correlation_idgruppiert Nachrichten, die zu einer Sitzung oder Publikationsfluss gehören;causation_ididentifiziert 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.