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

11 KiB
Raw Blame History

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

PhasePlayingPhaseLevelClear (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.Keygame.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).
  • WallMaskuint16 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 rowLEVEL 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 Keygame.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.