🧊 Small Rules, Evolving Worlds: Strata Relay and Cross-Application Messaging with Symfony 8.2
on November 1, 2026
Procedural generation is most useful when it replaces repeated content with rules that players can understand. Variety alone is not enough. A generated state must still present a meaningful decision, every action must have a predictable scope, and the system must be able to distinguish a valid puzzle from a random arrangement.
We built Strata Relay around that constraint. It is a turn-based terrain puzzle for a portrait screen. The logical world fits inside 125 voxel positions, but the player never moves a character through it. Instead, each turn mutates a local cross of terrain columns. The resulting contour determines whether a signal can travel between two fixed crystals.
The same preference for explicit rules also helps when independent applications exchange events. A small, versioned message contract is easier to reason about than shared implementation objects or a stream of every terrain change. Symfony 8.2's Messenger interoperability features provide a useful way to study that boundary. The messaging examples in this article are hypothetical and remain separate from the working game.
Compact software is more than a small source tree
Strata Relay generates its levels from a seed, a difficulty stage and a fixed ruleset. It has one authored Godot scene and no imported gameplay art or audio. That gives us a small game-specific asset footprint, but it does not make every other metric small.
We track compactness as several independent properties:
- Source size is the code we maintain.
- Authored asset size covers stored levels, images, models and audio.
- Export size includes the engine runtime and packaging.
- Runtime memory includes engine, browser, CPU and GPU allocations.
- Generation cost measures the work required to create a level.
- Gameplay depth describes the decisions produced by the rules.
Procedural generation directly reduced stored level data in this project: there is no catalogue of handcrafted boards. It also allowed us to render the board from shared drawing rules instead of importing tile art. It did not remove the Godot WebAssembly runtime, prove low memory use, or automatically make the puzzle strategically interesting. Those questions require separate evidence.
Determinism is the foundation. A local pseudo-random generator turns the same seed and stage into the same path, bedrock mask, decoys and scramble sequence. Restart can reconstruct the initial board without storing a serialized level. New Seed changes the input, not the generation algorithm.
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
The generator is constructive. It creates a valid resonant route first, fills the remaining cells with deterministic low and high decoys, adds stage-dependent bedrock, then applies known local pulses near the route. Because a terrain state advances through three values, applying the same pulse twice reverses one earlier pulse. Reversing the scramble order and doubling each pulse produces a witness solution.
This gives every generated puzzle at least one verified route to victory without running a generalized solver. The witness is not claimed to be shortest or unique. That distinction matters: constructive generation proves solvability for our rules and budget, while optimality would require a bounded search and a different validation target.
A 5 × 5 × 5 world represented as a heightfield
The logical bound is five cells wide, five cells deep and five voxels high. We deliberately represent it as 25 columns rather than arbitrary occupancy of all 125 positions.
Each column has one of three gameplay levels, mapped to physical heights 1, 3 and 5. The middle level is resonant. A pulse advances low to resonant, resonant to high, and high back to low. The wrap is presented as a peak fracturing.
This heightfield excludes caves, overhangs and floating blocks. In return, every interactive cell has a visible top surface. We do not need camera rotation, layer controls or rules for selecting hidden voxels. The complete board remains readable in a fixed 648 × 1152 portrait view.

