# Writing a game for Spindle

This guide is for anyone, human or agent, composing content for a quality-based narrative game that runs on Spindle. It covers the whole file format, the rules the engine applies, the patterns that keep a game manageable, and the checks to run before calling the work done. Print it any time with `spindle guide`; `spindle init` also copies it into every new game as `AGENTS.md`.

## What you are making

A quality-based narrative (QBN) game is a pool of short scenes, called **storylets**, each unlocked by the player's **qualities**: numbers that describe the player's skills, possessions, relationships, progress and troubles. The player is always in one **area**. They pick an available storylet, pick one of its **branches**, and get an **outcome** whose **effects** change qualities, which in turn unlocks and locks other storylets. There's no fixed plot; the story emerges from which gates the player's qualities open. A deck of **cards** (storylets drawn at random) adds variety.

Everything is plain text. Each storylet, quality, area and category is one Markdown file with YAML frontmatter. `spindle build` compiles them into one SQLite file that the browser reads.

## Workflow

1. Start from the skeleton: `spindle init my-game`, then `cd my-game`. It's a nearly empty game that builds and plays. For fuller games to learn from, read the examples in the Spindle repository (`examples/lamplighter` uses every feature in this guide; `the-martian` and `twenty-thousand-leagues` show interactive elements doing real work).
2. Plan before writing prose: list the qualities (with what each level means), the areas, and the progression spine (see *Designing a game* below).
3. Write content files under `content/`.
4. Run `spindle check` after every batch of files. It reports unreadable files, references to things that don't exist, unreachable gates and other mistakes, with the file or entity at fault. Fix every error; read every warning.
5. Run `spindle serve` and play. The page picks up each rebuild within two seconds, keeps the player's save, and lists content problems at the top.
6. Before handing off, run `spindle check --strict` (fails on warnings too) and `spindle build`.

`spindle check` is the fastest feedback you have. An agent without a browser can still check a game thoroughly with it, and by reading the compiled database with `sqlite3 build/game.db` (see the README for the schema).

## Project layout

```
my-game/
├── config.toml                 title, tagline, theme, interface words
├── content/
│   ├── rules.md                game rules (frontmatter) and designer notes (body)
│   ├── categories/<id>.md      groups of qualities in the sidebar
│   ├── qualities/<id>.md
│   ├── areas/<id>.md
│   ├── storylets/**/<id>.md    any subdirectories you like
│   └── components/<name>.js    code for interactive elements (optional)
├── static/                     images and other files; `image: images/x.svg` means static/images/x.svg
├── themes/<theme>/theme.css    the look (optional)
├── layouts/head.html           HTML added to <head> (favicon, fonts) (optional)
├── layouts/footer.html         HTML added to the footer (credits) (optional)
└── build/game.db               the compiled game (generated; don't edit or commit)
```

The file name without `.md` is the entity's **id**. Ids use only letters, digits, `_` and `-`, and must be unique within each collection (two storylets can't share an id even in different subdirectories). Subdirectories under `storylets/` are only for organising; the convention is `storylets/<area id>/`, `storylets/cards/` and `storylets/anywhere/`. Files and directories starting with `.` are ignored.

## config.toml

All keys are optional.

```toml
id = "lamplighter"            # save key in the browser; defaults to a slug of the title
title = "The Lamplighter"
tagline = "One line under the title."
language = "en"
theme = "lamplight"           # uses themes/lamplight/theme.css
stylesheets = []              # extra stylesheet URLs

[terms]                       # the words the interface uses
actions = "Actions"
qualities = "Qualities"       # sidebar heading
storylets = "Here"            # heading over the area's storylets
hand = "Opportunities"        # heading over the cards
draw = "Draw a card"
discard = "Discard"
travel = "Travel"
choose = "Go"
go_back = "Perhaps not"
continue = "Onwards"
success = "Success"
failure = "Failure"
rare_success = "A rare success!"
start_over = "Start over"
name_prompt = "What is your name?"   # asked of each new player
name_button = "Begin"
who_is_playing = "Who's playing?"      # the player picker's heading
new_player = "New player"
switch_player = "Switch player"
```

Changing `id` makes every player start afresh, so settle it early.

## rules.md

