Spindle
A kernel for quality-based narrative games. You write a game as a directory of Markdown files with YAML frontmatter; Spindle compiles it into a single SQLite file; a browser-based engine plays it by reading that file in pieces with HTTP range requests. Game logic runs in the browser, and each player's save stays in their browser's localStorage, so any web server that can serve a file can host a game.
The content format supports storylets, branches, qualities, areas, cards, challenges, autofire.
Spindle needs Python 3.11+ and nothing else. The browser engine is plain JavaScript modules with no dependencies, including its own reader for the SQLite file format.
Docs: exupero.github.io/spindle — also has the guide to writing content, written for an agent or a person.
Install
From this directory:
pip install --user . # or: pipx install .
That puts a spindle command on your path. Use pip install --user -e . instead to run from this checkout while working on Spindle itself. Without installing, python3 -m spindle works from this directory.
Quick start
spindle init my-game # a new, nearly empty game
cd my-game
spindle serve # build, watch and serve at http://127.0.0.1:8080/
Edit anything under content/ and the page picks up the new build within two seconds, keeping the player's progress. Problems in the content are listed at the top of the page and in the terminal; while the content has errors, the last good build keeps being served.
Each game is its own directory, and spindle works on whichever directory it's given (the current one by default), so one installation serves any number of games.
Commands
spindle init <dir> # create a new, nearly empty game
spindle guide # print the guide to writing content (also in each new game as AGENTS.md)
spindle check [dir] [--strict] # look for mistakes; --strict fails on warnings too
spindle build [dir] [-o file] # compile to build/game.db (or the given file)
spindle serve [dir] [--port N] [--poll S] [--no-watch] [--open]
spindle export [dir] [-o site] [--db URL] # a static site for any web server
Writing games
AUTHORING.md is the complete guide to composing content: the file formats, the rules the engine applies, design patterns, pitfalls and a checklist. It's written so that an agent can build a game from it alone. The examples are finished games to read and borrow from; Lamplighter exercises every feature.
In brief, a game directory looks like this:
my-game/
├── config.toml title, tagline, theme, interface words, save id
├── content/
│ ├── rules.md game rules and designer notes
│ ├── categories/<id>.md
│ ├── qualities/<id>.md
│ ├── areas/<id>.md
│ ├── storylets/**/<id>.md
│ └── components/<name>.js interactive elements for storylets (optional)
├── static/ images, served at static/
├── themes/<theme>/theme.css optional look
├── layouts/head.html optional HTML for <head>
├── layouts/footer.html optional HTML for the footer
└── build/game.db the compiled game
Examples
Each directory under examples/ is a complete game. Play one with spindle serve examples/<name>.
| Example | What it shows |
|---|---|
| lamplighter | the original starter game: every feature in the guide, with an allocate element (split points among qualities) and a wick element (tune a slider, test it) |
| the-martian | after Andy Weir's novel. A stoich element is a chemistry bench: the player balances N2H4 -> N2 + H2 and H2 + O2 -> H2O to turn rocket fuel into drinking water, and the element derives the effects from the balanced coefficients. Hydrogen in storage is the menace |
| twenty-thousand-leagues | after Jules Verne's novel. A ballast element has the player trim the Nautilus's two tanks to sink at a given rate while level, and a catalogue element is Conseil's classification quiz that awards naturalism for each right answer |
The novels' stories are retold in new words; the games are small and exist to show what interactive storylets can do.
Hosting
spindle export -o site writes a directory to put on any static host:
site/
├── index.html the player page
├── game.db the compiled game
├── _spindle/ the engine
├── static/ the game's images
└── themes/ the game's theme
Hosts that support range requests (nginx, Apache, Caddy, GitHub Pages, S3 and most CDNs) serve only the pages of the database a player needs. Hosts that don't (such as python3 -m http.server) send the whole file on the first request, and the engine then reads from memory, which also works.
The page reads game.db beside it by default. To read a database from elsewhere, pass --db <url> to export, or add ?db=<url> to the page's address; images and theme files are then looked up beside that database. A database on another origin must be served with CORS headers (Access-Control-Allow-Origin, and Access-Control-Expose-Headers: Content-Range, ETag). spindle serve sends these, so a page hosted anywhere can play a game from a running authoring server.
How it works
Compiling
spindle build reads config.toml and every content file, checks them (the same checks as spindle check) and writes a SQLite file. It writes to a temporary file and renames it into place, so a server never serves half a build. The same content always compiles to the same bytes.
The file's layout is designed for reading over HTTP:
| Table | Holds | Read |
|---|---|---|
meta |
key/JSON pairs: title, terms, rules, stylesheets, head and footer HTML, game id, build id | whole, at start |
categories, qualities, areas |
everything about them (quality level labels and texts as JSON) | whole, at start |
storylets |
what's needed to list and gate a storylet: area, kind, title, teaser, flags, requirements (JSON) | by index storylets_by_area (area, kind) or storylets_by_id (id) |
storylet_bodies |
the storylet's text, keyed by the storylet's rowid, and for storylets with interactive elements parts: the text and elements in order, as JSON |
when a storylet is opened |
components |
the code of each interactive component | by index components_by_id (id) when a storylet uses one |
branches |
a storylet's branches, with requirements and challenge as JSON | by index branches_by_storylet (storylet, position) when opened |
outcomes |
a branch's outcomes with effects as JSON | by index outcomes_by_branch (branch, kind) when a branch is chosen |
Every table has an INTEGER PRIMARY KEY named n. The file uses 4 KB pages, UTF-8, a rollback journal (never WAL, so everything is in the one file), application_id 0x53504E44 ("SPND"), and a build stamp in user_version: a 31-bit hash of the compiled content.
Reading
web/js/sqlite.js is a read-only reader for the SQLite file format: it parses the header and schema, walks table b-trees by rowid, seeks indexes by key prefix (with SQLite's ordering for NULLs, numbers and text), decodes records and follows overflow pages. It fetches pages with Range requests, in aligned chunks of four pages (the compiler writes each table and index contiguously, so neighbouring pages are usually needed next), and keeps them in memory. Opening a game reads the small tables; an area reads its storylets through the area index; a storylet's text, branches and outcomes are read when needed. In testing, loading the Lamplighter example, naming the character and showing the first area took five requests for 4 KB to 16 KB each.
Interactive elements
A storylet's text can hold fenced ui blocks of YAML that name a component and give it parameters. The compiler splits the text into parts and keeps the code from content/components/*.js in the components table. When a storylet opens, the page reads the components it uses, loads each as a module, and calls it with a context (see web/js/ui.js): the element to draw into, the block's parameters and read access to the player's qualities. A component reports the effects it wants, which the engine applies with the chosen branch's own (engine.choose(save, branchId, { effects })), and whether the branches may be chosen yet. AUTHORING.md documents the format and the context; the starter game's allocate and wick components show both.
New builds, not cached content
The engine never uses content from an old build:
- Before every player action, and when the tab regains focus, it reads the database's first 100 bytes with
cache: "no-store"and compares the build stamp. (spindle servealso has the page check every two seconds; exported sites don't poll unless exported with--poll.) When the stamp changes, it discards everything read so far and opens the new build. - Page requests carry the stamp as
?v=<stamp>, so browser and CDN caches keep builds apart. - When the server gave an
ETag(and the database is on the page's own origin), page requests carryIf-Range. If the file is replaced between the header check and a page read, the server answers with the whole new file instead of a piece of the old one; the engine recognizes the new stamp, abandons the action, opens the new build and retries it. Pages from two builds are never mixed.
Saves
A save is a small JSON object in localStorage: the character's name, area, open storylet, quality values, hand of cards and the budget's state. It's written after every action, so closing the tab loses nothing. When a new build arrives, the save carries over; anything it refers to that no longer exists (an area, a storylet, a card) is dropped. Change id in config.toml to give a game a fresh set of saves.
Players
Several people can play on one browser, each with their own save, without accounts or logins: two siblings on the family computer, say.
- On the first visit the page asks the player's name (the game's
name_prompt) and creates a player. Withask_name: falsein the rules, it creates "Player 1" without asking. - While there's only one player, the page goes straight into their game. With two or more, a new visit asks "Who's playing?", showing each player with a colour of their own, where they are and when they last played. A reload carries on with the tab's player.
- "Switch player" in the footer goes back to the picker, where players can also be added or forgotten. "Start over" resets only the current player, keeping their name.
?player=<name>in the address opens that player's game directly (creating them if needed), so each player can bookmark their own. Names match ignoring case and accents.- "Download save" writes the current player's save to a file; "Load save" adds a file's save as a new player, so it never overwrites anyone. That's how a game moves to another device.
Storage keys are spindle:<game id>:players (the list of players) and spindle:<game id>:save:<player id> (each save), plus spindle:<game id>:player in sessionStorage for the tab's current player. A single save from before players existed becomes the first player. Players are kept apart, not protected: anyone at the keyboard can pick any player, and the saves sit in plain browser storage.
The actions budget
There's no limit on play for now: the budget rule defaults to unlimited, the only budget there is. The hooks for a limit are in place, so one can be added without touching content:
- Branches compile with their
cost(default 1), and the engine asks the game's budget before every choice and every card draw, passes it the cost, and tells it what was spent. - A budget keeps its own state in the save (
save.budget) and reports a status that the page shows as a meter in the header when enabled. - Unknown rules in
rules.mdare compiled as-is, so a budget's settings (say,actions_maxandaction_regen_seconds) can be written into games now.
To add one, implement status, canSpend and spend in web/js/budget.js (the file documents the interface), register it in budgets, add its name to BUDGETS in content.py, and set budget: <name> in a game's rules.md. The engine tests include a throwaway budget that shows the engine enforcing one.
Tests
python3 -m unittest discover -s tests -t . # content, compiler, server, CLI
npm test # SQLite reader, engine, HTTP source, example components (Node 20+)
The JavaScript tests compile the Lamplighter example with Python, then read and play it through the same modules the browser uses.
Code map
| File | Purpose |
|---|---|
spindle/content.py |
reading a project: config, Markdown formats, compact requirement/effect/challenge syntax, checks |
spindle/miniyaml.py |
a small YAML-subset reader for frontmatter |
spindle/compiler.py |
the SQLite schema and compiling |
spindle/server.py |
the authoring server: watch, rebuild, serve with range requests |
spindle/site.py |
static-site export |
spindle/cli.py |
the command line |
spindle/web/js/sqlite.js |
the range-request SQLite reader |
spindle/web/js/content.js |
game content over the reader; rebuild detection |
spindle/web/js/engine.js |
the game rules |
spindle/web/js/budget.js |
budgets (just unlimited for now) |
spindle/web/js/render.js |
text templating and formatting |
spindle/web/js/save.js |
players and their saves in localStorage |
spindle/web/js/app.js |
the player interface |
spindle/web/index.html, base.css |
the page and its base styles |
spindle/starter/ |
the small skeleton game that spindle init copies |
examples/ |
finished example games (see below) |
spindle/AUTHORING.md |
the guide to writing content |