# Story

> Dialogue with cut-out portraits, choices on cards, reward sheets, objectives and chapters, written as data and run by a small saveable state machine.

The story kit gives a game a story told in beats: lines of dialogue with a speaker's portrait, a choice on two or three cards, an objective the game must report progress on, a reward sheet, and chapter titles. You write the story as plain data. A pure runner moves through it and returns a small JSON state you can save. Three components draw the current beat.

Use it for any game with a narrator or characters who talk: a town builder with a quest line, an adventure, a tutorial told by a character. The [kingdom module](https://docs.moonspell.app/modules/kingdom.md) is built on it and is the reference user.

Everything below is exported from `@/engine`.

## Smallest working example

A two-line conversation, a choice, an objective the player completes by tapping, and a reward.

```tsx
import React, { useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
  ChoiceCards,
  DialogueBox,
  RewardSheet,
  advance,
  choose,
  createStoryState,
  currentBeat,
  currentObjective,
  report,
  skipDialogue,
  speakerOf,
  type GameDefinition,
  type GameScreenProps,
  type Story,
} from '@/engine';

const STORY: Story = {
  speakers: [
    { id: 'queen', name: 'Queen Maren', role: 'Ruler of Ashvale', color: '#7A4BC2', portrait: { icon: 'crown.fill' } },
    { id: 'tobbin', name: 'Old Tobbin', role: 'Farmer', side: 'right' },
  ],
  chapters: [
    {
      id: 'ch1',
      title: 'The harvest',
      beats: [
        { kind: 'title', text: 'Chapter 1', sub: 'The harvest' },
        { kind: 'say', speaker: 'queen', text: 'The granary is empty. We need wheat before winter.' },
        { kind: 'say', speaker: 'tobbin', text: 'Then pick a field and I will show you how.' },
        {
          kind: 'choice',
          id: 'field',
          title: 'Which field?',
          options: [
            { id: 'river', label: 'River', detail: 'Wet soil, slow but sure', icon: 'drop.fill' },
            { id: 'hill', label: 'Hill', detail: 'Dry soil, quick but thin', icon: 'sun.max.fill' },
          ],
        },
        { kind: 'objective', id: 'harvest', text: 'Harvest wheat', target: 3 },
        { kind: 'reward', title: 'Harvest', items: [{ resource: 'Wheat', amount: 3 }] },
      ],
    },
  ],
};

const Game = ({ onExit }: GameScreenProps) => {
  const [state, setState] = useState(createStoryState);
  const beat = currentBeat(STORY, state);
  const objective = currentObjective(STORY, state);

  const harvest = () =>
    setState((s) => {
      const next = report(s, 'harvest');
      const b = currentBeat(STORY, next);
      // An objective blocks advance until its target is met; then move on.
      return b?.kind === 'objective' ? advance(STORY, next) : next;
    });

  return (
    <View style={{ flex: 1, backgroundColor: '#2E3B2A' }}>
      {objective && (
        <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
          <Text style={{ color: '#fff' }}>
            {objective.text}: {objective.done} of {objective.target}
          </Text>
          <Pressable onPress={harvest}>
            <Text style={{ color: '#fff' }}>Harvest</Text>
          </Pressable>
        </View>
      )}
      {!beat && (
        <Pressable style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }} onPress={onExit}>
          <Text style={{ color: '#fff' }}>The end. Tap to exit.</Text>
        </Pressable>
      )}
      {beat?.kind === 'title' && (
        <Pressable style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }} onPress={() => setState((s) => advance(STORY, s))}>
          <Text style={{ color: '#fff', fontSize: 28 }}>{beat.text}</Text>
          {beat.sub ? <Text style={{ color: '#fff' }}>{beat.sub}</Text> : null}
        </Pressable>
      )}
      {beat?.kind === 'say' && (
        <DialogueBox
          speaker={speakerOf(STORY, beat.speaker)}
          text={beat.text}
          onAdvance={() => setState((s) => advance(STORY, s))}
          onSkip={() => setState((s) => skipDialogue(STORY, s))}
        />
      )}
      {beat?.kind === 'choice' && (
        <ChoiceCards title={beat.title} options={beat.options} confirm={beat.confirm} onChoose={(id) => setState((s) => choose(STORY, s, id))} />
      )}
      {beat?.kind === 'reward' && (
        <RewardSheet
          title={beat.title ?? 'Reward'}
          items={beat.items.map((it) => ({ label: it.resource, amount: it.amount }))}
          button="Take it"
          onDone={() => setState((s) => advance(STORY, s))}
        />
      )}
    </View>
  );
};

const game: GameDefinition = { id: 'harvest-tale', title: 'Harvest Tale', description: 'A short story about wheat.', Component: Game };
export default game;
```

To keep the story across sessions, store the `StoryState` in a save instead of `useState` (see [Persisting the story](https://docs.moonspell.app/engine/story.md#persisting-the-story)).

## Beats

A `Story` is `{ speakers, chapters }`. Each `Chapter` is `{ id, title, beats }`, and the runner plays the beats in order.

| `kind` | Fields | Waits for |
| --- | --- | --- |
| `title` | `text`, `sub?` | Whatever your game uses to continue (usually a tap), then `advance`. |
| `say` | `speaker` (a speaker id), `text` | A tap on `DialogueBox`. The first tap finishes the typing, the second calls `onAdvance`. |
| `choice` | `id`, `title`, `options`, `confirm?` | The confirm button on `ChoiceCards`, then `choose`. `advance` refuses to leave an unanswered choice. |
| `objective` | `id`, `text`, `target?` (default 1), `hint?` | Progress reported with `report` or `reportTotal` reaching `target`. `advance` refuses to leave it before then. |
| `reward` | `title?`, `items: { resource, amount }[]` | The button on `RewardSheet`. Grant the items yourself when it is pressed, then `advance`. |
| `scene` | `scene`, `args?` | Something only your game knows how to do (start a battle, pan the camera, unlock a building). Do it, then `advance`. |

A `Speaker` is `{ id, name, role?, color?, portrait?, side? }`. `side` is `'left'` (default) or `'right'`. A `ChoiceOption` is `{ id, label, detail, icon?, portrait? }`.

## Portraits

A `Portrait` is either a cell of a sprite sheet, `{ source, cols, rows, frame }`, or an SF Symbol, `{ icon }`. Cells count in reading order: left to right, top to bottom, from 0.

```ts
const SHEET = { source: require('../../assets/sprites/oath-of-ashvale/sheet.png'), cols: 3, rows: 3 };
const portrait = (frame: number) => ({ ...SHEET, frame });

const speakers = [{ id: 'queen', name: 'Queen Maren', portrait: portrait(0) }];
```

This is how [Oath of Ashvale](https://docs.moonspell.app/examples.md) gives its queen, marshal and farmer their cut-outs. Until a sheet exists, or with no portrait at all, `PortraitView` draws the icon on a coloured disc, or the speaker's initials (`monogramOf(name)`). The sheet is drawn with `SheetImage`; see [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md).

## The runner

Every function is pure and returns a new state, or the same object when nothing changed. That makes it safe inside `setState` and inside a save's `update`.

| Function | What it does |
| --- | --- |
| `createStoryState()` | A fresh state at chapter 0, beat 0. |
| `restoreStoryState(story, saved)` | A saved state checked against the story it will run, so a save from before you edited the chapters still loads. |
| `currentChapter(story, s)`, `currentBeat(story, s)` | What is playing now, or `null` when the story is finished. |
| `advance(story, s)` | The next beat, or the next chapter. Refuses to leave an unmet objective or an unanswered choice. |
| `choose(story, s, optionId)` | Records the pick for the current choice beat and advances. |
| `report(s, objectiveId, amount?)` | Adds progress (default 1) to any objective, met or not. |
| `reportTotal(s, objectiveId, total)` | Sets progress to a count your game already keeps: buildings standing, wheat held. |
| `objectiveDone(s, beat)`, `targetOf(beat)` | Whether an objective beat is met, and its target. |
| `currentObjective(story, s)` | `{ id, text, hint?, done, target }` for a quest tracker, or `null`. |
| `skipDialogue(story, s)` | Jumps over `say` and `title` beats to the next beat that needs the player (choice, objective, scene, reward) or the next chapter. |
| `isDialogueBeat(beat)` | `true` for `say`, `choice`, `reward` and `title`: the beats that cover the game's controls. |
| `speakerOf(story, id)` | The speaker, or `{ id, name: id }` when the id is unknown. |

`StoryState` is `{ chapter, beat, choices, progress, finished }`. `choices` holds each choice's pick by its `id`, so later code can read `state.choices.field`. Progress is kept by objective id across beats: if the player harvests before the story asks for it, the work still counts when the objective comes up.

## Components

All three cover the whole screen while they are up. Give each the same `theme` so they match your game.

### DialogueBox

| Prop | Default | What it does |
| --- | --- | --- |
| `speaker` | required | The `Speaker` to show: portrait, name, role. |
| `text` | required | The line, typed out. |
| `onAdvance` | required | Called on the tap after the line is fully shown. |
| `onSkip` | none | Shows a Skip pill at the top right. Hidden when not set. |
| `theme` | `DEFAULT_STORY_THEME` | Colours and font. |
| `speed` | `45` | Characters per second. |
| `skipTop` | `110` | Top inset of the Skip pill, to sit under your HUD's top bar. |

### ChoiceCards

| Prop | Default | What it does |
| --- | --- | --- |
| `title` | required | The question. |
| `options` | required | Two or three `ChoiceOption`s, shown as cards. The first is selected at the start. |
| `confirm` | `'Choose'` | The confirm button's label. |
| `onChoose` | required | Called with the selected option's id. |
| `theme` | `DEFAULT_STORY_THEME` | Colours and font. |

### RewardSheet

| Prop | Default | What it does |
| --- | --- | --- |
| `title` | required | The banner word: "Victory", "Harvest". |
| `subtitle` | `'Rewards'` | The line above the items. |
| `items` | required | `RewardRow[]`: `{ label, amount, picture?, color? }`. `picture` is a `Portrait`. |
| `button` | required | The button's label. |
| `onDone` | required | Called when the button is pressed. |
| `theme` | `DEFAULT_STORY_THEME` | Colours and font. |
| `bannerColor` | the theme's accent | The banner's colour. |
| `testID` | `'reward-sheet'` | Test id of the sheet. |

`RewardSheet` is not tied to the story: use it for any payoff moment, such as a won battle.

### PortraitView

`PortraitView` draws one portrait with its fallback: `portrait?`, `monogram`, `color`, `size`, `fontFamily?` and `shape` (`'disc'` by default, or `'cutout'` for a bare cut-out).

### Theme

`StoryTheme` is `{ fontFamily?, panel, text, dim, accent, onAccent, scrim }`. `DEFAULT_STORY_THEME` is a white panel with dark text and a red accent. Pass your game's palette and the font family you loaded with `useGameFonts` (see [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)).

The components set test ids for automated playtests: `dialogue`, `dialogue-name`, `dialogue-text`, `btn-skip`, `choice-cards`, `choice-<id>`, `choice-detail`, `btn-choice-confirm`, `reward-title`, `btn-reward-done`.

## Persisting the story

`StoryState` is small JSON. Keep it in a save and read it back with `restoreStoryState`, the way the kingdom module does:

```tsx
import { useMemo } from 'react';
import { advance, createStoryState, currentBeat, defineSave, restoreStoryState, useSave, type Story, type StoryState } from '@/engine';

const save = defineSave({ version: 1, key: 'story', defaults: { story: createStoryState() } });

export function useStory(story: Story) {
  const data = useSave(save);
  const state = useMemo(() => restoreStoryState(story, data.story), [story, data.story]);
  const setStory = (fn: (s: StoryState) => StoryState) =>
    save.update((d) => ({ ...d, story: fn(restoreStoryState(story, d.story)) }));
  return { state, beat: currentBeat(story, state), next: () => setStory((s) => advance(story, s)), setStory };
}
```

See [Saves and progression](https://docs.moonspell.app/engine/saves.md) for versions, migrations and sync.

## Gotchas

- **Nothing advances by itself.** The runner only moves when you call `advance`, `choose` or `skipDialogue`. A met objective waits until you advance; a `scene` beat waits until your game has done the scene and advances.
- **Grant rewards yourself.** The `reward` beat only describes the items. Add them to the wallet when `onDone` fires, then advance.
- **`title` beats have no component.** Draw them your own way (a full-screen chapter card) and advance on a tap.
- **Ids are forever.** Choices and objectives are saved by id. Renaming one in a later version loses that progress; add a new id instead.
- **Edit chapters safely.** Always load saved states through `restoreStoryState`, which clamps the chapter and beat to the story as it is now.
- **No emoji in lines.** Write dialogue as plain text; use `icon` or `portrait` for pictures.

## See also

- [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): a town builder whose story drives building, farming and battles, with objective ids it reports by itself.
- [Examples](https://docs.moonspell.app/examples.md): Oath of Ashvale, two chapters with cut-out portraits and a knight-or-ranger choice.
- [Saves and progression](https://docs.moonspell.app/engine/saves.md): keeping the story state.
- [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md): fonts, icons and `SheetImage`.