The representation also keeps state compact. The authoritative board contains 25 ternary levels, 25 immutable flags, two anchor coordinates, a seed, a stage and turn metadata. Resonance and charge are derived instead of stored as additional materials.
There are only two stored terrain classes:
- mutable soil, which may be selected and changed;
- immutable stone, used for both anchors and stage-dependent bedrock.
Cyan resonance is derived from a column's middle height. The brighter charged route is derived from graph connectivity. This avoids stale duplicated state: when heights change, the connected component is recomputed from the board that actually exists.
The fixed isometric view is procedural Canvas geometry. Each column is drawn from a top diamond and two side polygons, with repeated seams showing height. This is a logical voxel world presented in 2.5D, not a scene full of independent 3D mesh nodes. At this board size, direct drawing keeps projection, ordering and touch polygons in one place.
The turn pipeline: local mutation, bounded propagation
A turn changes the selected mutable column and its mutable cardinal neighbours. At most five columns can change. Bedrock is skipped. The pulse budget decreases once, then the game recomputes connectivity and terminal state.
The authoritative rule is short enough to inspect directly:
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}
The returned events do not drive another simulation. They tell the view which columns to animate and provide their previous and next physical heights. The state transition remains complete before rendering begins. That separation prevents animation timing from changing the rules.
Signal propagation is a flood-fill over orthogonally adjacent resonant cells. It begins at the gold source and succeeds when the cyan target belongs to the same connected component.
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
This is the only propagation step. It cannot create an unbounded mutation chain because it never changes terrain. The traversal examines a maximum of 25 cells and reaches a stable result immediately.
The view updates only the affected columns. present() keeps current display heights, computes target heights from the new state and records the event cells. _process() interpolates those values and redraws the derived route. The logical world is not regenerated after a turn; continuity comes from applying one deterministic transition to the previous state.
Building solvable stages without stored levels
The generator constructs a source-to-target path from the left edge to the right edge. Later stages introduce bends, more scramble pulses and immutable cells outside the valid path. Off-path cells become low or high decoys, so a board can contain convincing but disconnected resonant fragments.
The five stages each add one consequence:
- First signal introduces the three-state cycle with one scramble pulse.
- Bent relay makes the authored route turn.
- Bedrock introduces immutable columns that a pulse cross skips.
- False echoes adds more decoys and overlapping effects.
- Deep strata combines five scramble operations with six bedrock columns.
The move limit equals the witness length: two inverse pulses for each scramble pulse. We verify the witness through apply_pulse(), the same public rule used by player input. There is no alternate solver-only mutation path that could drift from the game.
The resulting generation flow is small and explicit:
seed + stage
│
├─ build a resonant source-to-target path
├─ add deterministic decoys and bedrock
├─ apply reversible local pulses
├─ reject a candidate that is still solved
└─ replay the inverse witness through the public rule
│
└─ playable initial state
This approach was a better fit than breadth-first or A* search for the current objective. Twenty-five ternary cells already imply up to 3²⁵ raw level assignments before immutable masks and terminal metadata. A search would be reasonable if we needed proof of minimum solutions. For five short teaching stages, reversible construction provides the guarantee we actually need with less machinery.

