Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/apursley2012/the-beach-mystery/llms.txt

Use this file to discover all available pages before exploring further.

The Beach Mystery treats its story as a directed graph of node objects defined entirely in data/story.js. Rather than encoding narrative branches as nested if/else chains inside components, every scene, dialogue beat, and decision point is expressed as a plain JavaScript object with a predictable shape. The rendering engine reads the current node, switches on its type, and delegates to the appropriate UI — it never needs to know the story’s content. This clean separation means adding a new branch is as simple as adding a new object to STORY_NODES and pointing an existing next or options[n].next field at its id.

Why a Node Graph?

Content is decoupled from logic

All story text, choices, and state mutations live in data/story.js. The StoryPlayer component reads the current node ID from gameState.currentNode and renders whatever it finds — it never hardcodes a scene name or dialogue line.

Branches are just pointers

Every narration node has a next field that names the following node ID. Every choice node has an options array where each option names its own next ID. Adding a branch is adding an object and updating a pointer.

State mutations are declarative

A node can set inventory flags by including a setState object (e.g. { hasJewel: true }). The engine merges these flags when the node is entered. No imperative mutation code is needed in components.

Endings are declared, not detected

A narration node that concludes a storyline carries an ending field with the ending ID. The engine calls reachEnding(endingId) automatically when it renders that node — no special casing required.

Node Types

There are three node types. The type field controls which UI the player sees and which hook function advances the story.
A narration node presents one or more lines of story text. Lines are revealed one at a time; the player clicks Continue → after each line. When the last line is shown, clicking Continue calls advanceNode(), which transitions currentNode to the value of next.Narration nodes may also carry:
  • setState — an object of inventory flags to merge into gameState.state on entry.
  • ending — an ending ID string. When rendered, the engine calls reachEnding(endingId) and records the achievement.
A choice node presents a prompt and two or more labelled buttons. Each button maps to an options entry with a label, a value, and a next node ID. Clicking a button calls makeChoice(option), which:
  1. Appends { nodeId, choice: option.label } to gameState.history.
  2. Resolves the target node from STORY_NODES[option.next].
  3. Merges that target node’s setState flags (if any) into gameState.state.
  4. Sets currentNode to option.next.
Choice nodes do not have a lines or next field of their own.
A riddle node shows riddleText and a free-text input. The player types an answer and submits. If the answer (case-insensitive) matches the answer field, solveRiddle() is called, which records { nodeId, choice: 'echo' } in history and advances to node.next.If the player submits two wrong answers, the hint string appears beneath the input. The single riddle in the game is:
I speak without a mouth and hear without ears. I have no body, but I come alive with the wind. What am I? Answer: echo

Node Interface

interface StoryNode {
  id: string;
  type: 'narration' | 'choice' | 'riddle';
  scene: string;          // key into sceneImages map (e.g. "cave-fork")
  lines?: string[];       // text lines rendered one at a time (narration/riddle context)
  next?: string;          // auto-advance target node ID (narration nodes)
  prompt?: string;        // question text shown above choice buttons (choice nodes)
  options?: {
    label: string;        // button text shown to the player
    value: string;        // internal value recorded in history
    next: string;         // target node ID when this option is chosen
  }[];                    // choice buttons (choice nodes)
  setState?: Partial<GameFlags>;  // inventory flags to merge on entry
  ending?: string;        // ending ID to trigger when this node is rendered
  // riddle-only fields:
  riddleText?: string;    // the riddle question text
  answer?: string;        // correct answer (matched case-insensitively)
  hint?: string;          // shown after 2 incorrect attempts
}

interface GameFlags {
  hasOpenedChest: boolean;
  hasJewel: boolean;
  hasMap: boolean;
}

Concrete Examples

chestChoice — a choice node

chestChoice: {
  id: "chestChoice",
  type: "choice",
  scene: "chest",
  prompt: "Do you have time to open the chest?",
  options: [
    { label: "Yes", value: "yes", next: "chestOpened" },
    { label: "No",  value: "no",  next: "chestSkipped" },
  ],
},
Choosing Yes navigates to chestOpened. Choosing No navigates to chestSkipped. Neither option has a setState on the choice node itself — state is applied by the destination node on arrival.

chestOpened — a narration node with setState

