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.

All game logic in The Beach Mystery lives in a single custom React hook: useGameState, defined in hooks/useGameState.js. Every piece of mutable data the application needs — which story node the player is on, which items they have collected, which endings they have unlocked, their accessibility settings, and any saved progress — is owned and managed here. Components never maintain their own game state; they destructure what they need from the hook and call the provided functions to drive transitions. This centralised design means the entire session can be serialised to a single JSON string, saved to localStorage, and restored exactly as it was left.

What the Hook Tracks

The hook manages three independent state slices, each persisted separately:

gameState

The active session: personalization variables, inventory flags, the current node ID, the full choice history, and whether an ending has been reached.

achievements

A string array of ending IDs the player has discovered across all sessions. Persisted independently so it survives resetGame().

settings

Accessibility and preference settings: sound, textSize, and highContrast. Persisted independently and applied globally via CSS data attributes.

Initial State

When the hook is first mounted (before startGame is called), gameState is set to this default object:
const initialState = {
  variables: {},          // populated by startGame(variables)
  state: {
    hasOpenedChest: false,
    hasJewel: false,
    hasMap: false,
  },
  currentNode: null,      // string node ID, set to "beachOpening" by startGame
  history: [],            // [{nodeId: string, choice: string}]
  endingReached: null,    // string ending ID, or null
  started: false,         // true after startGame() is called
};
The state sub-object tracks the player’s inventory flags. These are mutated declaratively: narration and choice destination nodes carry an optional setState object that is merged in when the node is entered.

Settings Object

{
  sound: true,           // boolean — whether sound effects are enabled
  textSize: "medium",    // "small" | "medium" | "large" — story text scale
  highContrast: false,   // boolean — enables high-contrast theme
}
Settings are loaded from localStorage key beach-mystery-settings on hook initialisation and automatically persisted whenever updateSettings is called. They are never reset by resetGame().

localStorage Keys

KeyContentsReset by resetGame?
beach-mystery-saveSerialised gameState objectNo — call clearSave() to delete the save file
beach-mystery-achievementsJSON array of ending ID stringsNo — persists across all sessions
beach-mystery-settingsJSON settings objectNo — persists across all sessions

Hook API

Import the hook into any component that needs to interact with the game:
import { useGameState } from '../hooks/useGameState';

function StoryPlayer() {
  const {
    gameState,
    makeChoice,
    advanceNode,
    solveRiddle,
    saveGame,
    resetGame,
  } = useGameState();

  const node = STORY_NODES[gameState.currentNode];
  // render node based on node.type
}

Functions

Called when the player submits the five-question personalisation form. Receives the form answers as an object and initialises a fresh session.
startGame(variables: Record<string, string>): void
What it does:
  • Captures the current time as HH:MM and injects it into variables as formatted_time.
  • Sets currentNode to "beachOpening".
  • Resets history, endingReached, and all inventory flags to their initial values.
  • Sets started to true.
When to call: Once, when the player presses the start button after completing the personalisation form. Do not call during an active session.
// Example: form submit handler
startGame({
  firstName: "Alex",
  favoriteColor: "blue",
  favoriteAnimal: "fox",
  cartoonChar: "Goofy",
  favoriteJewel: "sapphire",
});
// gameState.currentNode is now "beachOpening"
// gameState.variables.formatted_time is now e.g. "14:32"
Called when the player clicks a choice button on a choice-type node.
makeChoice(option: { label: string; value: string; next: string }): void
What it does:
  1. Verifies that currentNode is a choice-type node (no-ops otherwise).
  2. Appends { nodeId: currentNode, choice: option.label } to history.
  3. Resolves STORY_NODES[option.next] and merges its setState flags into gameState.state if present.
  4. Sets currentNode to option.next.
When to call: Inside a choice node renderer, once per button click. Pass the full option object from node.options.
Called when the player clicks Continue → on the last line of a narration-type node.
advanceNode(): void
What it does:
  1. Reads STORY_NODES[currentNode].next.
  2. Merges the destination node’s setState flags into gameState.state if present.
  3. Sets currentNode to node.next.
When to call: After all lines of a narration node have been displayed and the player clicks the advance button. If node.next is undefined (an ending node), the function is a no-op — the session is already complete.
Called when the player submits the correct answer to a riddle-type node.
solveRiddle(): void
What it does:
  1. Verifies that currentNode is a riddle-type node (no-ops otherwise).
  2. Appends { nodeId: currentNode, choice: 'echo' } to history.
  3. Sets currentNode to node.next.
