11 KiB
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 aValue. - 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
ResetTimeatPlayerRowResetChords * PlayDeltaZ, rescaled on every tempo change, so the landing chord is invariant across levels and across a Boost. On expiry, Y collapses toBaseIndexYand 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;LastPlayedTimeadvances by whole intervals, never bynow— 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 byGameDeltaZStepper level down toGameDeltaZMin.deltaAt(t)—BaseDeltaZ / BoostSpeedFactorwhile a Boost runs att, elseBaseDeltaZ. 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.Updatere-reads the interval at each boundary, so a Boost expiring inside a frame is exact.TimeToChordDistancereplays the recurrence forward, so every displayed countdown matches the simulation.
Pipeline
Single-threaded polling loop. No mutexes, no channels over game state.
- A
time.Tickerinjects 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 incmd/. They must work while paused and in every phase, whichDispatchdoes not allow.audio.Muterwraps anEngineand dropsPlaywhile muted; the header carriesMUTE. - The engine emits no side-effects. Gameplay occurrences are queued as
Eventenums 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.
hitHazardspends 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 inresolveCell, 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— rawValueper chord distance (0 = current chord).WallMask—uint16bitmask, 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 (
WallBandCountbands ofWallBandSizechords). Fill is binary: solid only when a wall occupies the current chord. Urgency rides on color, not density. - header row —
LEVELleft, active status (shield glyph, boost countdown) centered,ENERGYright. - 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 inrender. - 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 byconst _assertions (window vs bitmask width, window vs band span, density glyphs vs band count).game/— pure state engine. Contact behavior is theresolverstable keyed onValue;Statusholds persistent item effects.input/— native key → platform-neutralKey→game.Action. The gomobile path bypasses this package and producesActiondirectly.render/— appearance is thevalueVisualstable keyed onValue.TileGlyphASCIIis the atlas-limited substitution for raylib.render/raylib/shares therendercaching pipeline and is up-to-date with theTileLookaheadAPI.audio/— fire-and-forget effects. PCM synthesized at startup; PipeWire backend on Linux, silentnullEngineelsewhere or whenpw-playis absent.Mutercomposes anEnginewith 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.
audiospawns onepw-playprocess 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.
resolveCellfires 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
StatusthroughWallBandColor/TileGlyph/WallTimerText. Deferred. - Boost shares the warm end of the wall band palette (
PaleLemon → GoldvsYellow → BrightRed). Glyph and fixed distance-row column disambiguate. EventDeflectfires once per walled chord traversed; with thepw-playbackend 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.TimerTextclamps at 9.9s, so a fresh 10s Boost reads9.9for its first 100ms.