Story
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 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.
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).
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.
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 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.
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 ChoiceOptions, 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).
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:
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 for versions, migrations and sync.
Gotchas
- Nothing advances by itself. The runner only moves when you call
advance,chooseorskipDialogue. A met objective waits until you advance; ascenebeat waits until your game has done the scene and advances. - Grant rewards yourself. The
rewardbeat only describes the items. Add them to the wallet whenonDonefires, then advance. titlebeats 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
iconorportraitfor pictures.
See also
- Kingdom module: a town builder whose story drives building, farming and battles, with objective ids it reports by itself.
- Examples: Oath of Ashvale, two chapters with cut-out portraits and a knight-or-ranger choice.
- Saves and progression: keeping the story state.
- HUD and UI: fonts, icons and
SheetImage.