Spaces:
Sleeping
Sleeping
Commit ·
9ce912d
1
Parent(s): 9557331
docs: front-door menu spec + plan (Direction C, modes, how-to-play, menu music)
Browse filesPlan runs on its own branch feat/front-door-menu for isolation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/superpowers/plans/2026-06-14-front-door-menu.md
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Front Door — Menu + Modes + How-to-Play + Menu Music — Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan phase-by-phase. Steps use checkbox (`- [ ]`). **Do Phase 0 FIRST: create a dedicated branch `feat/front-door-menu` off `feat/voice-awareness` and do all work there** — if this overhaul goes wrong, `feat/voice-awareness` stays intact. Run the suite with `.venv/Scripts/python -m pytest -q`. Visuals are verified by screenshotting the RUNNING app (headless Chrome per CLAUDE.md), never the static harness. Audio is verified by ear (ask the user). Design doc: `docs/superpowers/specs/2026-06-14-front-door-menu-design.md`.
|
| 4 |
+
>
|
| 5 |
+
> **SUPERSEDES** `docs/superpowers/plans/2026-06-14-opening-chime-ritual.md` — do NOT execute that plan. With a menu, the chime→greeting ritual fires on **mode-select**, not on the first page gesture (the first gesture now lands on the menu). Phase 5 here owns the full audio flow.
|
| 6 |
+
|
| 7 |
+
**Goal:** A Fear & Hunger / Termina-**inspired** opening menu (approved Direction C) over the visitor's AI-painted foggy-wood backdrop, with two real modes — **Tester Run** (fast, judge-friendly arc) and **The Full Stay** (a genuinely longer experience) — a **How to Play** panel, a subtle eerie **menu melody** that starts on the first menu gesture, and the **chime → greeting** ritual as the "you're entering the wood" cue on mode-select. Makes the demo one click and lifts the whole thing from "Gradio app" to "a game".
|
| 8 |
+
|
| 9 |
+
**Architecture:** Two top-level Gradio views — `menu_view` (visible at start) and `game_view` (the existing game, `visible=False`). Mode-select flips visibility and initializes the game state with the chosen `mode`. `mode ∈ {"tester","full"}` drives a `PACING` config in `memory.py` that `should_recall`/`decide_ending` read (only the numbers change). The `<head>` controller starts the menu loop on first menu gesture and, on a mode button click, stops it and runs the chime→greeting ritual.
|
| 10 |
+
|
| 11 |
+
**Tech Stack:** Gradio 6.x (`gr.Column(visible=...)` view switch, buttons), CSS (menu styling + background `data:` URI), numpy audio (`sting.py` menu loop), the existing `<head>` audio-queue controller, pytest. No new deps.
|
| 12 |
+
|
| 13 |
+
**Critical caveat (CLAUDE.md):** `visible=False` can UNMOUNT a component. Only the menu (no persistent audio) and the game view toggle visibility; the game's `voice_panel`/`#idle-trigger` mount when `game_view` is shown — which is exactly when game audio is first needed. Safe. Do NOT put anything that must run while hidden behind `visible=False`.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## PHASE 0 — Isolation branch (do this before anything else)
|
| 18 |
+
|
| 19 |
+
**Why:** this overhaul restructures the whole layout, the audio flow, and the pacing engine. Keep it off the verified `feat/voice-awareness` branch so a bad run loses nothing.
|
| 20 |
+
|
| 21 |
+
- [ ] **0a.** Confirm a clean tree on `feat/voice-awareness`: `git status` (the spec + plan are already committed there).
|
| 22 |
+
- [ ] **0b.** Create and switch to the work branch:
|
| 23 |
+
```bash
|
| 24 |
+
git switch -c feat/front-door-menu
|
| 25 |
+
```
|
| 26 |
+
(If the orchestrator prefers an isolated worktree, use superpowers:using-git-worktrees instead — same effect.)
|
| 27 |
+
- [ ] **0c.** Every phase below commits on `feat/front-door-menu`. The merge back happens in FINAL, only after the user approves the live playthrough.
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## PHASE 1 — Two views + screen switch (no styling, no audio, no mode differences yet)
|
| 32 |
+
|
| 33 |
+
**Files:** `app.py`, `tests/test_app.py`.
|
| 34 |
+
|
| 35 |
+
### Step 1 — Wrap the game in `game_view`, add `menu_view`
|
| 36 |
+
|
| 37 |
+
- [ ] **1a.** In `app.py`, inside `with gr.Blocks(...) as demo:` and after `state = gr.State(_init_state)`, the current UI is a header `gr.Row` (title/restart/voice) + a body `gr.Row` (3 columns) + the event wiring. Wrap ALL of that existing UI in a hidden column:
|
| 38 |
+
```python
|
| 39 |
+
with gr.Column(visible=False, elem_id="game-view") as game_view:
|
| 40 |
+
# ... ALL the existing header row + body row + event wiring stays here,
|
| 41 |
+
# unchanged, just indented one level deeper ...
|
| 42 |
+
```
|
| 43 |
+
(Indent the existing `with gr.Row():` blocks and the `.click`/`.submit`/`idle_trigger` wiring into this column. The `demo.launch(...)` call at the bottom stays at module level, unchanged.)
|
| 44 |
+
|
| 45 |
+
- [ ] **1b.** ADD a menu column BEFORE `game_view` (so it renders first):
|
| 46 |
+
```python
|
| 47 |
+
with gr.Column(visible=True, elem_id="menu-view") as menu_view:
|
| 48 |
+
menu_art = gr.HTML(_menu_html()) # Phase 2 fills this in; "" for now
|
| 49 |
+
tester_btn = gr.Button("Tester Run", elem_id="btn-tester",
|
| 50 |
+
elem_classes="menu-btn menu-mode-btn")
|
| 51 |
+
full_btn = gr.Button("The Full Stay", elem_id="btn-full",
|
| 52 |
+
elem_classes="menu-btn menu-mode-btn")
|
| 53 |
+
howto_btn = gr.Button("How to Play", elem_id="btn-howto",
|
| 54 |
+
elem_classes="menu-btn")
|
| 55 |
+
```
|
| 56 |
+
For Phase 1 add a placeholder `_menu_html` near the other render helpers:
|
| 57 |
+
```python
|
| 58 |
+
def _menu_html() -> str:
|
| 59 |
+
return '<div class="menu-art-placeholder">HOLLOW — menu</div>'
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
### Step 2 — Enter-game handler
|
| 63 |
+
|
| 64 |
+
- [ ] **2a.** Add `_enter_game(mode)` near `_pristine_state` / `_init_state`:
|
| 65 |
+
```python
|
| 66 |
+
def _enter_game(mode: str):
|
| 67 |
+
"""Leave the menu, reveal the game, start a fresh run in the chosen mode.
|
| 68 |
+
Order matches the outputs list below."""
|
| 69 |
+
state = _init_state() # honors HOLLOW_FAST_FINALE for dev
|
| 70 |
+
state["mode"] = mode # "tester" | "full"
|
| 71 |
+
return (
|
| 72 |
+
gr.update(visible=False), # menu_view
|
| 73 |
+
gr.update(visible=True), # game_view
|
| 74 |
+
state,
|
| 75 |
+
[{"role": "assistant", "content": OPENING_LINE}], # chatbot
|
| 76 |
+
_render_bond(state["affinity"], get_tier(state["affinity"])),
|
| 77 |
+
render_treasure(state["treasure"], claimed=set(state["claimed"]),
|
| 78 |
+
wounds=state.get("wounds", [])),
|
| 79 |
+
render_entity(state["affinity"]),
|
| 80 |
+
)
|
| 81 |
+
```
|
| 82 |
+
Add `"mode": "tester",` to the three state factories (`_pristine_state`, both `_init_state` branches) so reads are safe.
|
| 83 |
+
|
| 84 |
+
- [ ] **2b.** Wire both mode buttons (How to Play wired in Phase 4):
|
| 85 |
+
```python
|
| 86 |
+
_enter_outputs = [menu_view, game_view, state, chatbot, bond_panel,
|
| 87 |
+
treasure_panel, entity_panel]
|
| 88 |
+
tester_btn.click(lambda: _enter_game("tester"), None, _enter_outputs,
|
| 89 |
+
show_progress="hidden")
|
| 90 |
+
full_btn.click(lambda: _enter_game("full"), None, _enter_outputs,
|
| 91 |
+
show_progress="hidden")
|
| 92 |
+
```
|
| 93 |
+
(These outputs reference `chatbot`, `bond_panel`, etc., which are defined inside `game_view`. Define `_enter_outputs` and the `.click` wiring AFTER those components exist — i.e., at the end of the Blocks body, alongside the other event wiring.)
|
| 94 |
+
|
| 95 |
+
### Step 3 — Tests + verify
|
| 96 |
+
|
| 97 |
+
- [ ] **3a.** In `tests/test_app.py` add:
|
| 98 |
+
```python
|
| 99 |
+
class TestEnterGame:
|
| 100 |
+
def test_enter_sets_mode_and_reveals_game(self):
|
| 101 |
+
outs = app._enter_game("full")
|
| 102 |
+
assert outs[0]["visible"] is False # menu hidden
|
| 103 |
+
assert outs[1]["visible"] is True # game shown
|
| 104 |
+
assert outs[2]["mode"] == "full" # mode stored on state
|
| 105 |
+
assert outs[3][0]["content"] == app.OPENING_LINE
|
| 106 |
+
```
|
| 107 |
+
- [ ] **3b. Run** `.venv/Scripts/python -m pytest -q` → green.
|
| 108 |
+
- [ ] **3c. Live screenshot:** launch, confirm the menu (placeholder) shows first and the game is hidden; click Tester → menu hides, game appears with the opening line. Kill the port owner.
|
| 109 |
+
- [ ] **3d. Commit:** `feat: front-door — menu view + game view with screen switch`.
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## PHASE 2 — The background asset + Direction C styling
|
| 114 |
+
|
| 115 |
+
**Files:** `assets/background.webp` (new), `app.py` (`_menu_html`, `_BACKGROUND_URI`), `styles.css`, `tests/test_app.py`.
|
| 116 |
+
|
| 117 |
+
### Step 1 — Process the visitor's image into a committed asset
|
| 118 |
+
|
| 119 |
+
- [ ] **1a.** Convert `assets/backroud.png` → `assets/background.webp` (grayscale + darkened to match the portraits, resized for a wide menu). Run once:
|
| 120 |
+
```python
|
| 121 |
+
from PIL import Image, ImageEnhance
|
| 122 |
+
img = Image.open("assets/backroud.png").convert("L")
|
| 123 |
+
img = ImageEnhance.Brightness(img).enhance(0.82)
|
| 124 |
+
img = ImageEnhance.Contrast(img).enhance(1.06)
|
| 125 |
+
img = img.convert("RGB").resize((1280, 960), Image.LANCZOS) # keep ~its aspect
|
| 126 |
+
img.save("assets/background.webp", "WEBP", quality=82, method=6)
|
| 127 |
+
```
|
| 128 |
+
Commit `assets/background.webp`. (HF accepts images; only committed audio binaries are rejected.) Leave `backroud.png` or delete it — your call; the app uses the `.webp`.
|
| 129 |
+
|
| 130 |
+
- [ ] **1b.** In `app.py`, near the top (after imports), load it as a base64 data URI the way portraits are embedded:
|
| 131 |
+
```python
|
| 132 |
+
with open("assets/background.webp", "rb") as _bg:
|
| 133 |
+
_BACKGROUND_URI = "data:image/webp;base64," + base64.b64encode(_bg.read()).decode()
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
### Step 2 — `_menu_html` (the C composition, visual only)
|
| 137 |
+
|
| 138 |
+
- [ ] **2a.** Replace the placeholder `_menu_html` with the real composition. The interactive buttons are separate Gradio components (Phase 1); this HTML draws the backdrop + title block + decorative text. The background lives on `#menu-view` via CSS (Step 3) so it covers the whole column; this HTML provides the centered title/eyebrow/tag + the credit + the "How to Play" corner is the Gradio `#btn-howto` styled in CSS.
|
| 139 |
+
```python
|
| 140 |
+
def _menu_html() -> str:
|
| 141 |
+
return (
|
| 142 |
+
'<div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
|
| 143 |
+
'<div class="menu-title">Hollow</div>'
|
| 144 |
+
'<div class="menu-tag">something is here, at the edge of the wood '
|
| 145 |
+
'— it remembers what you give it.</div>'
|
| 146 |
+
'<div class="menu-credit">A LOST CHILD WAITS · TURN YOUR SOUND ON</div>'
|
| 147 |
+
)
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
### Step 3 — Menu CSS (§23) — Direction C
|
| 151 |
+
|
| 152 |
+
- [ ] **3a.** In `styles.css`, add a `§23 — opening menu` block. Use `_BACKGROUND_URI` by writing the CSS so the background is set inline on the column from Python is NOT possible (CSS file is static); instead set the background via a CSS custom property injected in `_HEAD_JS` OR set it inline on the `menu_art` HTML wrapper. Simplest: put the background on a full-bleed div INSIDE `_menu_html` with an inline `style="background-image:url(...)"` using the URI. Revise `_menu_html` to wrap its content:
|
| 153 |
+
```python
|
| 154 |
+
def _menu_html() -> str:
|
| 155 |
+
return (
|
| 156 |
+
f'<div class="menu-bg" style="background-image:url(\'{_BACKGROUND_URI}\')"></div>'
|
| 157 |
+
'<div class="menu-scrim"></div>'
|
| 158 |
+
'<div class="menu-grain"></div>'
|
| 159 |
+
'<div class="menu-vig"></div>'
|
| 160 |
+
'<div class="menu-content">'
|
| 161 |
+
' <div class="menu-eyebrow">AN ADVENTURE IN THOUSAND TOKEN WOOD</div>'
|
| 162 |
+
' <div class="menu-title">Hollow</div>'
|
| 163 |
+
' <div class="menu-tag">something is here, at the edge of the wood '
|
| 164 |
+
'— it remembers what you give it.</div>'
|
| 165 |
+
'</div>'
|
| 166 |
+
'<div class="menu-credit">A LOST CHILD WAITS · TURN YOUR SOUND ON</div>'
|
| 167 |
+
)
|
| 168 |
+
```
|
| 169 |
+
Then in `styles.css §23` style: `#menu-view{position:relative;min-height:560px;...}`, `.menu-bg{position:absolute;inset:0;background-size:cover;background-position:center 38%;}`, `.menu-scrim{...radial dark...}`, `.menu-grain`/`.menu-vig` (reuse the entity grain/vig feTurbulence + inset shadow), `.menu-content{position:relative;z-index:3;text-align:center;padding-top:64px;font-family:Georgia,serif;}`, `.menu-title{font-variant:small-caps;letter-spacing:.16em;font-weight:700;font-size:72px;color:#ece6f4;text-shadow:0 0 30px #000,0 4px 12px #000;}`, `.menu-eyebrow`/`.menu-tag`/`.menu-credit` per the mockup. Style the buttons: `.menu-btn{position:relative;z-index:4;background:transparent!important;border:0!important;box-shadow:none!important;font-family:Georgia,serif;letter-spacing:.16em;color:#c4bede;...}` with a hover that brightens + widens letter-spacing and a `▸` marker; center them under the title. Position `#btn-howto` absolutely top-right as the corner item.
|
| 170 |
+
- The reference look is the approved mockup `\.superpowers/brainstorm/3158-1781454014/content/c-final-bg.html` — match its scrim/title/spacing.
|
| 171 |
+
|
| 172 |
+
- [ ] **3b. Live screenshot REQUIRED (CSS lies in the harness).** Launch, screenshot the menu, Read the PNG. Confirm: background visible + dark enough, title legible over it, the three buttons read as menu items (Tester/Full centered, How to Play in the corner), grain/vignette present. Iterate on the CSS until it matches the mockup. Kill the port owner.
|
| 173 |
+
|
| 174 |
+
- [ ] **3c.** Test: `assert "menu-title" in app._menu_html()` and `assert app._BACKGROUND_URI.startswith("data:image/webp")`. Run suite → green.
|
| 175 |
+
|
| 176 |
+
- [ ] **3d. Commit:** `feat: Fear & Hunger-inspired opening menu (Direction C) with painted backdrop`.
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## PHASE 3 — Mode mechanics (Tester vs Full pacing)
|
| 181 |
+
|
| 182 |
+
**Files:** `memory.py`, `tests/test_memory.py`, `tools/pacing_sim.py`.
|
| 183 |
+
|
| 184 |
+
### Step 1 — `PACING` config + mode-aware gates
|
| 185 |
+
|
| 186 |
+
- [ ] **1a. Failing tests** (append to `tests/test_memory.py`):
|
| 187 |
+
```python
|
| 188 |
+
def test_full_mode_recall_waits_for_two_memories():
|
| 189 |
+
from memory import should_recall
|
| 190 |
+
one = {"affinity": 30, "treasure": ["a"], "claimed": [], "turn": 1,
|
| 191 |
+
"last_recall_turn": None, "mode": "full"}
|
| 192 |
+
assert should_recall(one)[0] is False # full needs >=2 before first recall
|
| 193 |
+
two = dict(one, treasure=["a", "bb"])
|
| 194 |
+
assert should_recall(two)[0] is True
|
| 195 |
+
|
| 196 |
+
def test_full_mode_ending_gate_is_higher():
|
| 197 |
+
from memory import decide_ending
|
| 198 |
+
mid = {"affinity": 55, "claimed": ["a", "b"], "turn": 8, "tone": 25,
|
| 199 |
+
"wounds": [], "ended": False, "mode": "full"}
|
| 200 |
+
assert decide_ending(mid) is None # 55 < full gate 68
|
| 201 |
+
hi = dict(mid, affinity=70, claimed=["a", "b", "c"])
|
| 202 |
+
assert decide_ending(hi) == "good"
|
| 203 |
+
|
| 204 |
+
def test_tester_mode_unchanged():
|
| 205 |
+
from memory import should_recall, decide_ending
|
| 206 |
+
s = {"affinity": 24, "treasure": ["x"], "claimed": [], "turn": 1,
|
| 207 |
+
"last_recall_turn": None, "mode": "tester"}
|
| 208 |
+
assert should_recall(s)[0] is True # tester: first memory -> recall
|
| 209 |
+
e = {"affinity": 52, "claimed": ["a", "b"], "turn": 6, "tone": 25,
|
| 210 |
+
"wounds": [], "ended": False, "mode": "tester"}
|
| 211 |
+
assert decide_ending(e) == "good"
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
- [ ] **1b. Run, verify fail.**
|
| 215 |
+
|
| 216 |
+
- [ ] **1c. Implement.** In `memory.py`, add the config and make the two functions mode-aware:
|
| 217 |
+
```python
|
| 218 |
+
PACING = {
|
| 219 |
+
"tester": {"recall_min_memories": 1, "recall_cooldown": 2,
|
| 220 |
+
"end_affinity": 50, "end_claimed": 2, "bad_min_turn": 4},
|
| 221 |
+
"full": {"recall_min_memories": 2, "recall_cooldown": 3,
|
| 222 |
+
"end_affinity": 68, "end_claimed": 3, "bad_min_turn": 6},
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
def _cfg(state: dict) -> dict:
|
| 226 |
+
return PACING.get(state.get("mode", "tester"), PACING["tester"])
|
| 227 |
+
```
|
| 228 |
+
`should_recall`:
|
| 229 |
+
```python
|
| 230 |
+
def should_recall(state: dict) -> tuple[bool, str | None]:
|
| 231 |
+
cfg = _cfg(state)
|
| 232 |
+
unclaimed = [m for m in state["treasure"] if m not in state["claimed"]]
|
| 233 |
+
if len(unclaimed) < cfg["recall_min_memories"]:
|
| 234 |
+
return False, None
|
| 235 |
+
richest = max(unclaimed, key=len)
|
| 236 |
+
last = state.get("last_recall_turn")
|
| 237 |
+
if last is None:
|
| 238 |
+
return True, richest
|
| 239 |
+
if state["turn"] - last >= cfg["recall_cooldown"]:
|
| 240 |
+
return True, richest
|
| 241 |
+
return False, None
|
| 242 |
+
```
|
| 243 |
+
`decide_ending` (replace the two numeric gates with `cfg`):
|
| 244 |
+
```python
|
| 245 |
+
cfg = _cfg(state)
|
| 246 |
+
tone = state.get("tone", 0)
|
| 247 |
+
if tone <= -30 and state["turn"] >= cfg["bad_min_turn"] and len(state.get("wounds", [])) >= 2:
|
| 248 |
+
return "bad"
|
| 249 |
+
if state["affinity"] >= cfg["end_affinity"] and len(state["claimed"]) >= cfg["end_claimed"]:
|
| 250 |
+
return "good" if tone >= 20 else "loop"
|
| 251 |
+
return None
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
- [ ] **1d.** Update existing `decide_ending`/`should_recall` tests that pass states without `mode` — they default to "tester" via `_cfg`, so they should still pass; fix any that hard-coded the old 50/4 expecting a different mode.
|
| 255 |
+
|
| 256 |
+
- [ ] **1e.** `tools/pacing_sim.py`: add a `--mode` (or loop both) so it sets `state["mode"]`. Run it for both modes; record the measured arc lengths in the commit message. Confirm Tester ~5-7, Full ~8-12.
|
| 257 |
+
|
| 258 |
+
- [ ] **1f. Run** the suite → green. **Commit:** `feat: Tester vs Full pacing modes (config-driven gates)`.
|
| 259 |
+
|
| 260 |
+
---
|
| 261 |
+
|
| 262 |
+
## PHASE 4 — How to Play overlay
|
| 263 |
+
|
| 264 |
+
**Files:** `app.py`, `styles.css`, `tests/test_app.py`.
|
| 265 |
+
|
| 266 |
+
- [ ] **1a.** In `app.py`, inside `menu_view` (after the buttons), add a hidden overlay group:
|
| 267 |
+
```python
|
| 268 |
+
with gr.Column(visible=False, elem_id="howto-overlay") as howto_overlay:
|
| 269 |
+
gr.HTML(_HOWTO_HTML)
|
| 270 |
+
howto_close = gr.Button("close", elem_id="btn-howto-close",
|
| 271 |
+
elem_classes="menu-btn")
|
| 272 |
+
```
|
| 273 |
+
Add the copy constant near the other text constants:
|
| 274 |
+
```python
|
| 275 |
+
_HOWTO_HTML = (
|
| 276 |
+
'<div class="howto-panel">'
|
| 277 |
+
'<h3>How to Play</h3>'
|
| 278 |
+
'<p>You found a child at the edge of the wood. It has no memories of its own '
|
| 279 |
+
'— so it asks for yours.</p>'
|
| 280 |
+
'<p>Tell it something true: a person, a place, a moment you lived. It keeps each '
|
| 281 |
+
'one in its treasure.</p>'
|
| 282 |
+
'<p>Later it speaks your memories back — in the first person, as if it had '
|
| 283 |
+
'lived them. That is what it wants.</p>'
|
| 284 |
+
'<p>But it remembers how you treat it. Be gentle, or be cruel. '
|
| 285 |
+
'<b>Three endings</b> wait in the fog.</p>'
|
| 286 |
+
'<p class="howto-controls">type and press enter · 🔊 mute · '
|
| 287 |
+
'↺ begin again · turn your sound on</p>'
|
| 288 |
+
'</div>'
|
| 289 |
+
)
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
- [ ] **1b.** Wire open/close (visibility toggles — no audio, safe):
|
| 293 |
+
```python
|
| 294 |
+
howto_btn.click(lambda: gr.update(visible=True), None, howto_overlay,
|
| 295 |
+
show_progress="hidden")
|
| 296 |
+
howto_close.click(lambda: gr.update(visible=False), None, howto_overlay,
|
| 297 |
+
show_progress="hidden")
|
| 298 |
+
```
|
| 299 |
+
|
| 300 |
+
- [ ] **1c.** `styles.css §24`: `#howto-overlay{position:absolute;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;background:#04040ae0;}` and `.howto-panel{max-width:560px;border:11px solid transparent;border-image:var(--stone-frame) 30;background:var(--stone-base);padding:26px 30px;font-family:Georgia,serif;color:#c4bede;line-height:1.6;}` (reuse the carved-stone frame vars). Style `h3`, `.howto-controls` (smaller, muted, letter-spaced).
|
| 301 |
+
|
| 302 |
+
- [ ] **1d. Tests:** `assert "Three endings" in app._HOWTO_HTML`; open returns `visible True`, close returns `visible False`. Run suite → green.
|
| 303 |
+
- [ ] **1e. Live screenshot:** How to Play opens a readable stone panel over the menu; close returns. **Commit:** `feat: How to Play overlay`.
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## PHASE 5 — Menu music + chime→greeting ritual on mode-select
|
| 308 |
+
|
| 309 |
+
**Files:** `sting.py`, `tests/test_sting.py`, `app.py`, `tests/test_app.py`.
|
| 310 |
+
|
| 311 |
+
This phase OWNS the audio flow (superseding the standalone chime-ritual plan): the menu loop starts on the first menu gesture; choosing a mode stops it and plays **chime → greeting** as the entrance, then the game (already revealed by Phase 1) takes over with its per-reply voice.
|
| 312 |
+
|
| 313 |
+
### Step 1 — Synthesize the menu loop
|
| 314 |
+
|
| 315 |
+
- [ ] **1a. Failing test** (`tests/test_sting.py`): `assert len(menu_loop_wav_bytes()) > 2000`.
|
| 316 |
+
- [ ] **1b.** In `sting.py`, add `menu_loop_wav_bytes()`: a slow, low, detuned music-box / drone motif (a few minor-key notes over a soft low drone, light reverb), 1 dB-quiet, built to **loop seamlessly** (start and end at silence/zero-crossing). Reuse the existing `_wav` writer and the chime/sine helpers already in the file. Keep it short (~6-10 s) so the base64 stays small.
|
| 317 |
+
- [ ] **1c. Run** the sting test → green.
|
| 318 |
+
|
| 319 |
+
### Step 2 — Embed the loop + chime/greeting constants
|
| 320 |
+
|
| 321 |
+
- [ ] **2a.** In `app.py`, build the audio constants (the chime + greeting were previously the `_greeting_html` element; consolidate as `data:` URI constants like the planned chime-ritual):
|
| 322 |
+
```python
|
| 323 |
+
from sting import chime_wav_bytes, menu_loop_wav_bytes
|
| 324 |
+
_CHIME_B64 = base64.b64encode(chime_wav_bytes(0)).decode()
|
| 325 |
+
_GREETING_B64 = speak(OPENING_LINE) or ""
|
| 326 |
+
_MENU_LOOP_B64 = base64.b64encode(menu_loop_wav_bytes()).decode()
|
| 327 |
+
_CHIME_URI = f"data:audio/wav;base64,{_CHIME_B64}"
|
| 328 |
+
_GREETING_URI = f"data:audio/wav;base64,{_GREETING_B64}" if _GREETING_B64 else ""
|
| 329 |
+
_MENU_LOOP_URI = f"data:audio/wav;base64,{_MENU_LOOP_B64}"
|
| 330 |
+
```
|
| 331 |
+
Remove the now-obsolete `_greeting_html` function and its `#hollow-greeting` element in `voice_panel` (set `voice_panel = gr.HTML(value="", elem_classes="voice-channel")`). Remove the `test_greeting_html_*` tests.
|
| 332 |
+
- [ ] **2b.** Put the menu loop element inside `menu_view`: `gr.HTML('<audio id="menu-loop" loop src="' + _MENU_LOOP_URI + '"></audio>')` (no autoplay — the controller starts it on gesture).
|
| 333 |
+
|
| 334 |
+
### Step 3 — Rework the `<head>` controller audio flow
|
| 335 |
+
|
| 336 |
+
- [ ] **3a.** In `_HEAD_JS`, inject the three URIs via placeholders (build with `.replace`, NOT f-string): `var CHIME_SRC="__CHIME__"; var GREETING_SRC="__GREETING__"; var MENU_SRC="__MENU__";` and after the string: `_HEAD_JS = _HEAD_JS.replace("__CHIME__", _CHIME_URI).replace("__GREETING__", _GREETING_URI).replace("__MENU__", _MENU_LOOP_URI)`.
|
| 337 |
+
- [ ] **3b.** Add to the controller (next to the queue helpers):
|
| 338 |
+
```javascript
|
| 339 |
+
var menuAudio = null;
|
| 340 |
+
function startMenuMusic() {
|
| 341 |
+
if (menuAudio || !MENU_SRC) return;
|
| 342 |
+
menuAudio = document.getElementById('menu-loop') || new Audio(MENU_SRC);
|
| 343 |
+
menuAudio.loop = true; menuAudio.volume = st.muted ? 0 : 0.5;
|
| 344 |
+
menuAudio.muted = st.muted;
|
| 345 |
+
menuAudio.play().catch(function () {});
|
| 346 |
+
}
|
| 347 |
+
function stopMenuMusic() {
|
| 348 |
+
if (menuAudio) { try { menuAudio.pause(); } catch (e) {} menuAudio = null; }
|
| 349 |
+
}
|
| 350 |
+
var _lastRitual = 0;
|
| 351 |
+
function openingRitual() { // chime, then the child greets
|
| 352 |
+
var now = Date.now(); if (now - _lastRitual < 800) return; _lastRitual = now;
|
| 353 |
+
resetQueue();
|
| 354 |
+
if (CHIME_SRC) enqueue(CHIME_SRC);
|
| 355 |
+
if (GREETING_SRC) enqueue(GREETING_SRC);
|
| 356 |
+
}
|
| 357 |
+
```
|
| 358 |
+
- [ ] **3c.** REPLACE the current first-gesture greeting handler. The first menu gesture now starts the **menu music** (not the greeting):
|
| 359 |
+
```javascript
|
| 360 |
+
function firstGesture() {
|
| 361 |
+
startMenuMusic();
|
| 362 |
+
window.removeEventListener('pointerdown', firstGesture, true);
|
| 363 |
+
window.removeEventListener('keydown', firstGesture, true);
|
| 364 |
+
}
|
| 365 |
+
window.addEventListener('pointerdown', firstGesture, true);
|
| 366 |
+
window.addEventListener('keydown', firstGesture, true);
|
| 367 |
+
```
|
| 368 |
+
- [ ] **3d.** Extend `wire()` (the poller that already wires `.voice-btn`/`.restart-btn`) to also wire the mode buttons: when `.menu-mode-btn` elements exist, add a click listener that does `stopMenuMusic(); openingRitual();` (do NOT stopPropagation — the server `_enter_game` must still run). Restart (`.restart-btn`) keeps calling `openingRitual()` (re-greet on "begin again", which restarts the current game in place).
|
| 369 |
+
- [ ] **3e.** Mute: `applyLive()` already covers queued audio; also have the mute toggle set `menuAudio.muted` if `menuAudio` exists.
|
| 370 |
+
|
| 371 |
+
### Step 4 — Tests + verify
|
| 372 |
+
|
| 373 |
+
- [ ] **4a.** `tests/test_app.py`: `assert "openingRitual" in app._HEAD_JS`, `assert "startMenuMusic" in app._HEAD_JS`, `assert "menu-loop" in app._menu_html() or "menu-loop" in <the menu audio html>`, `assert app._MENU_LOOP_URI.startswith("data:audio/wav")`. Remove obsolete greeting-html tests. Run suite → green.
|
| 374 |
+
- [ ] **4b. Live (by ear, user):** first menu gesture → eerie loop starts (low); click Tester/Full → loop stops, chime → child greets, game shows and streams replies; 🔊 mutes everything (menu + voice); "begin again" re-greets. Kill the port owner.
|
| 375 |
+
- [ ] **4c. Commit:** `feat: menu melody + chime-greeting ritual on mode select`.
|
| 376 |
+
|
| 377 |
+
---
|
| 378 |
+
|
| 379 |
+
## FINAL — verification & docs
|
| 380 |
+
|
| 381 |
+
- [ ] **1.** `.venv/Scripts/python -m pytest -q` → all green.
|
| 382 |
+
- [ ] **2.** Full live playthrough (`.venv-tts` + Ollama) with the user: menu (Direction C, painted backdrop, melody) → How to Play reads → pick **Tester** → chime→greeting → fast arc (wow ~turn 2, ending ~5-7); restart, pick **Full** → longer arc (~8-12). Mute/idle/streaming all intact. Confirm all three finales still fire with sound.
|
| 383 |
+
- [ ] **3.** Update `CLAUDE.md` + `AGENTS.md`: new front-door menu (Direction C + `assets/background.webp`), `mode` in state + `PACING` config (tester/full), How to Play overlay, menu loop + ritual-on-mode-select; note this superseded the standalone chime-ritual plan; `_greeting_html` removed. Update file structure + state shape + tests count. Keep ≤200 lines.
|
| 384 |
+
- [ ] **4.** **Merge back, with the user's go-ahead** (REQUIRED SUB-SKILL: superpowers:finishing-a-development-branch): once all phases pass and the user approves the live playthrough, merge `feat/front-door-menu` → `feat/voice-awareness` (fast-forward or `--no-ff`, the finishing skill decides). If the user is unhappy with the result, the branch is simply abandoned and `feat/voice-awareness` is untouched. Do NOT merge to `main` here — `main` and the deploy wait until the whole branch (incl. the prior streaming/pacing work) has had a live playthrough.
|
| 385 |
+
- [ ] **5.** Deploy is out of scope — the user runs the mirror-first deploy after the merge, approving the playthrough (and Phase C streaming Space-verification from the prior plan).
|
| 386 |
+
|
| 387 |
+
---
|
| 388 |
+
|
| 389 |
+
## Self-review notes
|
| 390 |
+
- **Spec coverage:** menu (P1+P2), modes (P3), how-to-play (P4), menu music + chime ritual (P5) — every spec component has a phase; each phase is independently shippable and verified.
|
| 391 |
+
- **Supersession is explicit:** the chime-ritual plan is NOT executed; this plan owns the ritual and re-points it to mode-select (the only place a greeting makes sense once a menu exists).
|
| 392 |
+
- **Unmount caveat respected:** only menu/game/how-to toggle `visible`; none hold audio that must run while hidden — game audio mounts when `game_view` shows.
|
| 393 |
+
- **Modes are config-only:** `PACING` + `_cfg(state)`; `should_recall`/`decide_ending` read `state["mode"]`; no call-path changes, so the streaming/extraction/finale logic is untouched. Tester reproduces the current arc exactly (default).
|
| 394 |
+
- **`.replace`, not f-string** for `_HEAD_JS` (the JS body has literal `{}`); placeholders filled after the string is defined.
|
| 395 |
+
- **Audio = images-are-fine / synth-the-rest:** background is a committed WebP (allowed); the menu loop is synthesized at runtime (`sting.py`), no committed audio binary.
|
| 396 |
+
- **Visuals screenshot-verified, audio by ear** — per the project's hard-won rules; the approved mockup at `.superpowers/brainstorm/3158-1781454014/content/c-final-bg.html` is the visual reference.
|
docs/superpowers/specs/2026-06-14-front-door-menu-design.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Front Door — Menu, Modes, How-to-Play, Menu Music — Design
|
| 2 |
+
|
| 3 |
+
**Date:** 2026-06-14 · **Project:** Hollow (Build Small Hackathon)
|
| 4 |
+
|
| 5 |
+
## Goal
|
| 6 |
+
Give Hollow a real "front door": a Fear & Hunger / Termina-**inspired** (not copied) opening menu over an AI-generated foggy-wood background, with two play modes — **Tester Run** (fast, judge-friendly) and **The Full Stay** (a genuinely longer experience) — a **How to Play** panel, a subtle eerie **menu melody**, and the existing **chime** as the "you're entering" cue when a mode is chosen. This makes the demo trivial to run (pick Tester) and lifts the whole thing from "homemade Gradio" to "a game".
|
| 7 |
+
|
| 8 |
+
## Approved visual direction
|
| 9 |
+
Direction **C** ("Cold Vista", Termina-style): a wide grayscale painted forest backdrop (path → ruined monument in fog), gothic small-caps **HOLLOW** title centered with the track eyebrow above, a tight vertical menu, "How to Play" in a corner, a quiet credit line, all under a radial **scrim** + top/bottom gradients (text legibility over any image) + grain + vignette. The visitor-supplied image (`assets/backroud.png`) is the backdrop, post-processed to grayscale + darkened to match the portraits.
|
| 10 |
+
|
| 11 |
+
## Architecture
|
| 12 |
+
- **Two top-level views in the Blocks:** `menu_view` (visible at start) and `game_view` (the existing 3-column game, `visible=False` at start). Selecting a mode flips both visibilities and initializes the game state with the chosen mode.
|
| 13 |
+
- **Audio-unmount caveat (CLAUDE.md):** `visible=False` can unmount a component, so the game's `voice_panel` / `#idle-trigger` must NOT be needed while hidden. They aren't — game audio only matters once `game_view` is shown, at which point they mount and the `<head>` controller's MutationObserver picks audio up. Safe.
|
| 14 |
+
- **Mode is state.** `state["mode"] ∈ {"tester","full"}`. A `PACING` config dict (in `memory.py`) maps each mode to its thresholds; `should_recall` and `decide_ending` read `state.get("mode","tester")` and look the values up. No engine call paths change — only the numbers.
|
| 15 |
+
- **The opening ritual moves to mode-select.** Today chime→greeting fires on the first page gesture. With a menu, the page's first gesture lands on the MENU; so: menu melody starts on the first menu gesture; choosing a mode **stops the melody, runs the chime→greeting ritual, and reveals the game**. The `<head>` controller gains listeners on the mode buttons (same pattern as the restart button).
|
| 16 |
+
|
| 17 |
+
## Components
|
| 18 |
+
1. **Menu screen** — `menu_view` rendered as a single `gr.HTML` (the C composition, fully styled, background embedded) PLUS three real `gr.Button`s (Tester / Full / How to Play) positioned over it via CSS, so clicks hit the server. Visual is CSS; the background is the post-processed WebP as a base64 `data:` URI in a Python constant (same approach as the portraits).
|
| 19 |
+
2. **Mode mechanics** — `PACING` config:
|
| 20 |
+
|
| 21 |
+
| key | tester | full | meaning |
|
| 22 |
+
|-----|--------|------|---------|
|
| 23 |
+
| `recall_min_memories` | 1 | 2 | unclaimed memories needed before the first recall |
|
| 24 |
+
| `recall_cooldown` | 2 | 3 | turns between recalls |
|
| 25 |
+
| `end_affinity` | 50 | 68 | affinity gate for good/loop |
|
| 26 |
+
| `end_claimed` | 2 | 3 | claimed memories for good/loop |
|
| 27 |
+
| `bad_min_turn` | 4 | 6 | min turn for the wound ending |
|
| 28 |
+
|
| 29 |
+
Tester reproduces today's arc (wow ~turn 2, ending ~5-7). Full lands ~turn 8-12. Both branch good/loop at tone 20 and bad at tone ≤ −30 ∧ wounds ≥ 2, unchanged.
|
| 30 |
+
3. **How to Play overlay** — a dismissible panel over the menu. Copy (in the game's voice):
|
| 31 |
+
> You found a child at the edge of the wood. It has no memories of its own — so it asks for yours.
|
| 32 |
+
> Tell it something true: a person, a place, a moment you lived. It keeps each in its treasure.
|
| 33 |
+
> Later it speaks your memories back — in the first person, as if it had lived them. That is what it wants.
|
| 34 |
+
> But it remembers how you treat it. Be gentle, or be cruel. Three endings wait in the fog.
|
| 35 |
+
> *type and press enter · 🔊 mute · ↺ begin again · turn your sound on*
|
| 36 |
+
4. **Menu music + chime** — a slow, detuned music-box / drone **loop** synthesized in `sting.py` (`menu_loop_wav_bytes()`), embedded in the menu as a looped `<audio>`; the `<head>` controller starts it on the first menu gesture (autoplay gate), at low volume, and stops it on mode-select. Mode-select then plays the existing chime → greeting ritual and transitions to the game.
|
| 37 |
+
|
| 38 |
+
## Testing
|
| 39 |
+
- **Unit:** `PACING` config exists and `should_recall`/`decide_ending` honor `mode` (tester vs full gates); menu state init sets `mode`; mode-select returns the right visibility updates. How-to-play open/close. `menu_loop_wav_bytes()` returns real audio.
|
| 40 |
+
- **Live (screenshot):** the menu renders with the background + scrim + title + buttons legible; the switch to game works.
|
| 41 |
+
- **By ear (user):** menu melody on first gesture; chime→greeting on mode-select; game voice/idle/mute unaffected.
|
| 42 |
+
- **Pacing re-measure:** run `tools/pacing_sim.py` for both modes; confirm Tester ~5-7 and Full ~8-12.
|
| 43 |
+
|
| 44 |
+
## Risks / constraints
|
| 45 |
+
- **`visible=False` unmount** — only the menu (no persistent audio) and the game view toggle; game audio mounts on show. Verified pattern.
|
| 46 |
+
- **Autoplay** — menu music can't play before a gesture; it starts on the first menu interaction (consistent with the greeting rule).
|
| 47 |
+
- **Space build** — the background is an **image** (WebP), which HF accepts (only committed audio binaries are rejected); the menu melody is synthesized at runtime like the other stings. No new deps.
|
| 48 |
+
- **Scope** — five phases, each shippable; Phase 3 (modes) is the only engine-touching one and is pure-number config.
|
| 49 |
+
|
| 50 |
+
## Out of scope (deliberate)
|
| 51 |
+
- Re-painting the in-game scene (the game already has its stone/fog styling); this is the menu/front-door. A light F&H typography pass in-game can follow later if wanted.
|
| 52 |
+
- Save/continue, multiple save slots, settings screens. YAGNI for a hackathon front door.
|