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
- Start from the skeleton:
spindle init my-game, thencd 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/lamplighteruses every feature in this guide;the-martianandtwenty-thousand-leaguesshow interactive elements doing real work). - Plan before writing prose: list the qualities (with what each level means), the areas, and the progression spine (see Designing a game below).
- Write content files under
content/. - Run
spindle checkafter 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. - Run
spindle serveand play. The page picks up each rebuild within two seconds, keeps the player's save, and lists content problems at the top. - Before handing off, run
spindle check --strict(fails on warnings too) andspindle 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
- display decides how the level is shown.
levelshows the number ("Nerve 3");countis for things you have several of ("× 4", and messages say "You've gained 2 × Lamp Oil");labelshows the level's label ("Thick fog");bardraws a progress bar towardsmax(use it for story progress). - progression:
linearqualities are just the number.pyramidqualities 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= neffects are all in levels, and+neffects are in points. ## <level>: <label>sections are optional and may skip levels. The label (after the colon) is used bydisplay: labeland 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
---
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:
- 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:
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 leastbroad_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 leastnarrow_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 negativesort_orderto 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_tomoves the player;nextopens 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
barquality (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
pyramidqualities, raised a little by most branches (+1on success and failure alike, so failure still teaches) and tested by challenges. Difficulties should climb with the spine. - Resources:
countqualities 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 +2on failure), with anautofireat a threshold that sets the player back (move_tohome,gloom = 1), and an ordinary storylet that lowers them for a price. - Conditions:
labelqualities 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 andmet_keeper = 1in 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 usecan_go_back: falseon 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, notcoins+1. Random ranges are+1..3. - A requirement above a quality's
maxcan never be met;spindle checkreports it. - Pyramid effects are in points:
+1on 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/nextpointing 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 --strictpasses.- 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
---
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.