# CLAUDE.md — project memory for Hallway 8 Guidance for Claude Code working in this repo. Read this first. ## What this is A browser game about memory and doubt. You move through a place that should be identical each loop; **turn back** if something changed, **go on** if nothing did. Eight correct calls in a row wins; one wrong call resets to the start. The place is described from rotating sentence pools with only a subset of details shown per loop, so the player must remember *meaning*, not wording. It ships several **arcs** (skins/backstories) that share one mechanic. Live on a Hugging Face Docker Space at `alvations/hallway8`. ## Stack & layout Python + Flask backend, vanilla HTML/CSS/JS frontend. No build step. ``` app.py Flask server; owns all game state, arc selection, answers hallway.py loads an arc, builds & describes a loop; difficulty ramp anomalies.py anomaly selection + wording/heading drift memory.py per-player habit tracking (PlayerMemory) data/arcs/*.json one file per arc: meta/skin, properties, sentence pools, story templates/index.html static/style.css skins (body[data-skin=...]) + layout static/game.js client: arc select, rendering, pacing, drift, sid, controls scripts/deploy_hf.py deploy/update the HF Space (run locally) scripts/make_private_hf.py toggle Space visibility (--public to release) scripts/export_landing_media.py render landing theme -> ogg + 1080p youtube mp4 (local) tests/test_game.py test suite (no pytest needed) docs/ARCS.md design rules, shared tuning, built + planned arcs ``` ## Run / test / deploy ```bash pip install -r requirements.txt python app.py # http://127.0.0.1:5000 (HOST/PORT/SECRET_KEY env) python tests/test_game.py # runs the whole tests/ suite (auto-discovers # tests/test_*.py); plain runner or pytest H8_EMU=1 python tests/test_game.py # also runs the headless-Chromium emulation # checks (tests/emulation.cjs); opt-in, skips # cleanly when node/Playwright/Chromium absent ``` **Emulation tests** (`tests/emulation.cjs`, driven by `tests/test_emulation.py`) cover client rendering that unit tests cannot reach: reduced-motion prose order + visibility, Act 2 touch verbs render and stay visible under reduced motion, the flavour line auto-fades after a readable dwell (its opacity fade is kept alive under reduced motion, since opacity is not motion), and the alternate-ending achievement never unlocks without a server-confirmed exit. Off by default so the fast suite stays fast; enable with `H8_EMU=1`. Deploy is **local-only**: the sandboxed web session's network policy blocks `huggingface.co` (403 at the proxy), so deployment must run from a machine with normal internet: ```bash export HF_TOKEN=hf_... # or `hf auth login`; a WRITE token python scripts/deploy_hf.py # creates/updates alvations/hallway8, uploads python scripts/make_private_hf.py # private (default) python scripts/make_private_hf.py --public # public on release day ``` The `Dockerfile` targets HF Spaces (port 7860, **single gunicorn worker** — game state is in-memory per process; do not scale workers). README front matter is the Space card (`sdk: docker`, `app_port: 7860`). **Shortcut test build (non-canonical).** `GOAL` (in `hallway.py`) reads `H8_GOAL` (clamped 1..8), so `H8_GOAL=3 python app.py` reaches the end screen in 3 calls for quick testing. `scripts/deploy_hf_shortcut.py` deploys that to a **separate** Space (`alvations/hallway8-shortcut`) with `H8_GOAL=3` and `H8_ACT2_TEST=1` set; it refuses to target the canonical `.../hallway8`. `H8_ACT2_TEST` boosts the Act 2 triggers (fold + false exit) so the endings are reachable for testing; it is OFF in the real game. Canonical is always 8; never ship a shortcut build as the real game. ## Core mechanic (server truth) `correct = (choice == "back") == has_anomaly`. Confidence never enters this. The answer (`_has_anomaly`, etc.) lives only in server state and is stripped from every payload (`Hallway.public` drops keys starting with `_`) — never leak it to the client. ## Session state — important State is keyed by a client-echoed **`sid`** (the `X-Sid` header), NOT the session cookie. Reason: on Spaces the app runs in a cross-site iframe where the cookie is dropped, which otherwise spawned a fresh default-arc slot on every request (arc kept reverting to `hallway-eight`, progress lost). `/api/new` returns `sid`; the client stores it and sends it on every call. The cookie remains only as a same-origin fallback. Keep it this way. ## Arcs Each arc is a self-contained `data/arcs/.json`; new files appear on the opening screen automatically. Every arc MUST have: a strong diegetic hook, an NPC that can become the anomaly, a loop-aware NPC twist that acknowledges the player (`knows` for silent figures, `speaks` for talkers), and a psychological `sense` property (non-physical wrongness). Full schema, design rules, shared tuning, and the queue of planned arcs are in **`docs/ARCS.md`** — read it before adding or changing arcs, and keep it updated. Every arc's `meta` also carries three win-screen strings: `share_prompt` (the arc-flavoured share invitation), `attempt` (the run-count "look back" line, must contain `{n}`), and `attempt_first` (the clean-first-run line). See docs/ARCS.md. Built: `hallway-eight` (blue), `stairway8` (green, `axis: vertical`), `coach8` (amber). Intros weave the turn-back/go-on rule into the story rather than stating it as rules — keep that style. ## Win screen: sharing + look back The exit screen is engine-wide; arcs only supply the three `meta` strings above. - **Look back** (`#look`): a quiet toggle under "You're out." reveals the arc's `attempt`/`attempt_first` line (localized server-side; `{n}` = run count from `PlayerMemory.attempts`, resets + 1), then **fades after ~4.5s and hides**, so the eye returns to sharing/replaying. Deliberately fleeting; keep it that way. - **Sharing** (`#share`): **progressive disclosure** keeps the reward moment calm. By default only the arc's `share_prompt` shows, as a quiet trigger (`#share-open`); tapping it reveals `#share-panel` (the invitation line + the share targets). One of eight cryptic invitation lines (`ui|share_1..8`) is chosen at random. Mobile-first: if `navigator.share` exists, show one **Share** button (native sheet reaches Instagram/Snapchat/etc.) and hide the explicit platform row; else X / Facebook / LinkedIn + Copy link. Shared URL = `location.origin + pathname` (override with `window.H8_SHARE_URL`). No stats are shared or tracked. - **Identity / social card:** the tab favicon and the Open Graph / Twitter share image are the game's `8` (`static/img/{og.png,icon-512.png,icon-180.png}`, meta tags in `templates/index.html` with absolute `_external` URLs). 8 is the whole visual identity; do not add animated motifs. - **First-reset onboarding** (`#learn`): shown once (localStorage `h8_onboarded`), heading is the localized `ui|instructions`, body reuses the arc's own intro. - **Ending credits** (`#credits`): on a **first escape of an arc** (`winIsFirst` in `commit`) the win button reads **End Credits** (`setAgainMode`) and the nudge is held back; tapping it rolls the credits, and only after they close do the nudge + **Walk it again** appear. Player-initiated (not auto), so the look-back and share get their beat first. Re-viewable any time via **View credits** at the bottom of the Memory overlay (`#credits-row`, gated on any `mem.wins`). Content is the `CREDITS` array in `game.js`, styled like the landing text: a **Marvel- style opening studio splash** (`MELON LAB` + `© 2026 alvations`), then EIGHT, cast (AI collaborators one size smaller via `{n, sm}`), and the dedication as the final beat. **Authored English-only on purpose** (a personal dedication; only `view_credits`/`end_credits` are localized). Every line is our own; it never references the game that inspired it. Under reduced motion it does **not** scroll (`html.motion-reduce .credits-roll { animation: none }`): it reads statically. Tap anywhere or the `×` to skip. - **Adaptive nudge** (`#nudge`): one quiet line above "Walk it again" that shifts with progress, unseen fragments here (`nudge_frags`, `{n}`) -> the other places (`nudge_arcs`) -> eight escapes from one (`nudge_eight`). It nudges breadth and depth only, never naming secret content (alternate endings, `npc_knows`). Hushed with `#look`/`#share` for the benediction's first beat. ## Collection: achievements + collectibles Client-side persistence/replayability layer (full design in **`docs/COLLECTION.md`** — read before touching it). Opened from a quiet corner button (`#ach-open`, top-right cluster with sound/settings, select screen only) so it lives off the landing; the overlay is titled **Memory** (`mem_title`). All state is `localStorage` `h8_mem` (never sent to the server, never scored). Layout top to bottom: Save/Load, then the achievements grid, then the **Recollections** fragment ledger (`col_section`). - **Only earned achievements appear**, populated as they unlock — no wall of padlocks, and no `{n} of {total}` count (it would spoil how many exist). `ACHS` in `static/game.js` holds `{id, test()}`; each earns a **drawn line glyph** (noir, chrome/accent tier). The `melon` "Melon Supporter" badge is the ONE colour break (a full-colour watermelon). Names/descriptions are localized `ach_n_*`/`ach_d_*` (English fallback `ACH_TEXT`); descriptions never spoil the detail to watch. - **Records are achievements.** How a run was won is folded into `ACHS`, not a separate ledger: `steady` (never guessed), `cold` (no touches), `nerve` (final call a correct turn-back on a real change), `streak` (>=3 clean escapes). Still tracked in `mem.records` via a live `run` object (reset on every fresh climb; observes the win logic, never changes it). The strongest mark still also surfaces as a fleeting win-screen "read record" (`read_*`) under the look-back. - **Fragment recall.** `recordFlashback(arc, idx, line)` stores the fragment text in `mem.flashText`; a filled ledger dot is a button that recalls its words (`col_recall` / `col_recall_empty`). Only collected fragments are quoted. - **Save/Load** exports `eight-save.json`: two blobs (memory + achievements) run through a keystream XOR seeded `melon8` — **obfuscation, not crypto** (keeps a save unreadable, hides the achievement count). Wrong file ignored silently. - **Erase** (`#ach-erase`): wipes the local memory back to a blank slate (runs, achievements, recollections, records), gated behind a deliberate confirm dialog (`#erase-confirm`, "This cannot be undone"). Display/language settings are preferences, not run-memory, so they are kept. Memory is `localStorage` only and per-browser, so this is a true reset for that device. - **Promo codes / redeem: removed for now.** The Code + Redeem UI, the `redeemPromo` engine, and the `I18N-XX` locale-unlock path are gone; the language picker exposes only the 8 core locales (the other 13 stay translated and compiled on disk, just hidden). The `melon` "Melon Supporter" badge is **kept** (glyph + `mem.melon`), currently unearnable, held for a future use; the `mem.promos`/`mem.melon`/`mem.langs` fields stay in the save for compatibility. Dead `promo_*` UI strings remain in the catalog, unreferenced. ## Audio Ambience is **generated live in the browser** (`static/audio.js`, Web Audio API) per arc — no audio files, no external assets. It is room tone, not music and not eerie stings: hallway = fluorescent buzz + ventilation hum (rare, soft flicker ~once/min); coach = steady almost-empty-subway roll with a rare lurch and a rare passing train (~once/min, not chuggy); stairway = near silence, low room tone and reverb space, with a rare wind gust (~once/min). "Rare" events use `_every(50000, 90000, fn)`; keep them sparse. The **landing** screen has its own quiet theme (`_landing`): a soft, slow vi-IV-I-V arpeggio, wistful fantasy, deliberately muted and background, never epic or eerie. `window.ambience.play(skin)` starts on entering a loop (or `"landing"` on the select screen); it stops/rebuilds on transitions. Landing audio starts on the first user gesture (autoplay policy). An exported render of the landing theme is saved at `static/audio/landing.ogg` (mono OGG Vorbis, ~42s, normalized; rendered offline from the same synth via OfflineAudioContext). An always-visible mute toggle (`#sound-toggle`) persists to `localStorage` (`h8_muted`); it is a **drawn SVG line icon** (speaker + waves, or speaker + x when muted), not an emoji, and inherits the chrome-tier colour. Audio only starts from a user gesture (browser autoplay policy). **Movement SFX:** committing a choice plays `window.ambience.step(dir, dwellMs, confidence)`, synthesised (footstep thud + scuff, plus a directional scene-change swish). A confidence score (0..1) is derived from `dwellMs` (`performance.now() - decideStart`, set when controls appear) and raised by an explicit certainty rating (only 3/4 ever reach a commit): confident -> 3-5 firm steps + swish; hesitant -> 2-4 tiptoe; long and unsure -> a single near-silent shuffle. **Running SFX:** after the act response, `ambience.run(dir)` plays a hurried 5-7 step flurry, used sparingly and mostly when the player turned **back** on a real anomaly (`had_anomaly`, ~22%; ~5% on a false alarm). All routed through the master gain, so the mute toggle covers it. To add a soundscape for a new arc, add a `_(bus)` builder and branch to it in `play()`. ## Rendering & reduced motion (keep it this way) Reduced motion (`html.motion-reduce`, from the toggle `h8_motion` OR the OS `prefers-reduced-motion`) suppresses animation/transition. Three load-bearing rules, each covered by an emulation test (see below): - **Do not force-`opacity:1` on anything that toggles off.** The motion-reduce reset pins "fade-in-once" elements visible; the Act 2 flavour line (`.touch-line`) toggles on AND off, so it is deliberately excluded from that list and instead keeps a real `transition-duration` under reduced motion (an opacity fade is not motion). It shows on a touch, holds ~4.5s, then **auto-fades** (`touchLineTimer` in `showTouchLine`, cleared in `clearTouch`). Never let it linger to the next commit or snap without a fade. - **Guard async UI callbacks against loop changes.** The hover-to-inspect aside uses a `renderGen` counter (bumped every `renderRoom`) plus `node.isConnected`, so a stale `setTimeout` from a past loop can never land an aside before the new loop's prose has rendered. - **Gate the alternate-ending badge on the server.** `takeExit` records the ending (unlocking `end_`) only when the server returns `kind == "exit"`, so the badge can never appear without the ending truly being reached. Verify client rendering with the **browser emulation tests**: `H8_EMU=1 python tests/test_game.py`. Full testing story (both layers, the deterministic-harness diagnostic method, browser gotchas) is in **`docs/TESTING.md`**. Read it before touching reduced-motion rendering or adding a UI-timing feature. ## Localization (i18n) Full reproducible playbook (process, audit, per-line + level-composition QE, post-edit QE, revision loop, the >=95 gate, adding a language/arc, and porting the whole rigor to another game) lives in **`docs/LOCALIZATION.md`** — read it before doing any translation work; the summary below is the map, that doc is the territory. Every translatable string has a deterministic key (`i18n.arc_items` / `ui_items`). English in the arc JSON is the source of truth; `hallway.build(mem, rng, locale)` rebuilds the same key per line and looks up the localized text, falling back to English key by key (so partial translations degrade gracefully). The client sends `X-Lang`; `app.py` resolves the locale and localizes meta/intro/win/arcs; a language picker (top-left corner of the landing) persists to `localStorage` (`h8_lang`). New UI keys since the first phase: `instructions`, `look_back`, `share_1..8`, `share_native/copy/copied`, the collection strings (`ach_section`, `col_section`, `ach_n_*`/`ach_d_*` badge names+descriptions, `col_recall`, `col_recall_empty`), and the settings/fold strings (`set_*`, `fold_*`, `promo_*`). New per-arc `meta` keys: `share_prompt`, `attempt`, `attempt_first`. Metaphoric strings (e.g. `look_back`) must be translated by *meaning*, not literally — see docs/LOCALIZATION.md. Pipeline (translations are model-generated + self-scored; no external MT): - `data/i18n/lines.json` = audit trail, `{key: {en_US:{text}, de_DE:{text,score, postedit,postedit_score,revisions[]}, ...}}` (exact shape the owner asked for). - Author `data/i18n/src/.json` (UI + shared) or `data/i18n/src//.json` (per-arc) -> `scripts/i18n_extract.py` (seed en) -> `scripts/i18n_ingest.py` (merge; globs both flat and per-arc files) -> `scripts/i18n_compile.py` (emit `data/i18n/compiled/.json` for the server + `static/i18n/.json` for the client UI). - `scripts/i18n_levelqe.py --arc ` composes N levels and scores each as the mean of its lines' postedit_scores (`data/i18n/levels.json`); gate is mean >= 95 over 120 levels. Below that, revise the weakest lines and re-run ingest/compile/levelqe. - English (`en_US`) is the fixed reference and is never scored. The picker exposes only the locales in `i18n.EXPOSED_LOCALES` (the phase's chosen set), intersected with locales that actually have a compiled catalog. Other locales can be fully translated + compiled on disk and still stay hidden until promoted into that list. `available_locales()` (served by `/api/langs`) enforces this; keep `EXPOSED_LOCALES` authoritative for what ships. Status: engine + language toggle live. All 21 locales translated for the three arcs (coach8, hallway-eight, stairway8) + shared UI, each clearing the level gate (120/120 >= 95, 0 leaks; means ~95.2-95.6). Eight are EXPOSED in the picker (English, German, French, Portuguese, Japanese, Korean, Simplified Chinese, Vietnamese); the other 13 (en_GB, es_ES, fr_CA, it_IT, nl_NL, cs_CZ, zh_TW, yue_HK, th_TH, id_ID, ms_MY, hi_IN, ta_IN) are fully translated + compiled on disk but hidden until promoted into `i18n.EXPOSED_LOCALES`. Per-locale iteration to the gate is easiest via `scripts/i18n_selfqe.py --loc ` (read-only, reads a locale straight from its src files; safe to run alongside others). ## UI conventions - **Controls:** back is always on the LEFT, forward on the RIGHT. The UI adds the arrow; arc labels (`go_on`/`turn_back`) are plain text with no arrows. Default arrows are horizontal `←`/`→`; set `"axis": "vertical"` in an arc's meta for ↑ (back) / ↓ (forward), as stairway does. - **Arc colours must stay visually distinct** (currently blue / amber / green) and must **carry into the loop**, not just the landing: the choice buttons (`.choice`), the HUD progress word (`.progress`), and the ambient background temperature all take a whisper of the arc's `--accent`/`--hue`, so the place keeps its identity all the way in. Keep these subtle (a hair, not a wash). - **Chrome follows the last-played arc.** `applyMeta` records the arc's skin in `localStorage` `h8_last_skin`; the landing (and the settings panel opened from it) uses `lastSkin()` instead of snapping back to the default, so the settings accent matches the arc you were just in, not a fixed blue. - **Language picker** is a quiet fixed control in the **top-left** corner (`#lang-corner`, a globe + native `