# Symph: Architecture & Design Specification ## System Abstract Symph is a rhythm-driven grid traversal game. Real-time spatial mechanics are synchronized against a quantized temporal progression. The engine decouples hardware-frequency rendering and input polling from macro-frequency musical progression, exposing a deterministic, side-effect-free state machine to Terminal, Raylib, and Mobile frontends. --- ## 1. Domain Nomenclature * **Symphony** *(planned)*: collection of Songs. * **Song**: master temporal container (Z-axis). Slice of `Chord`. * **Chord**: one slice in time. A 3x3 matrix of `Note`. * **Note**: atomic element at `(Y, X)`, carrying a `Value`. * **Symphace** *(planned)*: bounded environment surrounding the grid. --- ## 2. Coordinate System & Displacement * **PIZ**: temporal depth index. Advances on the beat. * **PIY, PIX**: grid coordinates, bounded to `[0, 2]`. Spawn/rest anchor `(1, 1)`. Movement is decoupled from `PIZ`. * **X (lanes)**: persistent. The player holds the lane until moved again. * **Y (rows)**: transient. A row shift arms `ResetTime` at `PlayerRowResetChords * PlayDeltaZ`, rescaled on every tempo change, so the landing chord is invariant across levels and across a Boost. On expiry, Y collapses to `BaseIndexY` and the landing cell is resolved — returning into a wall is fatal. A manual move back to the base row cancels the timer (duck out of a jump). * Cells resolve on three triggers: beat arrival, player movement, row-reset landing. --- ## 3. Phases `PhasePlaying` → `PhaseLevelClear` (song exhausted, `TransitionDuration` interlude, then next level) → `PhasePlaying`. `PhaseGameOver` on wall contact; `ActionConfirm` restarts from level 1. Input is inert during `PhaseLevelClear`. `Pause`/`Resume` shift every absolute timestamp forward by the paused span, so no beats or row-reset expiries accumulate behind a pause (used by the terminal viewport gate). A deadline already lapsed when the dead span opened is cleared, not shifted: dead time never revives a Boost, extends a row reset, or replays a burn-out. --- ## 4. Chronometry & Concurrency Two isolated time domains: * **Macro (beat)**: `PlayDeltaZ`. Elapsed spans are consumed in whole ticks; `LastPlayedTime` advances by whole intervals, never by `now` — no drift. * **Micro (render/input)**: host framerate (`GameRenderUpdate`, ~60Hz). Resolves sub-beat state: row-reset countdown, blink phase, morph colors. ### Tempo * `BaseDeltaZ` — level-derived: `GameDeltaZBase` (500ms), shortened by `GameDeltaZStep` per level down to `GameDeltaZMin`. * `deltaAt(t)` — `BaseDeltaZ / BoostSpeedFactor` while a Boost runs at `t`, else `BaseDeltaZ`. The only place tempo is scaled; a slow effect divides here. * `PlayDeltaZ` — the interval of the beat **in flight**, fixed at that beat's start. A Boost taken mid-chord lands on the next boundary: the chord under the player never shortens beneath them. * `Update` re-reads the interval at each boundary, so a Boost expiring inside a frame is exact. `TimeToChordDistance` replays the recurrence forward, so every displayed countdown matches the simulation. ### Pipeline Single-threaded polling loop. No mutexes, no channels over game state. * A `time.Ticker` injects synthetic wake-up events (`terminal.KeyNone`) into the frontend event queue; raylib polls its own frame loop. * Per iteration: translate native key → `input.Key` → `game.Action`, `Dispatch(action, now)`, `Update(now)`, `DrainEvents()`, render. * Host controls bypass `input`/`game`: quit (Esc / Ctrl+C / `q`) and the audio gate (Ctrl+S) are handled in `cmd/`. They must work while paused and in every phase, which `Dispatch` does not allow. `audio.Muter` wraps an `Engine` and drops `Play` while muted; the header carries `MUTE`. * The engine emits no side-effects. Gameplay occurrences are queued as `Event` enums and drained once per iteration by the frontend, which maps them to audio. --- ## 5. Values & Items Every `Value` has one row in `types.valueSpecs` — `{Name, Glyph, Polarity}` — which is the single source of truth for kind identity. `Glyph` is the pattern authoring rune *and* the ASCII display fallback; `init` asserts completeness, ASCII range and uniqueness at process start. `Polarity` is a table lookup, not a switch. | Value | Glyph | Polarity | Behavior | |---|---|---|---| | `ValueEnergy` | `*` | positive | +1 energy, consumed on contact | | `ValueMagnet` | `M` | positive | consumed; sweeps **every** Energy note within `PositionLookaheadWindow` chords, at all 9 grid positions | | `ValueShield` | `S` | positive | consumed; arms `ShieldCharges` absorb charges. No expiry | | `ValueBoost` | `B` | positive | consumed; arms `BoostDuration` of `BoostSpeedFactor`× tempo. Refreshes, does not stack | | `ValueWall` | `#` | negative | fatal, unless a charge absorbs it | Adding a kind is three rows: `types.valueSpecs` (identity), `game.resolvers` (contact behavior), `render.valueVisuals` (color ramp). No switch is edited. `parseChord` rejects any pattern authoring a kind with no resolver, so an unresolved kind can never be walked through. ### 5.1 Status `game.Status` holds the persistent player effects. Both survive a level change; both are cleared on restart, never on death-free level transition. * **Shield** — a charge count. `hitHazard` spends one charge, **destroys the note**, records it in the consume ring, and play continues: the lookahead, ring and timer all re-derive from the cleared cell, giving the player exactly one beat to leave the lane. Because absorption sits in `resolveCell`, it covers all three resolution triggers (beat arrival, movement, row-reset landing) with no second code path. * **Boost** — an absolute deadline. Read only at beat boundaries. `Status.shift(at, d)` moves **live** deadlines across a pause and across the level-clear interlude, so a Boost taken at a song's tail is not burned by the transition wave. `at` is the instant the dead span opened; a deadline already lapsed at `at` is cleared, never revived. `restart` zeroes the whole `Status`. --- ## 6. Lookahead Model `ScanTile(y, x, window)` returns a `TileLookahead` for one grid position, allocation-free, in a single pass: * `Cells` — raw `Value` per chord distance (0 = current chord). * `WallMask` — `uint16` bitmask, bit *d* set when distance *d* holds a wall. `TileLookaheadMaxWindow` = 16 is the hard ceiling on the window. * `Positive` / `Negative` — nearest occurrence per polarity class. * `NearestWall()` derives the leading wall segment (distance, run length, reopen gap, trap flag, open-ended flag) from the bitmask. All proximity, run, gap and trap **policy** derives from the bitmask in the frontend. The engine holds no presentation rules. --- ## 7. Presentation Contract (terminal) Tile, 12x5: * **border** — ring colored by nearest-wall band (`WallBandCount` bands of `WallBandSize` chords). Fill is binary: solid only when a wall occupies the current chord. Urgency rides on color, not density. * **header row** — `LEVEL` left, active status (shield glyph, boost countdown) centered, `ENERGY` right. * **row 1** — wall timer. `▼` chords until the lane shuts, `▲` until it reopens; `+` open-ended run, `!` trap gap (`<= TrapGapMax`). * **row 2** — distance row: nearest positive and nearest negative occurrence, each glyph + chord-distance. The tile(s) whose distance equals the **grid-wide** minimum for that polarity blink their group (positive: white/green, negative: yellow/red, `RenderBlinkPeriod`). Ties are not broken — every tile at the minimum blinks. Distance 0 counts. * **row 3** — lookahead strip: one cell per chord, leftmost = now. Distance is carried positionally, so items draw at arrived color; walls keep the band gradient so ring, timer and strip agree. A cell emptied by consumption burns out in place: white flash, then glyph collapse into the rail cell. Grid-scope scan (`render.GridScan`) and the burn-out projection (`render.FadeGrid`) are shared by both frontends; policy lives in `render`. * **background** — player position fill; brightened while a charge is held, so the shield reads in the field rather than only in the HUD. Item color ramps use high-luminance endpoints at both ends: the distance row draws items at their true distance, and a dark distant endpoint is unreadable against the black field. **Raylib**: same contract, different medium. Layout derives from two gap fractions — `gapFracX` (usable width) and `gapFracY` (tile height). The row gap hosts the reset funnel, so the funnel font is sized against it (`funnelFrac`, floored at `funnelFontMin`). --- ## 8. Package Topology Unidirectional dependency flow (supports CGO/gomobile). * `types/` — dependency-free structures (`Song`, `Chord`, `Note`, `PlayerState`) and the value registry (`valueSpecs`: identity, glyph, polarity). Breaks import cycles. * `parameter/` — compile-time constants: grid bounds, timings, glyphs, tile geometry, lookahead window. Several invariants are enforced by `const _` assertions (window vs bitmask width, window vs band span, density glyphs vs band count). * `game/` — pure state engine. Contact behavior is the `resolvers` table keyed on `Value`; `Status` holds persistent item effects. * `input/` — native key → platform-neutral `Key` → `game.Action`. The gomobile path bypasses this package and produces `Action` directly. * `render/` — appearance is the `valueVisuals` table keyed on `Value`. `TileGlyphASCII` is the atlas-limited substitution for raylib. `render/raylib/` shares the `render` caching pipeline and is up-to-date with the `TileLookahead` API. * `audio/` — fire-and-forget effects. PCM synthesized at startup; PipeWire backend on Linux, silent `nullEngine` elsewhere or when `pw-play` is absent. `Muter` composes an `Engine` with the host mute gate. * `cmd/` — hosts (`symph-term`, `symph-raylib`): wiring, event poller, ticker. --- ## 9. Known Divergences * Raylib substitutes ASCII for the Unicode glyph set (default font atlas is 32..126) and draws walls as rectangles rather than density glyphs. Load a font with an explicit codepoint set for glyph parity. * `audio` spawns one `pw-play` process per effect; per-spawn stream connect costs tens of ms. Replace with a persistent stream before latency matters. * FreeBSD audio backend (sndio/OSS) unimplemented — falls through to `nullEngine`. * **Boost expiry inside a wall band is the primary death mode.** `resolveCell` fires on beat arrival, so a boosted player standing in a wall when the deadline lapses dies on the next beat — one chord (>= 150ms) to leave the lane. The HUD blink is the only warning. Intentional. * **Walls still render lethal while boosted.** Ring, timer and strip are unaware of immunity; recoloring requires threading `Status` through `WallBandColor` / `TileGlyph` / `WallTimerText`. Deferred. * **Boost shares the warm end of the wall band palette** (`PaleLemon → Gold` vs `Yellow → BrightRed`). Glyph and fixed distance-row column disambiguate. * `EventDeflect` fires once per walled chord traversed; with the `pw-play` backend that is one process spawn per chord during a boosted wall run. * `ItemFadeDuration` (140ms) clears the boosted beat floor (`GameDeltaZMin/BoostSpeedFactor` = 150ms) by 10ms. Lower it to ~100ms before touching either constant. * `TimerText` clamps at 9.9s, so a fresh 10s Boost reads `9.9` for its first 100ms.