# M1 PR2 — Playable Loop Design Spec > **Status:** Approved by ginnoir (brainstorm 2026-06-11). > **Branch:** `feat/m1-playable-loop` off `main`. > **Closes:** Gitea #5 (Action queue UI), #6 (Story graph and first branch). > **Parent plan:** `docs/plans/2026-06-11-m1-vertical-slice.md` §PR2. ## Summary PR2 turns the PR1 queue engine into the first **playable loop**: a hybrid story graph (choices, action-complete beats, boot, resource thresholds), a full-window visual-novel story panel with running story log, upgraded action queue UI with narrative context (subtitles/tooltips), and player preferences for story-open and action-detail modes. Stub prose and placeholder names prove branch mechanics; real content replaces them in PR3. ## What we decided (brainstorm) | Topic | Decision | |---|---| | Story pacing | **Hybrid** — manual choices, action-complete beats, boot intro, resource thresholds | | Threshold checks | On **action completion** and on **publish** (~10 fps) | | Stub A/B branch | **Flags + resources + route-exclusive action unlocks** | | Action context | `storyHint` subtitle + `storyTooltip` with mechanics and consequences | | Story UI | **Full-window overlay** (VN layout + running story log), not inline strip | | Story open behavior | **Player-configurable**, default **auto on new beats**; modes: auto / choices-only / manual | | Action detail display | **Inline subtitle default**; hover on desktop; configurable (inline / hover / info-button) | | PR2 scope | **Mechanics + minimal Settings drawer**; full mobile layout pass deferred to PR4 (#10) | | Architecture | **Approach 1** — pure engine triggers + thin UI/prefs in state layer | ## Architecture ``` src/engine/story.ts traverse, applyChoice, evaluateTriggers, outcomes src/engine/game.ts + storyFlags, currentStoryNodeId, seenStoryNodeIds src/engine/save.ts persist story fields in SAVE_VERSION=1 (.default()) src/content/storySchema.ts Zod story node / choice / trigger / outcome defs src/content/story.ts stub ~6-node graph src/content/schema.ts + action.storyHint, action.storyTooltip src/state/storyOrchestration.ts evaluateTriggers after boot, publish, actionComplete src/state/prefs.ts localStorage: storyOpenMode, actionDetailMode src/state/viewModel.ts + story view, action availability, hints src/state/runtime.ts wire orchestration, applyChoice, cancelQueue src/ui/StoryPanel.tsx full-screen overlay, VN passage, choices, story log src/ui/ActionPanel.tsx queue list, cancel, disabled, subtitles/tooltips src/ui/SettingsDrawer.tsx two preference selects ``` **Boundaries (unchanged from M0):** - `src/engine/` — pure TS; no React, DOM, localStorage, wall clock. - `src/state/` — orchestration, prefs, RAF, persistence. - `src/ui/` — renders view model; calls runtime commands only. ## Story schema ### StoryNode ```typescript StoryNode { id: string prose: string choices?: StoryChoice[] triggers?: StoryTrigger[] enterOutcomes?: StoryOutcome[] } ``` ### StoryChoice ```typescript StoryChoice { id: string label: string requirements?: { minResources?: Record requireStoryFlags?: string[] excludeStoryFlags?: string[] } outcomes: StoryOutcome[] targetNodeId: string } ``` ### StoryTrigger ```typescript StoryTrigger { type: 'boot' | 'actionComplete' | 'minResources' actionId?: string minResources?: Record targetNodeId: string once?: boolean // default true } ``` ### StoryOutcome | Type | Effect | |---|---| | `setFlag` | `storyFlags[id] = true` | | `clearFlag` | `storyFlags[id] = false` | | `grantResource` | increase resource | | `consumeResource` | decrease resource (validated) | | `log` | narrative line appended to story log (no state change) | Outcomes on a choice or `enterOutcomes` apply atomically before advancing. ### Action narrative fields ```typescript action.storyHint?: string // one-line subtitle (inline mode) action.storyTooltip?: string // longer detail: yields, costs, consequences ``` Tooltip copy may reference flags/choices textually (authoring); engine does not parse natural language. ### Content validation At `buildContent` / story load: - All `targetNodeId` references resolve. - Trigger `actionId` values exist in `actionsById`. - Resource ids in outcomes/triggers exist. - Graph has exactly one boot trigger entry point. - Invalid graphs throw at load (dev-time), matching action schema behavior. ## GameState extensions ```typescript interface GameState { // existing: resources, activeActionId, actionElapsedMs, actionQueue storyFlags: Record currentStoryNodeId: string seenStoryNodeIds: string[] } ``` **Save v1** (no version bump; PR4 ships formal v1→v2 migration): ```typescript storyFlags: z.record(z.string(), z.boolean()).default({}) currentStoryNodeId: z.string().default('') // hydrated to entry on first boot if empty seenStoryNodeIds: z.array(z.string()).default([]) ``` ## Engine API (`src/engine/story.ts`) | Function | Purpose | |---|---| | `initStory(state, content)` | Resolve entry node from boot trigger; set `currentStoryNodeId` | | `evaluateTriggers(state, content, ctx)` | ctx: `{ reason: 'boot' \| 'publish' \| 'actionComplete', actionId? }`; returns `{ enteredNodes: string[], events: StoryEvent[] }` | | `applyChoice(state, content, choiceId)` | Validate requirements, apply outcomes, advance, append to `seenStoryNodeIds` | | `getAvailableChoices(state, content, nodeId)` | Filter choices by requirements | | `getCurrentNode(state, content)` | Lookup helper | **Trigger evaluation rules:** 1. **`boot`** — fires once on `initStory` if target not seen (when `once !== false`). 2. **`actionComplete`** — fires when `ctx.reason === 'actionComplete'` and `ctx.actionId` matches. 3. **`minResources`** — fires when all thresholds met; checked on `publish` and after `actionComplete`. 4. A trigger does not re-fire if `targetNodeId` is in `seenStoryNodeIds` and `once !== false`. 5. Entering a node applies `enterOutcomes`, adds id to `seenStoryNodeIds`, emits events. **Integration with actions:** `game.ts` `isActionAvailable` receives `state.storyFlags` (remove PR1 default `{}` placeholder at call sites). ## Stub story graph ~6 nodes, one A/B fork, placeholder prose: ```text boot_intro └─ fork_choice ─┬─ route_a_beat → unlocks fortify_camp (requireStoryFlags: route_a) └─ route_b_beat → unlocks push_onward stub action (requireStoryFlags: route_b) threshold_beat (minResources coin ≥ 3) → merchant_flavor actionComplete(scout_path) → scout_aftermath ``` **Route outcomes:** - Route A: `setFlag route_a`, grant supplies, unlock `fortify_camp`. - Route B: `setFlag route_b`, grant coin, unlock new stub action `push_onward`. Add `push_onward` to `definitions.ts` with `requireStoryFlags: ['route_b']`. ## Runtime orchestration ```text boot → loadGame → initStory → evaluateTriggers(boot) → publish each frame: tickGame → [if action completed this tick] evaluateTriggers(actionComplete) publish (≥10fps) → evaluateTriggers(publish) if new node: appendStoryLog open StoryPanel per prefs.storyOpenMode optionally mirror one line to event log applyChoice → engine → appendStoryLog → publish → close/minimize panel cancelQueuedAction → engine → publish ``` `src/state/storyOrchestration.ts` owns the trigger call sequence; runtime invokes it. ## Player preferences (`src/state/prefs.ts`) Stored in `localStorage` (not save v1): | Key | Values | Default | |---|---|---| | `storyOpenMode` | `auto` \| `choices-only` \| `manual` | `auto` | | `actionDetailMode` | `inline` \| `hover` \| `info-button` | `inline` | **SettingsDrawer** — gear icon in header; two `