Skip to main content

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.

kindFieldsWaits for
titletext, sub?Whatever your game uses to continue (usually a tap), then advance.
sayspeaker (a speaker id), textA tap on DialogueBox. The first tap finishes the typing, the second calls onAdvance.
choiceid, title, options, confirm?The confirm button on ChoiceCards, then choose. advance refuses to leave an unanswered choice.
objectiveid, text, target? (default 1), hint?Progress reported with report or reportTotal reaching target. advance refuses to leave it before then.
rewardtitle?, items: { resource, amount }[]The button on RewardSheet. Grant the items yourself when it is pressed, then advance.
scenescene, 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.

FunctionWhat 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​

PropDefaultWhat it does
speakerrequiredThe Speaker to show: portrait, name, role.
textrequiredThe line, typed out.
onAdvancerequiredCalled on the tap after the line is fully shown.
onSkipnoneShows a Skip pill at the top right. Hidden when not set.
themeDEFAULT_STORY_THEMEColours and font.
speed45Characters per second.
skipTop110Top inset of the Skip pill, to sit under your HUD's top bar.

ChoiceCards​

PropDefaultWhat it does
titlerequiredThe question.
optionsrequiredTwo or three ChoiceOptions, shown as cards. The first is selected at the start.
confirm'Choose'The confirm button's label.
onChooserequiredCalled with the selected option's id.
themeDEFAULT_STORY_THEMEColours and font.

RewardSheet​

PropDefaultWhat it does
titlerequiredThe banner word: "Victory", "Harvest".
subtitle'Rewards'The line above the items.
itemsrequiredRewardRow[]: { label, amount, picture?, color? }. picture is a Portrait.
buttonrequiredThe button's label.
onDonerequiredCalled when the button is pressed.
themeDEFAULT_STORY_THEMEColours and font.
bannerColorthe theme's accentThe 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, 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: 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.