```markdown
---
start_area: lamp_house    # where new players begin; defaults to the first area
hand_size: 3              # cards a player can hold; 0 turns the deck off
show_chances: true        # show percentages on challenges
ask_name: true            # ask for the character's name first
---

Designer notes: free text for whoever works on the game next.
```

Other rules, with defaults: `broad_base_chance: 60`, `broad_min_chance: 1`, `narrow_base_chance: 60`, `narrow_step: 10`, `narrow_min_chance: 10`, `pyramid_level_cap: 0` (no cap), and card weights `weight_rare: 1`, `weight_infrequent: 3`, `weight_standard: 6`, `weight_frequent: 10`, `weight_abundant: 20`.

`budget: unlimited` (the default and, for now, the only option) lets players take as many actions as they like. Branches still have a `cost`, so a game written now will work when a limited budget is switched on later. Rules the engine doesn't know are kept and compiled as-is, so it's fine to record settings for a future budget (for example `actions_max`) now.

Use the body of `rules.md` for designer notes: the progression spine, the economy, what each menace does, facts writers should respect. The notes aren't shown to players.

## Categories

```markdown
---
name: Skills
sort_order: 1        # order in the sidebar (lower first)
visible: false       # optional: hide every quality in this category from players
---

Optional description.
```

Use a hidden category (such as `bookkeeping`) for flags and counters players shouldn't see.

## Qualities

```markdown
---
name: The Fog
category: conditions
display: label        # level (default), count, label or bar
progression: linear   # linear (default) or pyramid
max: 3                # optional cap
initial: 2            # starting level (default 0)
image: images/fog.svg # optional, under static/
visible: true         # false hides this quality from players
sort_order: 0
---

Description, shown when the player expands the quality.

## 1: Thin fog

Text shown when the player reaches level 1, and in the sidebar while at it.

## 2: Thick fog

## 3: Pea-souper
```

- **display** decides how the level is shown. `level` shows the number ("Nerve 3"); `count` is for things you have several of ("× 4", and messages say "You've gained 2 × Lamp Oil"); `label` shows the level's label ("Thick fog"); `bar` draws a progress bar towards `max` (use it for story progress).
- **progression**: `linear` qualities are just the number. `pyramid` qualities grow slowly: effects add *change points*, and level *n* costs *n* points (so level 3 needs 1 + 2 + 3 = 6 points). Use pyramid for skills that improve through practice. For pyramid qualities, `initial`, `max`, requirements, challenge difficulties and `= n` effects are all in levels, and `+n` effects are in points.
- `## <level>: <label>` sections are optional and may skip levels. The label (after the colon) is used by `display: label` and in requirement text; the section's text is shown when the player reaches that level. A `##` heading here must be a level number.
- Qualities never go below 0 or above `max`. A quality at 0 isn't shown in the sidebar.

## Areas

```markdown
---
name: The Old Town
image: images/old-town.svg
sort_order: 3
on_map: true          # false: players can't travel here; only outcomes can move them
requires: [lamps_lit >= 2]   # needed to travel here
---

Description, shown at the top of the area. Templating works here.
```

Players can travel freely between areas on the map whose requirements they meet (but not in the middle of a storylet).

## Storylets

```markdown
---
title: Light a Great Lamp
area: harbour                  # omit for "anywhere"
teaser: "[if lamps_lit >= 1]Another lamp waits.[else]The lamp is dark.[/if]"
requires: [oil >= 1, lamps_lit <= 3]
branches:
  Climb up and light it:
    challenge: broad nerve 2
    rare: 10
    success:
      effects: [oil -1, lamps_lit +1, nerve +1]
    failure:
      effects: [oil -1, gloom +2, nerve +1]
    rare_success:
      effects: [oil -1, lamps_lit +1, coins +3]
---

The storylet's text: everything before the first `##` heading.

## Climb up and light it

The branch's text.

### Success: Light

The outcome's text. The part after the colon is the outcome's title.

### Failure: Blown out

### Rare success: A crowd below
```

The body holds the words; the frontmatter holds the rules. `## <branch title>` headings list the branches in order. Under each, `### Success`, `### Failure` and `### Rare success` sections (each optionally `: Title`) hold the outcomes' text. The frontmatter's `branches` map is keyed by the **exact** branch title and holds only mechanics; a branch with no mechanics needs no entry. `##` and `###` headings are reserved for this structure, so use `*emphasis*` rather than headings in prose.

Storylet keys:

