v0.1.0 initial commit

This commit is contained in:
2026-07-15 01:28:56 -04:00
commit 874abee663
35 changed files with 3486 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
# 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.