Files
symph/doc/design.md
T
2026-07-15 01:28:56 -04:00

240 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.