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:

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.

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:

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