Overview
Status: COMPLETE — Full design documented. Open implementation gaps tracked in Known Issues.
Last updated: October 27, 2026
Mystery — Game Design Overview
Status: COMPLETE — Full design documented. Open implementation gaps tracked in Known Issues.
Doc coverage:: COMPLETE — Full code extraction done. All mechanics verified against Java source.
Mystery is a detective investigation game where players explore zone-based crime scenes, interrogate NPCs via recursive conversation trees, collect categorized clues, confront suspects with evidence, and make formal accusations through a guided flow of suspect, weapon and motive, with the location asked last only when the scenario names it. The core design centers on information gathering under efficiency pressure — the scoring system rewards finding the truth quickly, not exhaustively.
Game Identity
| Property | Value |
|---|---|
| play_game ID | mystery |
| skill_identifier | MURDER_MYSTERY |
| Genre | Detective mystery with NPCs and clues |
| Engine | Zone-based investigation with NPC interrogation and clue collection |
| DB Schema | mystery |
Design Pillars
- Information Asymmetry — NPCs lie, clues mislead, testimony conflicts are intentional (not bugs)
- Efficiency Rewards — Score penalizes excessive movement and conversations, rewarding focused investigation
- Progressive Disclosure — Clue-gated paths, conversation options, and confrontation unlock as evidence accumulates
- Replayability — Multiple scenarios with independent leaderboards, score preservation across replays
Core Game Loop
Select scenario (or resume abandoned case)
→ Explore zones (grid navigation, directional + named)
→ Investigate locations (examine, search, look)
→ Discover clues (badge-based, 6 categories)
→ Talk to NPCs (recursive conversation trees)
→ Get testimony, leads, clue-gated revelations
→ Confront NPCs with evidence (+50/-25 points)
→ Review case file (evidence, suspects, progress)
→ Make accusation (suspect → weapon → motive, then location only when the scenario names a murder_location)
→ Scoring (14 scoring components, efficiency bonuses, penalty caps)
→ Leaderboard entry
Launch Opens On The Case
A launch speaks the case first: on a first launch the scenario's narrative, who is here and the first move, then "What would you like to do, Detective?"; on every later launch one line of where the player stands (§ Adaptive onboarding). The first offered option is a case verb: talk to the witness on the start square, or search. Nothing that is not the case stands ahead of it:
- The one-time beta note and billing line are spoken when the player exits, never on launch
(,
WelcomeTextUtility.BILLING_ASK). - Pending chat messages are read after the case has spoken (, before the options readout), so a launch with auto-listen mail still offers the case verb first and LISTEN_TO_MESSAGES after it.
Nothing is removed: the first launch keeps the full narrative, a returning player hears the
introduction by saying hear the story, which status offers, and the messages are still read on the
same turn.
Data Model
Entity Relationship Map
MysteryAccount
├─ currentProgress: MysteryPlayerProgress
│ ├─ cluesFound: List<Long> → MysteryClue.clueId
│ ├─ weaponsDiscovered: List<Long> → MysteryWeapon.weaponId
│ └─ clueConfrontations: Map<clueId, npcId> → MysteryNPCConversationTree
└─ bestScores: Map<scenarioId, MysteryScenarioBestScore>
MysteryScenario
├─ → MysteryCharacter (suspects, victim, quest giver)
├─ → MysteryClue (evidence items)
├─ → MysteryWeapon (potential murder weapons)
├─ → MysteryZone (locations)
└─ → MysteryNPCConversationTree (per-NPC dialog)
MysteryCharacter
└─ npcId → MysteryNPCConversationTree.npcId
MysteryZone
└─ zoneSquares: Map<"x,y", MysteryZoneSquare>
├─ conversationNpcId → MysteryNPCConversationTree.npcId
├─ clueIds → MysteryClue.clueId
└─ requiredClueId → MysteryClue.clueId (for locked areas)
MysteryNPCConversationTree
└─ rootNode: MysteryConversationNode (recursive)
└─ options: List<MysteryConversationOption>
├─ nextNode → MysteryConversationNode (recursive)
├─ revealsClueId → MysteryClue.clueId
└─ requiredClueIds → MysteryClue.clueId (AND logic)
Key Enums
| Enum | Values | Purpose |
|---|---|---|
MysteryGameState |
IN_PROGRESS, READY_TO_ACCUSE, COMPLETED, ABANDONED | Game lifecycle |
AccusationStep |
NONE, CHOOSING_LOCATION, CHOOSING_SUSPECT, CHOOSING_WEAPON, CHOOSING_MOTIVE | accusation state machine, suspect first |
MysteryClueCategory |
PHYSICAL, TESTIMONY, MOTIVE, TIMING, LOCATION, RELATIONSHIP | 6 evidence categories |
MysteryDifficulty |
EASY, MEDIUM, HARD | Scenario difficulty rating |
MysteryAudioType |
INTRODUCTION, CHARACTER, INVESTIGATION, ACCUSATION, RESULT | Audio asset types |
Account Model (Flat JSON Pattern)
MysteryAccount stores all player data in a single JSON column (same pattern as Darkness Falls):
customerId— Player identifiercurrentProgress: MysteryPlayerProgress— Active game state (null when not playing, with ONE documented exception: a first-timer's Gothic Manor case is pre-materialized before confirmation — see Scenarios, First-Timer Pre-Loaded Gothic Manor Case)bestScores: Map<Long, MysteryScenarioBestScore>— Best score per scenario
Scenarios
Each scenario is a self-contained mystery case with its own zones, NPCs, clues, weapons, and solution.
MysteryScenario fields:
scenarioName,description,introductionText,introductionNarrative(with voice tags)scenarioPack: String— Groups related scenarios into a named pack (e.g., "Mansion Mystery Pack")settingDetails: Map<String, Object>— JSON map of scenario-specific configuration. Key used by the engine:accusation_motives: List<String>— Scenario-specific motive options for Step 4 of accusation. Falls back to a generic 5-motive list if absent.
difficulty: MysteryDifficulty(EASY/MEDIUM/HARD)estimatedPlayTimeMinutesstartingZoneRef— Initial zone (derived from scenarioId if null)minCharacters,maxCharacters— NPC count boundsparentScenarioId,variantLabel— For scenario variants ("Original", "Variant A", etc.)
Scenario selection flow: Start game → list available scenarios → select → load intro narrative → place player in starting zone.
Picking from the case list. Each page of the list numbers its cases from one ("Option 1" is the
first case on the page shown), so "option 6" on page 2 is the sixth case read out, never another
rendered option such as Case of the Day; the option's key stays the case's place in the whole list.
A case named from any page is chosen even when it is not on the page shown: an exploration miss
that names a case (, two words or more) selects it. Choosing a
case while another case is open with work in it asks first ("Say yes to leave it and start ..., or
say no to keep investigating"); the yes option carries CONFIRM_CASE_SWITCH_<id> back to
selectScenario. A guided first case with nothing done in it is left without the ask.
Which case "next case" opens (, used by the solve's
offer and by "next case" itself): from the unsolved cases the player can open without badges,
today's Case of the Day while it is among them, then the first case never started, then the first
started once. A case started twice and left unsolved is passed over and named with the way back
("You have left The Jealous Heart twice, so here is another. Say its name to go back to it."): a
player who started one case fifteen times and left it every time was handed it again each time.
The count is the per-player use count MYSTERY_CASE_STARTED_<scenarioId>, written by
beginScenario, so no field is added to the Kryo-cached MysteryAccount. A launch back into a case
left with nothing done in it (no clue, no interview) speaks the one-line where-you-stand and adds
"Or say new case to choose another." with a New Case option, instead of narrating the case again.
Adaptive onboarding scales the launch by play count ():
| Play Count | Launch |
|---|---|
| 1 (first play) | The case's full narrative, the room, the witness and the first move |
| 2+ | One line of where the player stands: the room, the clues found, who is here and the verbs, for example "You are in Bakery Floor with 2 clues. Clara Webb is here. Say talk, search, or move." |
A returning player already heard the case, and hearing its introduction on every launch was the
commonest replay complaint, so the introduction moved behind status. Status reads where the player
stands and offers Hear the Story, an option on the status action keyed story
(), which reads the case's introductionText (or the narrative when the
scenario has none). Status does not read it every time, since a player asking for status asked for
the readout. Nothing is removed: the story is one phrase away. A new game still opens its case with the full narrative ().
New game resets all progress (clues, zones, NPCs, score) but preserves best scores from previous completions.
First-Timer Pre-Loaded Guided Onboarding Case (EASY Academy)
A first-timer — a player with empty bestScores — has a live guided onboarding case already materialized in currentProgress before they ever confirm "Begin Investigation". The guided case is the lowest-id EASY Academy tutorial case (Murder at the Seaside Bakery, scenarioId 40, EASY, free tier), with Gothic Manor (scenarioId 1) as the fallback when no EASY case is active. An EASY case is a short two-zone tutorial with a start-square witness and a first clue reachable in one or two moves, so a brand-new player's opening case is completable in one sitting rather than a full MEDIUM mystery — the whole point of the onboarding funnel is a FIRST COMPLETION. On the player's first start, the entry/status path materializes this case and offers the onboarding prompt ("we are starting you with Murder at the Seaside Bakery"). An immediate status said BEFORE any explicit selection already reports a live case: the bakery floor, clues found 0 of N, Clara Webb the cashier is here. status, search, and talk all act on this pre-loaded case. The first-timer branch in only OFFERS the onboarding options and does not itself call beginScenario; currentProgress is materialized earlier on the entry/status path ().
This is intended guided onboarding, not a bug. Dropping a brand-new player straight into a solvable EASY tutorial case — rather than making them parse a thirty-plus case catalog first — is the funnel's deliberate on-ramp: the player is already inside a case and learns the verbs by doing them. The consequence for the data model is that the Account Model rule "currentProgress is null when not playing" carries one documented exception: a first-timer's currentProgress is non-null from their very first start, before any confirmation. The single guided-case resolver is (EASY-first, Gothic Manor fallback); the delegate, the first-timer scenario offer, and the new-player Case of the Day all route through it so the pre-loaded case and the offered case always agree. Owner decisions: human_question 22999 / decision 360 (2026-06-23, the pre-loaded case is intended); the EASY-first routing is the Mystery onboarding overhaul (2026-07-13, human_idea 24153).
Free Tier and the EASY Intro On-Ramp
The free tier is any scenario with scenarioId 1 through 5 OR difficulty EASY. The gate lives in , applied in both listScenarios (display) and selectScenario (premium charge). The original five free cases are MEDIUM; every EASY case is the guided intro on-ramp, kept free as the funnel entry point. Which cases are EASY is the difficulty of the scenario rows in the content database, never a list here: the gate reads it, so a new EASY case joins the free pool with no engine change. The EASY cases shipped are scenarioIds 40 to 42 (the Academy Mysteries pack: the Seaside Bakery, the Book Club, the Garden Party) and 43 to 45 (game-content#143); theNewEasyCases43To45_openWithoutBadges_soTheFreePoolCountsThem holds the gate to that.
Each is a small 2-zone, 3-by-3-grid case with one victim, three interviewable suspects (one murderer), one murder weapon plus a decoy, five clues (three critical), and shallow NPC trees. All HARD cases and the later MEDIUM packs remain premium. Authored 2026-06-17 per report 59 CR2 and . Regression shield: scenario_tests 2176, 2177, 2178 prove each EASY case is solvable to a correct accusation, and scenario_test 2363 guards the free-funnel property that a non-subscriber with zero badges selects an EASY case for free with no badge paywall (the inverse of the premium-paywall guard, scenario_test 146); the EASY catalog and free gate are thus protected against silent regression to zero (report 59 CR2 finding 292). Ordering: getAvailableScenariosForPlayer leads the browse list with the EASY Academy cases, then the other free intro cases (ids 1 through 5), then the premium packs, so a first-timer sees the shortest, easiest, free cases on the first page. First-timers are additionally auto-routed to the lowest-id EASY case as their guided first case (see First-Timer Pre-Loaded Guided Onboarding Case). Both landed in the Mystery onboarding overhaul (2026-07-13, human_idea 24153).
Content authoring gotcha (boolean flags must be integers): the flags isMurderer, isVictim, and isMurderWeapon MUST be stored as integers 1 and 0 in rule_data, because the MyBatis mappers select with JSON_EXTRACT(rule_data, '$.isMurderer') = 1. A JSON boolean true does NOT match = 1 in MySQL, so a case authored with booleans loads and even passes game_content validate_scenario (which checks === true), yet the murderer, victim, and murder-weapon lookups silently return nothing and every accusation scores incorrect. By contrast isCritical (clue mapper) and the scenario isActive use = true, which coerces and accepts either form. The game_content create path does not coerce. Author these three flags as integers to match the existing 39 scenarios. The brittle = 1 mappers and the === true validator disagreeing with each other are a latent footgun worth hardening separately (align both on the coercing = true/= false form).
Zone & Navigation
Zone Structure
Zones are grid-based areas (rooms, gardens, corridors) with coordinate navigation.
MysteryZone:
zoneRef: String— Unique ID (e.g., "MANSION_STUDY", "AM_SERVANTS_QUARTERS")zoneSize: Integer— Grid dimension (e.g., 5 for 5x5)zoneSquares: Map<"x,y", MysteryZoneSquare>— Coordinate-indexed squaresparentZoneRef+parentZoneX/Y— Return coordinates for "go back"
MysteryZoneSquare:
squareDescription— First-visit textsquarePermanentDescription— Revisit text (shorter)conversationNpcId— NPC at this location (if any)clueIds: List<Long>— Discoverable clueszoneRefToClone+zoneXToEnter/zoneYToEnter— Zone transition exitisBlocked: Boolean— Impassable (walls, locked doors)requiredClueId: Long— Clue needed to unlock (player-specific)
Navigation Types
| Type | Command | Behavior |
|---|---|---|
| Directional | "go north/south/east/west" | Move 1 square in direction |
| Named entry | "enter [zone name]" | Teleport to named zone |
| Named navigation | "go to [location]" | Fuzzy-match zone name |
| Go back | "go back" | Return to parent zone at specified coordinates |
Blocked Paths
- Blocked squares display "This path is blocked" — player position unchanged
- Unlock by discovering the
requiredClueIdclue - Unlock state is per-player — Player A unlocking doesn't affect Player B
- Once unlocked, paths remain accessible across zone visits
Zone Caching
- Service:
- TTL: 3 hours in Redis
- Zones served from cache; fallback to DB on miss
Investigation & Clue Discovery
Investigation Commands
| Command | Purpose |
|---|---|
| "examine" | Inspect current location for details |
| "search" | Actively search for clues |
| "look" | Get location description |
| "look around" | Broader area overview |
Clue Model
MysteryClue:
clueName,clueDescriptionclueCategory: MysteryClueCategory— One of 6 categoriespointsIfFound: Integer— Score valueimplicatesCharacterId: Long— Links clue to suspectrelatesToWeaponId: Long— Links clue to weaponisCritical: Boolean— Required for perfect solve bonusisConfrontable: Boolean— Can be used in NPC confrontations (default: true)confrontationBonus: Integer— Points for correct confrontation (default: 50)confrontationPenalty: Integer— Points for wrong confrontation (default: -25)relatedNpcId: String— The "correct" NPC for confrontation validation
Clue Categories
| Category | Description |
|---|---|
| PHYSICAL | Physical evidence (weapons, objects, fingerprints) |
| TESTIMONY | NPC statements and interviews |
| MOTIVE | Evidence of potential motives |
| TIMING | Timeline and alibi information |
| LOCATION | Location-related evidence |
| RELATIONSHIP | Character relationship information |
Badge System (Clue Discovery Currency)
Clue discovery requires spending investigation badges:
- Badges are earned via daily login streaks (3-day, 7-day, 13-day milestones)
- Spending a badge discovers a random undiscovered clue from the current scenario
- Subscriber bonus: 50% chance to preserve badge after use (
ThreadLocalRandom.nextBoolean()) - Non-subscribers: badge always consumed
- Error states: no badges → "You need 1 investigation badge", all clues found → "You have already discovered all clues"
- Clues found via NPC conversations (choosing options with
revealsClueId) are separate from badge-discovered clues
Quick Action
One-tap badge spend shortcut (designed for voice/Alexa devices). Same mechanics as badge spend but streamlined UI flow.
"quick clue", "use badge for clue" and "random clue" reach it (MYSTERY_USE_BADGE_FOR_CLUE), the
same words the balance menu sells it with. A player who can pay is asked before the badge is spent
("A quick clue costs one investigation badge ... Say yes to spend the badge, or say no to keep it");
the yes option carries CONFIRM_QUICK_CLUE. A player without a badge hears the shortfall at once.
NPC Conversations
Conversation Tree Structure
Each NPC has a recursive conversation tree:
MysteryNPCConversationTree
├─ npcId: "BUTLER_JENKINS"
├─ npcName: "Jenkins"
├─ voiceTag: "matthew" (SSML voice for Alexa)
└─ rootNode: MysteryConversationNode
├─ description: "Scene description (no voice tags)"
├─ npcText: "voicetagmatthew I was in the kitchen... voicetagend"
└─ options: List<MysteryConversationOption>
├─ playerText: "Ask about the night" (1-4 words for voice)
├─ alternatePhrases: ["the night", "what happened"]
├─ nextNode: MysteryConversationNode (recursive!)
├─ revealsClueId: 42 (optional: discovering a clue)
├─ requiredClueIds: [1, 5] (ALL required — AND logic)
└─ reward: '{"gold": 50}' (optional JSON reward)
Constants
| Constant | Value | Purpose |
|---|---|---|
OPTIONS_PER_PAGE |
3 | Max options shown per page |
MAX_CONVERSATION_DEPTH |
50 | Prevents infinite recursion |
Pagination
When an NPC has >3 options at any node (canonical pattern: ):
- Options carry their ABSOLUTE 1-based index (page two reads "4: ..., 5: ...") — the index base shared with Darkness Falls through
BASELINE_CONVERSATION_OPTION - "more" / "previous" are real options (keys
CONV_PAGE_<n>, canonical MORE/PREVIOUS synonyms) - Boundaries answer "You are already on the first/last page" and re-render — never a silent wrap
- Full detail:
npcs.md§ Pagination
Clue-Gated Options
Conversation options can require specific clues (requiredClueIds). These options are only visible to players who have discovered ALL required clues. This creates progressive disclosure — return to an NPC after finding evidence and new dialog opens up.
Voice Tags
NPCs have voice tags for Alexa SSML rendering (e.g., voicetagmatthew, voicetagkendra, voicetagjoey, voicetagbrian). Stripped in text display. NPC testimony conflicts between characters are intentional — the lying suspects mechanic.
Confrontation System
Players can confront NPCs with discovered clues to earn bonus points during investigation (before accusation).
Command: "confront [NPC] with [clue name]"
Mechanics:
- Fuzzy clue name matching against discovered clues
- Each clue has a
relatedNpcId— the "correct" NPC to confront - Correct confrontation:
confrontationBonuspoints (default +50) - Wrong confrontation:
confrontationPenaltypoints (default -25) - Each clue can only be confronted once (any NPC) — prevents point farming
- Two-axis system: Scoring based on
relatedNpcIdmatch (deductive reward), NPC reaction based onisMurderer(behavioral clue). These are independent — see scoring.md - Confrontations tracked in
clueConfrontations: Map<clueId, npcId> - The murderer's reaction keys on the scenario's murderer character: its
npcIdwhen the character row carries one, otherwise itscharacterNameagainst the confronted NPC's name, because most character rows carry nonpcId().
Accusation System
State Machine
Accusation is a guided flow tracked by AccusationStep enum. It opens on the core of the mystery,
who and with what, and asks the location last only when the scenario names where the murder took
place (settingDetails.murder_location):
NONE → CHOOSING_SUSPECT → CHOOSING_WEAPON → CHOOSING_MOTIVE [→ CHOOSING_LOCATION] → Score
Suspect: Choose from scenario's character list (dynamic per scenario)
Weapon: Choose from scenario's weapon list (dynamic per scenario)
Motive: Choose from scenario's motive list (dynamic per scenario)
Location: Only for a scenario that names its murder location; scored 100 points when right
Whatever the opening words already name is filled without asking: "accuse Tom with the poisoned coffee" asks only the motive, and a suspect named once is never asked for again. The detail is scoring.md § One-Breath Accusation.
Prerequisites
- Minimum 3 clues required — Attempting accusation with <3 clues is blocked
- Confirmation step before finalizing (player can cancel)
- Only one accusation resolves the case, but wrong accusations incur penalty and allow retry
Voice Selection at Each Step
Each step accepts the option number ("option 1", "first", "1") OR the spoken name. Name matching is by per-option synonyms registered in : the full display name, the title, AND each distinctive single word of the name (length 4 or more, excluding bare honorifics/articles). So a player may say the surname or first name alone ("Thornton", "Edmund") or the weapon's key word ("candlestick") — not only the full formal "Doctor Edmund Thornton". A matched name resolves to that option's number, so it flows through the same path as a numeric pick. Each option also answers to the name as it is spoken rather than read (spokenNameVariants): led by "the" ("the baker" for Tom Reese, Baker) and with a past-tense word in its base form ("poison coffee" for Poisoned Coffee). One-utterance accusations ("accuse the baker with poison coffee") are not part of this design: the flow stays four steps, and whether a single sentence may fill several steps is an open design question. Input that resolves to no option is met with a steer to say the number or the name (never a bare "valid option number" dead-end). Regression: scenario_test 2394 (partial surname completes a correct accusation).
Weapons Model
MysteryWeapon:
weaponName,weaponDescriptionisMurderWeapon: Boolean— True if this is the actual murder weapondiscoveryClueText— How it's founddiscoveryLocation— Where it can be found
Characters Model
MysteryCharacter:
characterName,characterTitle(e.g., "The Butler")description,personalityTraits: List<String>(free-form trait labels — see PersonalityTraitsDeserializer for legacy Map tolerance)relationshipToVictim,alibiisVictim: Boolean,isMurderer: Boolean,isQuestGiver: Boolean
Authoring trap — store the solution flags as ints, not JSON booleans. In rule_data, isMurderer, isVictim, and isMurderWeapon MUST be written as integers 1/0, NOT JSON true/false. The MyBatis solution mappers (mystery-character-mapper.xml, mystery-weapon-mapper.xml) select with JSON_EXTRACT(rule_data, '$.isMurderer') = 1, and in MySQL a JSON boolean true does not equal 1 through JSON_EXTRACT. Store them as booleans and getMurdererForScenario / getMurderWeaponForScenario / getVictimForScenario / getSuspectsForScenario silently return nothing: the case loads and plays, but createAccusation scores characterCorrect/weaponCorrect = false, so even the correct accusation reports "Incorrect." The game_content validate_scenario check is inverted here — it tests === true (strict JS bool), so booleans pass validation while breaking the game and the working int form fails validation. Trust a full solve via play_game over validate_scenario for these three flags. (isCritical on clues and scenario isActive use = true, which coerces and accepts either form.)
npcId → MysteryNPCConversationTree(if interviewable)displayOrder: Integer— UI ordering
Scoring System
Complete Formula
Score is calculated by when accusation resolves:
| Component | Points | Formula |
|---|---|---|
| Character correct | 500 | isCorrect ? 500 : 0 |
| Weapon correct | 200 | isCorrect ? 200 : 0 |
| Motive correct | 100 | isCorrect ? 100 : 0 |
| Clue discovery | Variable | Per-clue pointsIfFound values |
| NPC interviews | 50/each | npcsInterviewedCount × 50 |
| Zone exploration | 10/each | zonesVisitedCount × 10 |
| Movement efficiency | 0-500 | max(0, 500 - totalMovements × 5) |
| Interview efficiency | 0-300 | max(0, 300 - conversationsCompleted × 20) |
| Critical clue bonus | 200 | All critical clues found |
| All clues bonus | 300 | All scenario clues found |
| All NPCs bonus | 150 | All NPCs interviewed |
| Wrong accusation penalty | -100/attempt | attempts × -100, capped at -500 |
| Confrontation (correct) | +50/clue | Per-clue confrontationBonus (default 50) |
| Confrontation (wrong) | -25/clue | Per-clue confrontationPenalty (default -25) |
Total:
totalScore = characterPoints + weaponPoints + motivePoints
+ cluePoints + npcPoints + explorationPoints
+ movementEfficiencyBonus + interviewEfficiencyBonus
+ criticalClueBonus + allCluesBonus + allNpcsBonus
+ wrongAccusationPenalty
+ confrontationPoints
Design Intent
The efficiency bonuses create tension: exploring more zones and talking to more NPCs earns points (10/zone, 50/NPC), but excessive movement and conversations lose efficiency bonus points (5/move, 20/conversation deducted from 500 and 300 respectively). The optimal strategy is focused investigation.
Perfect Solve
A "perfect solve" requires: correct character + correct weapon + correct motive + all critical clues found + no wrong accusations. Earns the star marker on leaderboard.
Asking For Feedback
Once per player, the reply to a solved case ends on one line asking what they thought ("One
question, Detective: what did you think of the case? Say feedback to tell me, or carry on.") with a
Send Feedback option after the way on, so the next case stays the first thing offered and anything
else the player says skips it. It is never asked during a case or after a wrong accusation
(, use count MYSTERY_FEEDBACK_ASKED). The feedback
actions reach the shared feedback flow from a closed case ().
The scenario harness clears every per-player MYSTERY_ use count at the start of a run
(), so a once-per-player line is first on every run.
That a second solve does not ask is a JUnit pin (): a solved case leaves
the player's case list, so the scenario harness cannot reach a second solve.
Best Score Tracking
MysteryScenarioBestScore stores the breakdown of the highest-scoring attempt per scenario:
- Full score breakdown (character/weapon/motive/clue/confrontation points)
- Correctness flags (characterCorrect, weaponCorrect, motiveCorrect, perfectSolve)
- Time to solve, confrontation stats, accusation details
Progress Tracking
Player Progress Model
MysteryPlayerProgress tracks all state for an active game:
Navigation state: currentZoneRef, currentZoneX/Y, zonesVisited, zoneSquaresVisited (by "ZONE_REF:x,y"), unlockedSquares (per-player)
Investigation state: cluesFound, weaponsDiscovered, clueConfrontations, npcsInterviewed
Conversation state: currentConversationNpcId, conversationDepth, conversationPath (option indices), conversationOptionPage
Accusation state: accusationState (AccusationStep enum), accusationLocation, accusationSuspectId, accusationWeaponId, accusationMotive, accusationAttempts
Statistics: conversationsCompleted, totalMovements, totalScore
Lifecycle: gameState (IN_PROGRESS/READY_TO_ACCUSE/COMPLETED/ABANDONED), startedAt, lastActionAt, completedAt
Status & Next Steps
Two complementary channels tell a detective the concrete next verb toward the accusation:
Status readout (pull) — the "status" command's context-aware suggested next steps (, surfaced by ). Shows clues found of the 3 minimum, NPCs interviewed, and badge balance, then a progress-keyed suggestion:
- No clues -> "Explore the scene to find clues. Say search to look around."
- Clues but no NPCs -> "Find witnesses to interview." plus a view-clues reminder
- 3+ clues and 2+ NPCs -> "You may have enough evidence. Say accuse when ready."
- Fewer than 3 clues -> "Find N more clue(s). Try search or explore new areas."
Milestone next-step nudges (push) — the moment a clue discovery crosses an onboarding
milestone, one terse next-step line is spoken automatically so a new player is never left guessing.
Centralized in MysteryMilestoneGuidance.addClueMilestoneNextStep(before, after) and fired from the
exploration discovery paths that were previously silent about the next step:
- (auto-discovery on movement / zone entry)
- (examining a named clue)
The line is keyed on the clue-count crossing this turn (before -> after):
- first clue found (after == 1) -> "First clue in hand. Keep investigating..."
- crossing to the 3-clue accusation minimum (before < 3, after >= 3) -> "That is enough evidence. Say make an accusation..."
- otherwise (2 clues in hand) -> "Clue found. One more and you can make an accusation."
The accusation-ready line fires only on the crossing to three, so later discoveries do not nag. The conversation path already carries the equivalent push in (spoken when a conversation ends), and the first-talk front-load hands clue #1 on the first interview. So every milestone is reinforced across three surfaces: the status pull, the conversation-end push, and the exploration-discovery push.
Quit/Resume
- Inside an NPC conversation, "goodbye", "good bye", "bye" and "leave" end the conversation and keep the game. Every conversation page carries a leave option with those words (
ConversationLeaveWords.LEAVE_SYNONYMS): the tree's own goodbye choice when the page shows one, otherwise the engine'sBASELINE_EXIT_CONVERSATIONoption. The Tier 1 EXIT one-shots yield only to a displayed option carrying the same words. A goodbye choice whose reply offers nothing further is the NPC's farewell: its line is spoken and the conversation ends. - Outside a conversation, "quit", "stop", "goodbye", and "exit" all route to the shared baseline exit (). There is NO confirmation step and NO
ABANDONEDtransition — the case is saved on every interaction, so the player picks up exactly where they left off on the next launch. The farewell reads "Progress saved." plus a randomized goodbye (So long / Goodbye / Bye bye). - Resume: the next launch loads all progress from the same
mystery_player_progressrow ("continue your current case"). - All state preserved: clues, zone position, score, conversation progress.
- The
ABANDONEDvalue exists in theMysteryGameStateenum but is not currently written by any handler; exit relies on save-on-every-interaction rather than an explicit abandon transition. OnlyCOMPLETED(set after a finished accusation) is written today.
Leaderboard
Architecture
- Service:
- Storage: Redisson
RScoredSortedSetper scenario - Key pattern:
MYSTERY_LEADERBOARD:{scenarioId} - TTL: None (permanent)
- Performance: O(log N) using Redis sorted sets
Display
- Top 10 with rank indicators (medal emoji for 1st-3rd, #4-10 for others)
- Perfect solve marker (star)
- Current player marker "(You)" if in top 10
- Player rank shown separately if not in top 10
- Total detective count
- Each scenario has independent leaderboard
- Display name resolves from the chat system via ; a player with no chat name renders as Anonymous Detective, never their raw Amazon account id (
amzn1.ask.account.*). Resolution + fallback live in .
Warmup
On application startup:
warmUpFromDatabase()loads top 1000 scores per scenario from MySQL- Batch-loads into Redis sorted sets
- Fallback to MySQL if Redis empty (guarantees leaderboard always works)
Caching Strategy
| Entity | Cache | TTL | Notes |
|---|---|---|---|
| Zones | Redis | 3 hours | |
| Leaderboard | Redis sorted sets | Permanent | Per-scenario, warmup from DB on startup |
| Account/Progress | DB (flat JSON) | N/A | Loaded/saved per turn via |
| Conversation trees | Redis | 3 hours | Cached per NPC |
| Clues | Redis | 3 hours | Cached per scenario |
Implemented Panels (Built — Wired 2026-05-05)
All 4 panels formerly listed as 'Designed' are now built and live. They were blocked on the payload wiring (epic #8502, queue 18696) — that wiring landed alongside this update so the panels finally have data to render. All panels follow the noir aesthetic (EB Garamond display + Outfit UI fonts, amethyst/gold/teal palette, deep ink backgrounds).
1. Evidence Panel ()
Purpose: Sidebar panel showing discovered clues organized by 6 categories with confrontation status.
Layout: Collapsible category accordion in the sidebar, below story text.
Data flow: MysteryZoneResponse.mysteryClues[] provides full clue objects (populated by via ); progress.cluesFound (IDs) determines discovery; progress.clueConfrontations (map) determines confrontation status.
Features: Overall progress bar, 6 category toggles with icons/counts, clue cards with gold/teal/yellow left borders, confrontation stamps (used/available/n-a), critical clue star markers, per-clue point values.
2. Case File Dashboard ()
Purpose: Investigation overview with progress visualization and efficiency tracking.
Layout: Sidebar panel, accessible via tab toggle alongside Evidence Panel.
Data flow: Reads MysteryPlayerProgress for player state plus the four scenario rosters (mysteryClues, mysteryNPCs, mysteryWeapons, mysteryCharacters) for max-eligibility counts. Efficiency formulas previously mirrored client-side; that fallback path is retained but the primary source is now mysteryAccusation.scoreBreakdown line items computed by .
Features: 4 SVG progress rings (Clues/NPCs/Zones/Weapons), secondary stat rows, movement efficiency meter (500 pool), interview efficiency meter (300 pool), wrong accusation penalty tracker, "Make Accusation" CTA button.
3. Accusation Wizard ()
Purpose: Visual guided flow replacing text-only accusation commands, in the backend's order: suspect, weapon, motive, and a fourth step, the location, drawn only when the flow reaches it (a scenario that names where the murder took place).
Layout: Replaces main game board area when progress.accusationState is active.
Data flow: progress.accusationState determines step. Options from storyOptionsList. Suspect/weapon breadcrumb chips resolve IDs to names via mysteryCharacters / mysteryWeapons rosters; an "options failed to load" retry affordance fires when the backend returns no MYSTERY_ACCUSATION_OPTION items mid-flow.
Features: Connected stepper of three steps, or four once the location step is reached (pulsing current/filled complete), previous selection chips, step instruction in display font, responsive option card grid with hover lift+glow, cancel button.
4. Score Breakdown ()
Purpose: Post-accusation visual display of all scoring components.
Layout: Overlay replacing entire game interface after accusation resolves.
Data flow: Reads mysteryAccusation from MysteryZoneResponse, populated by from the persisted MysteryScenarioBestScore for the active scenario when gameState == COMPLETED. mysteryAccusation.scoreBreakdown carries pre-grouped line items (accusation / investigation / bonuses / adjustments) so the panel iterates rows instead of recomputing formulas. Client-side fallback formulas remain for legacy responses missing the breakdown.
Features: Verdict header (solved/incorrect), perfect solve golden badge, grand total in 3rem display font, grouped score sections (Accusation/Investigation/Bonuses) with correctness indicators, confrontation detail, leaderboard dismiss.
Integration Plan
MysteryZoneMap.tsx
\u251c\u2500\u2500 [TAB: Story | Evidence | Case File] \u2190 sidebar tabs (new)
\u2502 \u251c\u2500\u2500 Story panel (existing)
\u2502 \u251c\u2500\u2500 EvidencePanel (new) \u2014 when tab selected & clues > 0
\u2502 \u2514\u2500\u2500 CaseFileDashboard (new) \u2014 when tab selected
\u251c\u2500\u2500 AccusationWizard (new) \u2014 replaces board when accusing
\u251c\u2500\u2500 ScoreBreakdown (new) \u2014 replaces everything on completion
\u251c\u2500\u2500 GameBoard (existing) \u2014 normal gameplay
\u251c\u2500\u2500 MovementControls (existing)
\u251c\u2500\u2500 NpcInteractionPanel (existing)
\u2514\u2500\u2500 SquareInfoPanel (existing)
CSS: New styles in mystery-panels.css imported by . Uses existing --m-* CSS variables — no new design tokens needed.
Launch Readiness
Content Statistics
| Content Type | Count |
|---|---|
| Scenarios | 45 (6 EASY, 12 MEDIUM, 27 HARD); the scenario rows are the definition, so a count here that disagrees with them is the stale one |
| Clues | 319 |
| Characters | 250 |
| NPC Conversations | 218 |
| Weapons | 98 |
| Zones | 312 |
| Player accounts | 782 (mostly test) |
| Leaderboard entries | 75 |
Subsystem Design Docs
| Subsystem | Design Doc | Covers |
|---|---|---|
| Scoring | scoring.md | accusation state machine, one-breath accusation, scoring components, penalty caps |
| Investigation | investigation.md | Zone model, evidence collection, search/examine, badge system, party cooperative play, MysteryInvestigationNode, Admin REST API |
| NPCs | npcs.md | 5-pass NPC resolution, proximity check, clue-gated options, confrontation, navigation break-out |
| Daily Case | daily-case.md | Deterministic daily rotating Case of the Day — the gd-03 retention loop; how a consumed case re-opens; free-access reward + premium teaser |
| Player request backlog | player-request-backlog.md | Owner KEEP/DEFER/BUG + Design Fit Gate (standing idea 25330) — 2026-08-01 |
Sub-Docs In This Directory
Design docs that live alongside this index and were not previously linked from it. Wired in for index-to-sub-doc discoverability by design-audit follow-up (2026-06-15). Read the one matching your task.
| Doc | Covers |
|---|---|
| monetization.md | ISP Conversion Review: Murder Mystery |