Performance and validation
The measured runtime implementation consists of rules.gd, board_view.gd and main.gd: 686 GDScript lines and 22,202 bytes. Including two check programs and the benchmark gives six scripts, 816 lines and 26,354 bytes. There is one authored scene and no imported gameplay asset.
On an Apple M4 development host running Godot 4.7.2:
- 5,000 deterministic generations averaged 80.564 microseconds each;
- 50,000 local terrain updates averaged 12.346 microseconds each.
The update benchmark includes deep-copying the 25-cell state before applying a pulse. It excludes rendering and GPU work. These numbers describe one machine and one build; they are not mobile performance guarantees.
Validation covered more than the benchmark. The headless rule suite completed 557 assertions with zero failures, checking bounds, determinism, height limits, locality, terminal states and witness replay. A separate stress run generated 5,000 puzzles across all five stages. Every sampled board began unsolved and its witness reached victory. The real scene loop also verified restart identity and full solution replay.
The browser smoke test loaded the release export, observed a painted 648 × 1152 canvas, injected input and detected a changed rendered frame. Production testing then caught a pointer integration issue that code-level checks had missed: desktop mouse input was already being converted to touch by the project, so handling both paths applied a pulse twice. The released view accepts the screen-touch path once, and both mouse and touch now use that path.
The Web export illustrates why source compactness and delivery size must remain separate claims:
index.wasm: 39,514,754 bytes raw, 10,084,297 bytes with gzip level 9;index.pck: 130,544 bytes raw, 113,539 bytes with gzip level 9;- complete build directory: 39,100 KiB on disk.
The WebAssembly engine dominates the raw export. The PCK is not a pure Strata Relay number either: it contains shared Darkwaar code and project resources. We did not isolate game-only runtime memory from Godot and browser allocations, so this article makes no memory claim.
Cross-application messages belong outside the terrain loop
Strata Relay works offline. Its terrain simulation, generator, validation and win condition live in Godot. Sending each pulse across an application boundary would introduce latency, failure modes and distributed ordering into a rule that is already deterministic locally.
A completed session is a different kind of fact. In a hypothetical multi-application system, one application could publish a concise completion event and another could consume it for statistics or automation. That boundary should carry an explicit contract, not a serialized copy of either application's internals.
The proposed flow is deliberately coarse:
local game simulation
│
│ optional session summary
v
application boundary ── versioned event ──> independent consumer
For example, a hypothetical wire type could be game.session.completed.v1. Its payload could contain a game ID, seed, stage, outcome, turn count and duration. It would not contain the 25-cell state after every turn. The .v1 suffix makes compatibility an explicit contract decision: a breaking schema change gets a new type instead of silently changing the meaning of an existing event.
Symfony Messenger normally wraps a message in an Envelope and uses stamps for framework concerns such as routing, retries and bus selection. That is useful inside one Symfony deployment, but serialized stamp classes and bus names are poor contracts between independent applications.
Symfony 8.2's Messenger interoperability serializer addresses that boundary. messenger.transport.interop_serializer sends the JSON message without application-specific stamps and ignores incoming serialized stamps. A stable serialized type name can replace the producer's PHP class name. Applications that do not use PHP can bind the same type string to their own schema.
# Symfony 8.2+ — generic illustrative configuration
framework:
messenger:
identity_stamps: true
transports:
shared_events:
dsn: '%env(EVENT_TRANSPORT_DSN)%'
serializer: messenger.transport.interop_serializer
The matching PHP contract would also be versioned explicitly:
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,
) {}
}
With identity_stamps enabled, Messenger maps three useful identities to plain headers:
message_ididentifies one logical event across delivery attempts;correlation_idgroups messages belonging to one session or publication flow;causation_ididentifies the earlier command or event that produced this event.
These identifiers solve different observability problems. Reusing one value for all three would hide the relationship between an event, its workflow and its cause. They also do not replace idempotent consumers: a receiver must still decide how duplicate delivery is handled.
Transport independence does not mean transport indifference. JSON and plain headers prevent PHP class names from becoming the public contract, but production still needs retention, authentication, authorization, retry policy and schema evolution. If message signing is enabled, custom mapped headers must be written by the serializer so the signature covers them. Producers and consumers also need compatible Symfony patch levels before new signed header forms are introduced.
This example does not describe a deployed Strata Relay integration. The game neither emits game.session.completed.v1 nor depends on a message transport. The contract exists here only to demonstrate how an offline simulation and an interoperable event can remain separate concerns.
Play Strata Relay on Darkwaar
Discover how each terrain mutation changes the puzzle. Play directly in your browser.
Conclusion
Strata Relay is compact because its rules carry useful work. A deterministic seed reconstructs a board. A constructive scramble replaces stored levels while preserving a witness. One local mutation rule drives play, validation and automated demonstrations. A bounded flood-fill derives the visible signal without creating a second source of truth.
The measurements keep that success in proportion. The maintained GDScript is small, generation and local updates are inexpensive on the measured host, and the authored asset footprint is minimal. The Web build is still dominated by the engine, runtime memory remains unmeasured, and solvability does not prove optimality or difficulty.
The hypothetical messaging boundary follows the same discipline. The local simulation owns terrain evolution. A separate application could exchange a completed-session fact through a versioned contract without taking ownership of the game loop. Small rules, deterministic behavior and explicit boundaries make both the game and a future integration easier to test, explain and change.