Gothic mystery, M-scope (18-22 rooms), authored TypeScript engine, no LLM at runtime, fullscreen CRT terminal at /mystery, two themes (amber + ANSI), context-aware mobile chips, auto-save to localStorage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
19 KiB
Mystery text adventure ("Halfstreet") — design spec
Date: 2026-05-08 Status: Draft — pending user approval Scope: A self-contained text adventure game accessed via the homepage MysteryCard (the violet ★). Includes the route, the game engine, the world content, the terminal UI, the mobile input fallback, and the persistence layer. Excludes any change to the bento grid itself (handled by the homepage redesign plan), and excludes pointing halfstreet.io at the route (a future DNS task, not part of this build).
Dependencies: This build is independent of the homepage redesign plan. The route works standalone (typing /mystery directly always works). The MysteryCard click handler is the entry point if and when the redesign ships; if the redesign hasn't shipped yet, the route still functions and any existing homepage element can link to it.
Goal
Ship a gothic mystery text adventure called Halfstreet that reads as a real piece of writing — not a tech demo. Style it after early MS-DOS text adventures with two switchable themes (amber phosphor, ANSI/BBS color). Engine is hand-authored, deterministic, and runs entirely client-side. No LLM is called at runtime. The game must be genuinely fun on desktop and playable on mobile.
Non-goals
- Calling Claude or any LLM at runtime. Authoring-time use of Claude is fine; runtime is not. (See
feedback_no_llm_in_production.md.) - A backend / server / database — game state is client-side only.
- A graphical adventure (no Sierra-style image pane). Text only.
- A multiplayer or shared-state experience.
- Procedural / random world generation. The world is fully authored.
- Sound or music in v1.
- Pointing halfstreet.io at the route (separate DNS task).
- Touching the existing bento grid layout, MysteryCard component, or the
mystery-card-clickevent wiring (assumed in place from the homepage redesign).
User-facing summary
A visitor clicks the ★ on the homepage. The page transitions to a fullscreen CRT terminal at /mystery. They are dropped into the opening of Halfstreet — a gothic mystery in the register of Le Fanu and Shirley Jackson, told entirely in second-person prose. They explore a haunted estate, collect items (keys, lamps, a tarnished mirror, a cane sword), encounter villains they must outwit or fight, and over 18–22 rooms of content arrive at one of two or three endings. Their progress auto-saves; if they leave and come back a week later, it's still there. ESC or > quit returns them to the homepage.
On mobile, a context-aware row of tap chips above the input lets them play without typing.
Aesthetic
Two themes, user-toggleable via a small [B] [C] switch in a corner of the terminal. The active theme is remembered in localStorage. Both share: monospace font (system stack: 'Courier New', 'Cascadia Mono', 'Consolas', monospace), CRT bezel, scanlines, subtle phosphor text-glow, blinking cursor.
| Amber (default) | ANSI | |
|---|---|---|
| Background | #1a0d00 |
#000080 |
| Foreground | #ffb000 |
#ffffff |
| Accents | none | #ffff55 (titles), #55ffff (interactables), #aaaaaa (dim) |
| Room headers | plain text, dashed underline | double-line box-drawing characters |
The toggle is one of the first things a player sees; the choice is non-destructive and reversible per turn.
Architecture
A self-contained TypeScript engine inside the existing Astro 6 site, served as a single client-rendered route.
Three layers, with strict directionality:
┌────────────────────┐
│ World content │ (authored TypeScript modules)
│ rooms / items / │
│ encounters / │
│ story flags │
└─────────┬──────────┘
│ pure data, no logic
▼
┌────────────────────┐
│ Engine │ (pure functions)
│ parser • dispatch │
│ encounters • save │
└─────────┬──────────┘
│ (state in, state + narration out)
▼
┌────────────────────┐
│ UI │ (browser-only)
│ terminal • chips • │
│ theme • CRT styles │
└────────────────────┘
- World content has no logic — it is plain data the engine reads.
- The engine is pure — it takes state + input and returns new state + narration. No DOM access. Fully unit-testable.
- The UI is render + input only — it never mutates game state directly; it sends commands to the engine and renders the result.
This split means the writing (largest piece) can iterate without touching engine code, the engine can iterate without touching the UI, and the engine is fully testable without a browser.
Where it lives
Route
A new Astro page at src/pages/mystery.astro rendering the terminal at /mystery. The page has no bento, no nav, no footer — it fills the viewport. Title is "Halfstreet — Ethan J Lewis".
Entry from the homepage
The MysteryCard (created in the homepage redesign plan, Task 6) already dispatches a mystery-card-click CustomEvent on click. A small client script in the bento page listens for this event and navigates to /mystery (full navigation, not a SPA route — keeps the URL real and bookmarkable).
Exit
> quit or ESC navigates back to /. Game state is auto-saved before exit; revisiting /mystery resumes from the same point. The browser back button works the same way.
Engine details
State
A single immutable object. Every turn produces a new one; the prior state is retained for one-step UNDO.
interface GameState {
location: RoomId
inventory: ItemInstance[]
flags: Record<string, boolean | number | string>
resolveLevel: 'steady' | 'shaken' | 'reeling' | 'returning'
encounterState: Record<EncounterId, EncounterPhase> | null
lastNoun: NounRef | null // for pronoun resolution ("it")
transcript: TranscriptLine[] // capped at 200 lines
theme: 'amber' | 'ansi'
schemaVersion: number // for save migration
}
Parser
Tokenizes a line of input and resolves it to a structured command:
type ParsedCommand =
| { kind: 'verb-target'; verb: Verb; target: NounRef; preposition?: Prep; indirect?: NounRef }
| { kind: 'meta'; verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme' }
| { kind: 'unknown'; raw: string }
Features:
- Case-insensitive
- Synonyms, e.g.
take/get/grab/pick up→take - Direction shortcuts:
n/s/e/w/u/dmap togo <dir> - Pronouns:
itresolves tostate.lastNoun; ambiguous if none - Multi-word object disambiguation:
take keywith two keys present prompts"Which key — the brass key or the iron key?"and waits for a one-word reply lookrecaps the room;look at X(orexamine X/x X) examinesinventory/inv/i- Meta-commands:
restart,undo,hint,save(no-op acknowledgement since save is automatic),quit,theme(toggles) - Unknown verbs return one of a small pool of in-character refusals, never a generic "PARSER ERROR"
Dispatcher
Pure function: (state, command) => { state, narration }. Looks up the verb handler, validates against the world data (e.g. is the exit valid? is the item present? is the encounter active?), produces narration text, returns the new state.
Encounters
Each encounter is a small phase machine. Phases are authored data:
interface Encounter {
id: EncounterId
startsIn: RoomId
initialPhase: EncounterPhase
phases: Record<EncounterPhase, {
description: string // shown each turn this phase
transitions: {
verb: Verb
target?: NounRef
requires?: ItemRef[] | StateCondition
to: EncounterPhase | 'resolved' | 'failed'
narration: string
resolveCost?: 1 | 2 // wrong moves cost player resolve
}[]
}>
onResolved: { setFlags?: Record<string, unknown>; unblockExits?: ExitRef[] }
}
The "right" verb advances the phase; "wrong" verbs cost resolve and surface a clue in narration. A handful of minor enemies skip the phase model entirely and use simple HP-style combat (player swings sword, enemy HP decrements, enemy hits back).
Resolve
A four-step soft-HP track: steady → shaken → reeling → returning. Each "wrong" encounter verb costs a step. At returning, the next failed verb retreats the player to the previous safe room with a clue. Resolve regenerates one step per safe room entered. The player cannot truly die except via one specific late-game choice that reaches a "bad ending" — RESTART is the only way out.
Save
Auto-save to localStorage every turn under key halfstreet:save:v1. Saved data is the full GameState minus transcript rendering details (transcript is included but capped at last 200 lines). On load, if a save exists, the terminal shows > CONTINUE / > NEW GAME choices first. RESTART clears the save with a one-prompt confirmation.
schemaVersion is bumped if the save shape changes; older saves are silently discarded with a "Save format updated — starting fresh" message rather than failing.
World content
Story
Halfstreet — gothic mystery, M-scope, 18–22 rooms across three areas (the house, the grounds, the lower vaults). The player is an unnamed visitor called to an address that does not appear on any map. The road behind them is gone. The single overarching question — what is buried at Halfstreet, and why — resolves in one of three reachable end-states: a "true" ending, an alternate "wrong" ending reachable via specific player choices, and one "bad" ending reachable only via the late-game choice that costs the player their character. All three are authored.
Voice
Second-person, present tense, sparse. Register: Le Fanu, Shirley Jackson, M.R. James. Authored prose, not generated. Each room has:
- A
firstVisitdescription (longer, sets atmosphere) - A
revisitdescription (shorter, references state) - An
examineddescription (richer detail when player typeslook) - Per-object descriptions
Each encounter has phase-by-phase narration, with multiple variants for wrong-verb responses so encounters don't feel scripted.
Items
Authored item set including: brass key, iron key, tarnished silver mirror, oil lamp, safety matches, cane sword, folded letter, plus a handful of discoverable artifacts. Items have state (lit / unlit, held / placed), can combine in specific ways (matches + lamp = lit lamp), and gate progression. Inventory is unbounded for an M-scope game.
Authoring workflow (hybrid)
A two-stage process:
- Content bible (Claude drafts, user approves): a single document under
docs/superpowers/specs/halfstreet-bible.mdlisting all rooms with one-sentence descriptions and exits, all items with purpose, all encounters with their solution, the opening scene in full prose, and the final scene(s) in full prose. User reviews and edits before any room prose is locked. - Room prose (Claude drafts in user's voice): once the bible is approved, Claude authors the per-room prose in batches; user reviews each batch and may reject/rewrite. The opening and ending scenes from the bible are used verbatim — they are the style anchors.
This means: user controls the structure and the style anchors; Claude does the volume drafting using those anchors.
UI / Terminal
A single client-only TypeScript module renders the terminal. Layout:
┌─────────────────────────────────────────────────────────┐
│ ╔═══════════════════════════════════════════╗ [B] │ ← theme toggle
│ ║ ║ [C] │
│ ║ transcript scrolls upward as text ║ │
│ ║ appears; latest line at bottom ║ │
│ ║ ║ │
│ ║ ║ │
│ ║ > take torch_ ║ │
│ ╚═══════════════════════════════════════════╝ │
│ │
│ ┌─ chips (mobile/touch only) ──────────────────────┐ │
│ │ ↑N →E ↓S ←W LOOK TAKE TORCH INV │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
- Transcript auto-scrolls; old lines fade in opacity at the top.
- Cursor blinks at 1.05Hz at the input position.
- Scanlines overlay the entire terminal at low opacity; can be disabled in a
[?]settings dropdown for accessibility. - Phosphor glow is a
text-shadowof0 0 1.5px currentColor. - Bezel is the rounded outer container with inset shadow.
- The transcript is keyboard-scrollable (
PageUp/PageDown).
Mobile chips
Below the terminal on touch devices (detected via pointer: coarse media query), a horizontal row of context-aware tap chips:
- Direction chips for valid exits (
↑ N,→ E, etc.); invalid directions are shown dimmed - Item chips for visible-and-takeable items (
TAKE TORCH,TAKE KEY) - Encounter chips for present NPCs/encounters (
EXAMINE REVENANT,HOLD MIRROR TO REVENANT) - Persistent chips:
LOOK,INV
The chip set recomputes from world state every turn. Tapping a chip injects the command into the input and submits. Typing still works on mobile (Bluetooth keyboard, etc.). Chip styling matches the active theme.
Theme toggle
A small [B] [C] indicator in the upper right. Click cycles. Choice persists in localStorage under halfstreet:theme:v1. Default is amber.
Persistence
| Key | Value | Lifetime |
|---|---|---|
halfstreet:save:v1 |
full GameState (JSON) | until RESTART or schema bump |
halfstreet:theme:v1 |
'amber' | 'ansi' |
until manually changed |
No cookies, no server, no third-party. All state lives in the browser.
Error handling
- Malformed save in localStorage → silently discard, start fresh, log to console
- Schema-version mismatch → discard with a one-line in-terminal notice, start fresh
- Unknown verb → in-character refusal, no error
- Ambiguous noun → disambiguation prompt
- No-op verb in current context (e.g.
takesomething that isn't there) → in-character explanation, no error - Engine throws (should never happen) → caught at the UI boundary, shown as
[ The terminal hums and resets. ], save preserved, transcript continues
Testing
Vitest tests for the engine. No automated UI tests; UI is verified by manual smoke at desktop / tablet / mobile breakpoints.
| File | What it covers |
|---|---|
parser.test.ts |
Tokenization, synonyms, direction shortcuts, pronoun resolution, disambiguation, meta-commands |
dispatcher.test.ts |
Each verb against representative world states; invalid commands; resolve depletion |
encounters.test.ts |
Every encounter from start to all valid resolutions, including failure paths |
save.test.ts |
Round-trip identity; schema-version migration / discard; transcript truncation at 200 lines |
playthrough.test.ts |
A scripted command sequence reaches a known win-state from a fresh start |
File layout
src/
pages/
mystery.astro # the route — fullscreen CRT terminal
mystery/
world/
rooms.ts # room data + authored prose
items.ts # item data
encounters.ts # NPC / encounter data
story.ts # story flags, win conditions, endings
index.ts # barrel export of the world bundle
engine/
types.ts # GameState, ParsedCommand, etc.
parser.ts
parser.test.ts
dispatcher.ts
dispatcher.test.ts
encounters.ts
encounters.test.ts
save.ts
save.test.ts
playthrough.test.ts
ui/
terminal.ts # client-side terminal rendering + input
chips.ts # mobile chip computation
theme.ts # theme toggle + persistence
crt.css # bezel, scanlines, phosphor, themes
docs/
superpowers/
specs/
2026-05-08-mystery-text-adventure-design.md # this file
halfstreet-bible.md # content bible (created in implementation)
The MysteryCard click handler from the homepage redesign plan is unchanged; this build adds the route and the terminal at the other end of the navigation.
Acceptance criteria
- Clicking the ★ on the homepage navigates to
/mysteryand presents the opening scene of Halfstreet in the amber theme by default. - The terminal accepts typed input on desktop and tap-chip input on mobile, with the chip set updating per turn from world state.
- The player can complete one full playthrough end-to-end (scripted in
playthrough.test.ts). - Auto-save persists across page reloads and across browser sessions;
RESTARTclears the save with confirmation. - Theme toggle switches between amber and ANSI per click; choice persists.
- ESC and
> quitreturn to/; revisiting/mysteryresumes from the saved state. - The engine ships with comprehensive vitest coverage of the parser, dispatcher, encounter state machines, and save layer. The
playthrough.test.tsintegration test reaches a known win-state from a fresh start. - No runtime LLM calls anywhere in the shipped code.
- The 18–22 rooms of authored prose pass a final user pass before merge — writing quality is the primary acceptance bar.
- Bundle size for the
/mysteryroute does not exceed 80kb gzipped (excluding shared site assets), to keep first-load time under a second on mid-tier mobile.
Out of scope (future)
- Sound design (typewriter clicks, ambient drone)
- A high-score / "endings collected" persistent meta-layer across plays
- Pointing halfstreet.io at
/mystery— DNS task, not a code task - Sharing a transcript / endgame screenshot
- Achievements / steam-style unlocks
- Localization