This page is written for an LLM agent, as well as a person, composing content for a Spindle game. It is the complete reference: file formats, rules the engine applies, design patterns and a pre-handoff checklist. An agent can build a working game from this page alone, without reading the rest of the site or the engine's source.

Prefer plain text? Fetch authoring.md directly, or start from llms.txt.

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.

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

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

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

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

Areas

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

---
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:

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:

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:

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):

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

Designing a game

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

Writing storylets:

Pitfalls

Before you hand off

A complete storylet

---
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.