When to call: In the riddle input handler, after the player’s answer matches node.answer (case-insensitive comparison). Incorrect answers should not call this function — the UI is responsible for showing the hint after two failures.
The history entry always records 'echo' as the choice value regardless of how the player typed their answer, since echo is the only correct answer in the current riddle set.
Called when the player reaches a narration node that carries an ending field.
reachEnding(endingId: string): void
What it does:
  1. Sets gameState.endingReached to endingId.
  2. Adds endingId to the achievements array if it is not already present.
  3. The achievements array is automatically persisted to beach-mystery-achievements via a useEffect.
When to call: In the narration renderer, when node.ending is defined. Pass node.ending as the argument. The renderer should call this once when the ending node is first entered.Valid ending IDs: islandTreasure, hiddenChamber, animalDen, peacefulWalk.
Resets gameState to its initial value, effectively starting a new session from the beginning.
resetGame(): void
What it does:
  • Restores gameState to the initialState object (all flags false, currentNode null, started false).
  • Does not clear achievements — discovered endings are preserved.
  • Does not remove the save from localStorage — call clearSave() separately if you want to discard the save file.
When to call: When the player chooses to start over from the main menu or from the ending screen.
Serialises gameState to localStorage under the key beach-mystery-save.
saveGame(): void
When to call: At natural save points — after a choice is made, before navigating away from the play page, or when the player manually triggers a save. The hook does not auto-save; you must call this explicitly.
saveGame captures the state at the moment it is called. If you call it before makeChoice or advanceNode resolves, the saved state will not reflect the latest transition. Call it in a useEffect that depends on gameState.currentNode to ensure the save is always current.
Attempts to load and restore game state from localStorage.
loadGame(): boolean
Returns: true if a valid in-progress save was found and restored; false otherwise.What it does:
  • Reads and JSON-parses beach-mystery-save.
  • If the parsed object has started: true, replaces gameState with the saved object and returns true.
  • Returns false (without modifying state) if no save exists, the JSON is malformed, or started is false.
When to call: On mount of the play page or home page, to offer the player the option to continue a previous session.
A lightweight check for whether a resumable save exists, without actually loading it.
hasSave(): boolean
Returns: true if beach-mystery-save in localStorage contains a valid object where started is true and currentNode is not null.When to call: To conditionally render a “Continue” button on the home page.
Removes the beach-mystery-save key from localStorage.
clearSave(): void
When to call: When the player explicitly chooses to delete their progress, or before calling resetGame() when you want a fully clean slate.
Merges a partial settings object into the current settings state.
updateSettings(partial: Partial<Settings>): void
What it does:
  • Shallow-merges partial into the existing settings object.
  • The updated settings are automatically persisted to beach-mystery-settings via a useEffect.
When to call: Whenever the player toggles sound, changes text size, or switches high-contrast mode in the settings panel.
// Enable high-contrast mode
updateSettings({ highContrast: true });

// Change text size
updateSettings({ textSize: 'large' });

// Multiple settings at once
updateSettings({ sound: false, textSize: 'small' });

Complete Usage Example

import { useGameState } from '../hooks/useGameState';
import { STORY_NODES } from '../data/story.js';

function StoryPlayer() {
  const {
    gameState,
    achievements,
    settings,
    startGame,
    makeChoice,
    solveRiddle,
    advanceNode,
    reachEnding,
    resetGame,
    saveGame,
    loadGame,
    hasSave,
    clearSave,
    updateSettings,
  } = useGameState();

  const node = STORY_NODES[gameState.currentNode];

  if (!gameState.started || !node) {
    return <p>No active session.</p>;
  }

  // Trigger ending recording once when an ending node is entered
  if (node.ending && gameState.endingReached !== node.ending) {
    reachEnding(node.ending);
  }

  if (node.type === 'narration') {
    return (
      <div>
        {/* render node.lines */}
        <button onClick={() => { advanceNode(); saveGame(); }}>
          Continue →
        </button>
      </div>
    );
  }

  if (node.type === 'choice') {
    return (
      <div>
        <p>{node.prompt}</p>
        {node.options.map(option => (
          <button key={option.value} onClick={() => { makeChoice(option); saveGame(); }}>
            {option.label}
          </button>
        ))}
      </div>
    );
  }

  if (node.type === 'riddle') {
    // handle riddle input and call solveRiddle() on correct answer
  }
}
Because achievements persists independently, you can safely display the full achievements list on the /endings route even when no session is active — simply destructure achievements from the hook and map over it.

Build docs developers (and LLMs) love