Skip to main content

Quick Start

Say to your Alexa device:

"Alexa, open Last Light Mystery"

  1. 1Accept a new case and visit the crime scene
  2. 2Search locations for clues and gather evidence
  3. 3Interview suspects and catch them in contradictions
  4. 4Make your accusation before the trail goes cold

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

  1. Information Asymmetry — NPCs lie, clues mislead, testimony conflicts are intentional (not bugs)
  2. Efficiency Rewards — Score penalizes excessive movement and conversations, rewarding focused investigation
  3. Progressive Disclosure — Clue-gated paths, conversation options, and confrontation unlock as evidence accumulates
  4. 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 identifier
  • currentProgress: 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)
  • estimatedPlayTimeMinutes
  • startingZoneRef — Initial zone (derived from scenarioId if null)
  • minCharacters, maxCharacters — NPC count bounds
  • parentScenarioId, 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 squares
  • parentZoneRef + parentZoneX/Y — Return coordinates for "go back"

MysteryZoneSquare:

  • squareDescription — First-visit text
  • squarePermanentDescription — Revisit text (shorter)
  • conversationNpcId — NPC at this location (if any)
  • clueIds: List<Long> — Discoverable clues
  • zoneRefToClone + zoneXToEnter/zoneYToEnter — Zone transition exit
  • isBlocked: Boolean — Impassable (walls, locked doors)
  • requiredClueId: Long — Clue needed to unlock (player-specific)
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 requiredClueId clue
  • 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, clueDescription
  • clueCategory: MysteryClueCategory — One of 6 categories
  • pointsIfFound: Integer — Score value
  • implicatesCharacterId: Long — Links clue to suspect
  • relatesToWeaponId: Long — Links clue to weapon
  • isCritical: Boolean — Required for perfect solve bonus
  • isConfrontable: 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: confrontationBonus points (default +50)
  • Wrong confrontation: confrontationPenalty points (default -25)
  • Each clue can only be confronted once (any NPC) — prevents point farming
  • Two-axis system: Scoring based on relatedNpcId match (deductive reward), NPC reaction based on isMurderer (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 npcId when the character row carries one, otherwise its characterName against the confronted NPC's name, because most character rows carry no npcId ().

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, weaponDescription
  • isMurderWeapon: Boolean — True if this is the actual murder weapon
  • discoveryClueText — How it's found
  • discoveryLocation — 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, alibi
  • isVictim: 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's BASELINE_EXIT_CONVERSATION option. 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 ABANDONED transition — 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_progress row ("continue your current case").
  • All state preserved: clues, zone position, score, conversation progress.
  • The ABANDONED value exists in the MysteryGameState enum but is not currently written by any handler; exit relies on save-on-every-interaction rather than an explicit abandon transition. Only COMPLETED (set after a finished accusation) is written today.

Leaderboard

Architecture

  • Service:
  • Storage: Redisson RScoredSortedSet per 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:

  1. warmUpFromDatabase() loads top 1000 scores per scenario from MySQL
  2. Batch-loads into Redis sorted sets
  3. 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

Books by Lincoln Cole

Read the worlds behind the games

All books at llitd.com ↗