| Key | Default | Meaning |
| --- | --- | --- |
| `title` | the id | shown on the card and at the top |
| `area` | anywhere | the area it appears in |
| `kind` | `storylet` | or `card` (drawn into the hand instead of listed) |
| `frequency` | `standard` | cards only: `rare`, `infrequent`, `standard`, `frequent`, `abundant` |
| `teaser` | | text on the storylet's card in the list |
| `requires` | | quality gates, all of which must be met |
| `autofire` | `false` | take over as soon as `requires` is met; can't be backed out of |
| `can_go_back` | `true` | offer the "Perhaps not" button |
| `show_locked` | `false` | list it, greyed out with its requirements, when locked |
| `image` | | image under `static/` |
| `sort_order` | 0 | list order (lower first, then by title) |

Branch keys (under `branches: <title>:`):

| Key | Default | Meaning |
| --- | --- | --- |
| `requires` | | gates on this branch; locked branches show with their requirements |
| `challenge` | none | `broad <quality> <difficulty>`, `narrow <quality> <difficulty>` or `luck <percent>` |
| `rare` | 0 | percent chance that a success becomes a rare success |
| `cost` | 1 | actions spent (for a future budget; not limited now) |
| `image` | | |
| `success`, `failure`, `rare_success` | | outcomes, each with the keys below |

Outcome keys: `effects` (list), `move_to` (an area id), `next` (a storylet id to open immediately, for multi-part scenes), `image`.

A branch without a challenge always succeeds. A branch with a challenge must have a failure outcome. A branch with `rare` needs a `rare_success` outcome and vice versa.

## Interactive elements

A storylet can place custom interactive elements (a slider, a puzzle, a way of splitting points) in its text. The element's code lives in `content/components/<name>.js`; the storylet names it in a fenced block whose info string is `ui`, with YAML inside:

````markdown
The stove is lit and the lamps aren't due for a while.

```ui
component: allocate
points: 3
options:
  - quality: wits
    label: Read the almanac
  - quality: gloom
    label: Sit by the stove
    per: -1
```

Whatever you choose, the lamps will still need lighting.
````

`component` names the file (`allocate` is `content/components/allocate.js`); every other key is a parameter that the component reads, so what is allowed depends on the component. Put as many blocks in a storylet as you like, of the same component or of different ones; each is a separate element with its own parameters, and they appear where they're written, between the paragraphs. Blocks go in the storylet's opening text only, before the first `## <branch>`; one in a branch or outcome is an error. A ```ui block inside another fenced code block is just code.

An element works through the storylet's branches, which still carry the outcomes and their text:

- The effects an element asks for are applied, along with the chosen branch's own, whichever branch is chosen and whether it succeeds or fails. The player sees them in the usual list of changes. Elements can only change qualities the game defines.
- While an element says it isn't ready (a puzzle not yet solved, points not yet spent), the branches can't be chosen, and the element's reason is shown on them.

### Writing a component

A component is one JavaScript file with no imports. It exports a default function, which is called when its block appears, with a context:

```js
export default function (ctx) {
  const button = document.createElement("button");
  button.textContent = ctx.params.label;
  button.addEventListener("click", () => {
    ctx.setEffects(["coins +2"]);
    ctx.setReady(true);
  });
  ctx.el.append(button);
  ctx.setReady(false, "Press the button first.");
}
```

| Context | Meaning |
| --- | --- |
| `el` | the element to draw into |
| `params` | the block's YAML as an object, minus `component` |
| `name` | the player's name |
| `level(qid)`, `label(qid)`, `qualityName(qid)` | read a quality as the player has it |
| `setEffects(list)` | the effects to apply when a branch is chosen, replacing any set earlier by this element. Each is a string as in frontmatter (`"coins +2"`, `"gloom = 0"`, `"nerve +1..3"`) or an object `{ quality, op: "add" or "set", amount, amount_max }` |
| `setReady(ready, reason)` | whether branches may be chosen; `reason` explains why not |

The function may be `async`, and may return a function to call when the element leaves the page (to clear timers, say). Elements start out not ready until the function returns, and ready after that unless it called `setReady`. If the function throws, the element shows the error and the storylet can still be finished without it. Parameters are whatever the YAML says, so have the component cope with a missing or mistyped one. Errors show in the element's place and in the browser's console; `spindle check` doesn't run components.

Components are compiled into the game's database, so editing one rebuilds the game like any content file, and exported sites need nothing extra. They run with the page's full access (the game's own code), draw into `el` and may add their own `<style>`; use the theme's custom properties (`var(--color-accent)`, `var(--color-border)`, `var(--radius)`) so they match any theme. The Lamplighter example has two: `allocate`, which splits points among qualities, and `wick`, a slider the player tunes and tests. The Martian example has `stoich`, a chemistry bench whose effects come from the equation the player balances, and the Twenty Thousand Leagues example has `ballast` (two sliders and live gauges) and `catalogue` (a classification quiz). Copy a component file into your game and change its parameters rather than starting from nothing.

`spindle check` reports a ```ui block that names a missing component, a component file with no `export default`, and (as information) components no storylet uses. It doesn't run them.

