240 lines
11 KiB
Markdown
240 lines
11 KiB
Markdown
# 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.
|