chestOpened: {
  id: "chestOpened",
  type: "narration",
  scene: "chest-open",
  lines: [
    "You grip the lid and pull. It refuses to move at first, but with one last burst of strength the rusted latch breaks free.",
    "Inside, wrapped in faded blue cloth, you find an old treasure map and one brilliant {favoriteColor} {favoriteJewel}.",
    "The jewel catches the sunlight so brightly that the inside of the chest seems to glow.",
    "You carefully pocket the {favoriteJewel}, unfold the map, and smooth its brittle corners against the lid.",
    "A dotted route begins at this very beach and crosses the water to a small island marked with an X beneath an ancient tree.",
  ],
  setState: { hasOpenedChest: true, hasJewel: true, hasMap: true },
  next: "mapChoice",
},
When chestOpened is entered (via advanceNode() after the chestChoice “Yes” path), the engine merges { hasOpenedChest: true, hasJewel: true, hasMap: true } into gameState.state. This inventory state is then used further down the graph to determine which version of cave nodes to serve.

Text Interpolation

Story lines use {placeholder} tokens that are replaced at render time with values from gameState.variables. Variables are set when the player completes the five-question personalisation form and passed into startGame(variables). A formatted_time variable is also injected automatically — it captures the current HH:MM time at game start. The full set of interpolation tokens:
TokenSourceExample
{firstName}Personalisation Q1"Hello, Alex!"
{cartoonChar}Personalisation Q4"You glance at your Goofy watch."
{formatted_time}Auto-injected at game start"It is 14:32."
{favoriteColor}Personalisation Q2"a brilliant blue jewel"
{favoriteJewel}Personalisation Q5"you pocket the sapphire"
{favoriteAnimal}Personalisation Q3"a beautiful fox sleeps"
Tokens are replaced using a simple string replace chain at render time, not during data loading. The raw {placeholder} strings are stored in story.js exactly as shown above.

Complete Node Graph

The full decision tree, showing every node and the transitions between them:
beachOpening (narration)
└── chestDiscovery (narration)
    └── chestChoice (choice)
        ├── [Yes] → chestOpened (narration) [setState: hasOpenedChest, hasJewel, hasMap]
        │           └── mapChoice (choice)
        │               ├── [Yes] → followMap (narration)
        │               │           └── riddleIntro (riddle)
        │               │               └── riddleSolved (narration) ──── ENDING: islandTreasure 🏝️
        │               └── [No]  → skipMap (narration)
        │                           └── caveChoiceWithJewel (choice)
        │                               ├── [Yes] → caveEnteredWithJewel (narration)
        │                               │           └── cavePathChoiceWithJewel (choice)
        │                               │               ├── [Left]  → caveLeftWithJewel (narration) ── ENDING: hiddenChamber 💎
        │                               │               └── [Right] → caveRightWithJewel (narration) ─ ENDING: animalDen 🐾
        │                               └── [No]  → caveSkippedWithJewel (narration) ───────────────── ENDING: peacefulWalk 🌅
        └── [No]  → chestSkipped (narration) [setState: hasOpenedChest=false, hasJewel=false, hasMap=false]
                    └── caveChoiceWithoutMap (choice)
                        ├── [Yes] → caveEnteredWithoutJewel (narration)
                        │           └── cavePathChoiceWithoutJewel (choice)
                        │               ├── [Left]  → caveLeftWithoutJewel (narration) ── ENDING: hiddenChamber 💎
                        │               └── [Right] → caveRightWithoutJewel (narration) ─ ENDING: animalDen 🐾
                        └── [No]  → caveSkippedWithoutJewel (narration) ─────────────────── ENDING: peacefulWalk 🌅

Endings reachable from each root decision

First ChoiceSecond ChoiceThird ChoiceEnding
Open Chest → YesFollow Map → YesSolve Riddle🏝️ The Island’s Secret
Open Chest → YesFollow Map → NoEnter Cave → Yes, Left💎 The Hidden Treasure Chamber
Open Chest → YesFollow Map → NoEnter Cave → Yes, Right🐾 The Guardian’s Den
Open Chest → YesFollow Map → NoEnter Cave → No🌅 The Peaceful Shore
Open Chest → NoEnter Cave → Yes, Left💎 The Hidden Treasure Chamber
Open Chest → NoEnter Cave → Yes, Right🐾 The Guardian’s Den
Open Chest → NoEnter Cave → No🌅 The Peaceful Shore
The hiddenChamber and animalDen endings are each reachable from two separate paths — one where the player has the jewel and one where they don’t. The narration text differs, but the ending ID and achievement recorded are identical.
To add a new story branch, add a new object to STORY_NODES in data/story.js and point an existing next or options[n].next field at its id. No changes to any component or hook are required. See Adding Story Nodes for a step-by-step walkthrough.

Build docs developers (and LLMs) love