## Requirements, effects and challenges

Requirements and effects are one-line strings, usually in a flow list: `requires: [coins >= 3, tide == 1]`.

| Requirement | Meaning |
| --- | --- |
| `coins >= 3` | at least 3 |
| `coins > 2` | more than 2 |
| `coins <= 0` | at most 0 (i.e. has none) |
| `coins < 5` | less than 5 |
| `tide == 1` | exactly 1 |
| `1 <= tide <= 2` | between, inclusive |
| `coins` | at least 1 |

| Effect | Meaning |
| --- | --- |
| `coins +3` | add 3 |
| `coins -1` | remove 1 |
| `coins +1..4` | add a random 1 to 4 |
| `tide = 1` | set to 1 |
| `tide = 1..2` | set to a random 1 or 2 |

Challenges (percent chance of success, before the rare roll):

- `broad <quality> <difficulty>`: `broad_base_chance × level ÷ difficulty`, at least `broad_min_chance`, at most 100. With the defaults, a level equal to the difficulty gives 60%, and 1.67 × the difficulty guarantees success. Good for most skill checks.
- `narrow <quality> <difficulty>`: `narrow_base_chance + narrow_step × (level − difficulty)`, at least `narrow_min_chance`. With the defaults, 60% at the difficulty, +10% per level above. Good for checks that should stay tense.
- `luck <percent>`: a flat chance.

## Text

Storylet bodies and teasers, branch and outcome text, area descriptions and quality descriptions all support:

| Syntax | Meaning |
| --- | --- |
| `{{name}}` | the player's name |
| `{{charm}}` | the player's level in the quality `charm` |
| `{{charm.name}}`, `{{charm.label}}` | the quality's name; its current level's label |
| `[if charm >= 3]…[else]…[/if]` | conditional text; operators `>= <= > < == !=`; not nestable; `[else]` optional |
| `**bold**`, `*italic*` | emphasis |
| blank line | new paragraph |

All text is HTML-escaped, so markup in content is shown literally. Prefer conditional text for small variations over near-duplicate storylets.

## How the engine behaves

- The area view lists the area's storylets plus the "anywhere" storylets whose requirements are met, and the locked ones marked `show_locked`.
- Autofire storylets are checked whenever the player isn't in a storylet: the first one (in `sort_order`, then title order) whose requirements are met takes over. An autofire must change a quality it requires, or move the player, or it will fire again straight away. Use negative `sort_order` to give important autofires priority.
- Choosing a branch: the challenge is rolled; on success the rare chance is rolled; the outcome's effects apply in order; the card (if it was one) leaves the hand; `move_to` moves the player; `next` opens that storylet at once (the player sees the outcome, then the next storylet).
- Drawing a card picks at random, weighted by frequency, among the cards for the current area (and anywhere) whose requirements are met and that aren't already in the hand.
- Only visible qualities in visible categories appear in the sidebar, in requirement lists and in change messages; hidden ones still work everywhere.
- Saves live in the player's browser, one per player: several people can play on one browser, choosing who's playing when the page opens. Each player's name is their character's name, so `{{name}}` works for everyone. When the content is rebuilt, saves carry over: qualities keep their values, and a storylet, card or area that no longer exists is dropped. Renaming a quality's id loses players' progress in it, so avoid renaming ids once people are playing.

## Designing a game

A game stays manageable when every quality has a clear job. Aim for a handful of each kind:

- **A progress spine**: one `bar` quality (0 to 5–12) that tracks the story. Each level unlocks the next area or chapter; its level texts narrate the milestones. Gate the big storylets on it (`reach >= 4`), and cap the storylets that raise it (`reach <= 3`) so they disappear once done.
- **Skills**: two to five `pyramid` qualities, raised a little by most branches (`+1` on success and failure alike, so failure still teaches) and tested by challenges. Difficulties should climb with the spine.
- **Resources**: `count` qualities that form a loop, for example work → goods → money → the things the spine needs. A loop gives players something to do between milestones.
- **Menaces**: one or two troubles that rise as a cost of risky choices (`gloom +2` on failure), with an `autofire` at a threshold that sets the player back (`move_to` home, `gloom = 1`), and an ordinary storylet that lowers them for a price.
- **Conditions**: `label` qualities for the state of the world (tide, weather, alert level), changed by cards and read by conditional text.
- **Flags**: hidden qualities for one-off events and branching memory. The one-shot pattern: `requires: [met_keeper <= 0]` on the storylet and `met_keeper = 1` in its outcome.

Writing storylets:

- Keep each storylet short: a paragraph of setup, two or three branches, a paragraph per outcome. Write in the second person, present tense, unless the game has another voice.
- Make branches real choices: a safe option and a risky one, or two different costs, or two different qualities tested. Every branch should either change something or teach something.
- Give a locked branch a reason to exist: it tells the player what to aim for.
- Split a long scene into a chain with `next`, and use `can_go_back: false` on the later parts.
- Use cards for small incidents and the texture of the world, and for easing resource shortages.
- Make sure the player always has something to do: in every area, at least one storylet should be available at the starting state of that area, and the first area should never be empty. Menaces need a way down that doesn't require the thing the menace blocks.
- Write the ending as an autofire on the spine's last level, guarded by a hidden flag so it fires once, and let play continue afterwards.

## Pitfalls

- A `branches:` key that doesn't match a `## ` heading exactly is an error, and a branch whose settings are misspelled won't get them. Copy titles, don't retype them.
- Quote YAML values that start with `[`, `{`, `*`, `&`, `!`, `|`, `>`, `'`, `"`, `%` or `@`, or that contain `: ` or ` #`. Teasers that begin with `[if …]` need quotes. The frontmatter reader supports common YAML (block and flow lists and maps, quoted strings, `|` and `>` blocks, comments) but not anchors, aliases or tags.
- Effects use a space before the sign: `coins +1`, not `coins+1`. Random ranges are `+1..3`.
- A requirement above a quality's `max` can never be met; `spindle check` reports it.
- Pyramid effects are in points: `+1` on a pyramid skill at level 4 takes five such outcomes to reach level 5. That's intended; don't compensate with big numbers.
- A storylet in an area that doesn't exist, or a `move_to` / `next` pointing at nothing, is an error.
- Don't put `##` headings in prose. Level-3 headings other than the three outcome kinds are errors.
- Two branches of one storylet can't share a title (case-insensitively).
- An interactive element's effects ride along with whichever branch is chosen, so give the storylet branches that make sense for every state the element can be in, and don't have the element and a branch both change the same quality unless you mean both to apply.

## Before you hand off

- `spindle check --strict` passes.
- From a fresh save, the first area offers something to do, and every area on the map is reachable.
- The spine can be completed: follow it level by level and name the storylet that raises each level and what it requires.
- Every menace has an autofire at its threshold and a way down.
- `rules.md`'s body explains the spine, the economy and the menaces for the next writer.
- You've played it with `spindle serve`, or, if you can't use a browser, read the compiled database to confirm the storylets you expect are in each area.

## A complete storylet

```markdown
---
title: Sell Oil to the Fishermen
area: harbour
teaser: Their lanterns are always running dry.
requires: [oil >= 2]
branches:
  Haggle:
    challenge: luck 60
    success:
      effects: [oil -1, coins +2..4]
    failure:
      effects: [oil -1, coins +1]
  Give it away:
    success:
      effects: [oil -1, gloom -1]
---

The boats are drawn up on the shingle. Men are mending nets by the light of a single guttering lantern.

## Haggle

### Success: A fair price

They grumble, and pay.

### Failure: A poor price

They know you need the money more than they need the oil.

## Give it away

It's a cold night.

### Success: Thanks

Someone passes you a cup of something hot, and for a while the dark doesn't matter.
```
