$ git show console-adventure
Console Adventure
ACTIVE$ git clone
https://github.com/PaulNonatomic/console-adventure.git
✓ cloned
// OVERVIEW
A tiny dependency-light engine for branching choice-based text adventures. Declare a scene graph as a plain object — scenes with headings, narration and labelled choices that point to other scenes — and the engine handles state, scoring, tier resolution, optional share intent and a clean replay loop. Renders into any logger that exposes .log(msg, ...styles) — the browser console by default, but a custom renderer pipes the output into a game UI, a terminal app, or a headless test harness equally well. Built on top of console-shell for theming and style helpers; ships ESM + CJS + types, roughly 5 KB gzipped on its own.
// DETAIL
A tiny dependency-light engine for branching choice-based text adventures. Declare a scene graph as a plain object; the engine handles state, scoring, tier resolution, optional share intent and replay. Renders into any logger — browser console by default, custom renderer for game UIs, terminal apps, or headless tests. Built on top of console-shell.
// README
A branching choice-based text-adventure engine for the browser console.
console-adventure is a tiny engine for choice-based interactive narratives. Built on top of console-shell. You declare a scene graph as a plain object; the engine handles state, scoring, branching, tier resolution, and an optional share intent. The output renders into any logger you supply — the browser console by default, but anything with .log(msg, ...styles) works.
Why
The engine is the same one Nonatomic uses for the dev-console game on nonatomic.co.uk. It started life welded to the console; this package is the cleaned-up, logger-agnostic version. Use it for:
- A dev-tools easter egg (pair with
console-shell) - An in-game narrative UI (give it a custom renderer instead of
console.log) - A terminal app via xterm.js or ink
- Headless interactive-fiction tests
One runtime dependency (console-shell, the shared substrate). ESM + CJS + types, ~5 KB gzipped on its own.
Install
npm install console-adventure
# or
pnpm add console-adventure
# or
yarn add console-adventure
Quick start — standalone
import { createAdventure } from 'console-adventure';
const adventure = createAdventure({
start: 'entrance',
scenes: {
entrance: {
heading: 'You stand at a fork.',
narration: ['Two paths.'],
choices: [
{ label: 'Go left', points: 2, next: 'left' },
{ label: 'Go right', points: 1, next: 'right' }
]
},
left: {
heading: 'A warm hall.',
narration: ['It smells like solder.'],
choices: [
{ label: 'Continue', points: 2, flavour: 'You find the forge.', next: null }
]
},
right: {
heading: 'A cold hall.',
narration: ['Humming.'],
choices: [
{ label: 'Continue', points: 1, flavour: 'You find the forge.', next: null }
]
}
},
tiers: [
{ minScore: 4, label: 'Master', color: 'primary' },
{ minScore: 0, label: 'Apprentice', color: 'dim' }
],
share: {
text: ({ score, tier }) => `Forged ${tier} (${score}/4) at example.com.`,
url: ({ score }) => `https://example.com/foundry?s=${score}`
},
onComplete: ({ score, tier }) => console.log(`done: ${tier} (${score})`)
});
// Drive it directly:
adventure.start();
adventure.choose(1);
adventure.choose(1);
adventure.share(); // opens X intent, only after finish
By default the engine renders into console.log with brand styling. Pass logger: { log(msg, ...styles) } in the config to render somewhere else.
Quick start — plug into console-shell
import { createShell } from 'console-shell';
import { createAdventure } from 'console-adventure';
const game = createAdventure({ start: 'entrance', scenes: { /* ... */ } });
const shell = createShell({
namespace: 'mybrand',
banner: { wordmark: 'M Y B R A N D', hint: 'try mybrand.play()' }
});
shell.attach(game.asShellPlugin()); // → window.mybrand.play(), .choose(n), .share()
shell.install();
When attached, the adventure's theme and logger rebind to the shell's so the combined output reads as one consistent UI. The exposed namespace gets .play() (alias for start()), .choose(n), and .share() if a share: config is present.
Layered on top of console-shell. As of 0.2.0,
console-adventuredepends onconsole-shellforTheme,Logger,DEFAULT_THEME, and the style helpers — installingconsole-adventurepullsconsole-shellautomatically. This deliberately removes the duplication that the earlier 0.1.0 release carried. The two packages still have separate concerns (one is a CLI surface, the other is a narrative engine), but the shared substrate — palette types, log contract, theme defaults — lives in one place. All the shared types are also re-exported fromconsole-adventure, soimport { Theme, DEFAULT_THEME } from 'console-adventure'still works without you touching console-shell directly.
API
createAdventure(config: AdventureConfig): Adventure
| Field | Type | Notes |
|---|---|---|
start |
string |
Scene id where the game begins. Throws if not in scenes. |
scenes |
Record<string, Scene> |
Each Scene = { heading, narration[], choices[] }. |
tiers? |
Tier[] |
{ minScore, label, color? }. Resolver picks the highest qualifying tier. |
share? |
ShareConfig |
Set this to enable share() after finish. |
intro? |
string[] |
Lines printed once at the top of every start(). |
theme? |
Partial<Theme> |
Shallow-merged over DEFAULT_THEME. Overridden by the shell theme when bridged. |
logger? |
{ log(msg, ...styles) } |
Defaults to console. Tests pass a capturing stub. |
onStart? |
() => void |
Fires every start(). No dedupe — that's your job if you want it. |
onComplete? |
(args) => void |
Fires when a null-next choice is selected. Includes {score,max,tier}. |
onShare? |
(args) => void |
Fires when share() is invoked post-finish. |
The returned Adventure exposes:
start()— start (or restart) the game.choose(n)— pick optionn(1-indexed) in the current scene.share()— open the share intent; no-ops with a hint pre-finish.getState()—{ sceneId, score, finished } | nullfor inspection / tests.maxScore— max achievable across all paths (DFS-computed, reconvergence-aware).tierFor(score)— resolve a tier label for a score.asShellPlugin()— return a{ attachTo(shell) }adapter forconsole-shell.
Branching
Each Choice declares its own next: string | null:
choices: [
{ label: 'Take the left door', points: 2, next: 'left' },
{ label: 'Take the right door', points: 2, next: 'right' }
]
Two scenes both pointing to a third reconverge cleanly — the maxScore resolver walks the graph with memoised DFS so reconverging branches aren't double-counted.
Theme
DEFAULT_THEME ships a phosphor-on-void palette (lime + amber + magenta + cyan on near-black). Override any field via theme: in config, or rely on the shell's theme when bridged. Slot names: primary, accent, danger, info, text, dim.
Share intents
The default share() opens https://twitter.com/intent/tweet. Override share.intent to target a different platform — buildMastodonIntent and buildBlueskyIntent are provided:
import { buildMastodonIntent } from 'console-adventure';
createAdventure({
// ...
share: {
text: (a) => `Forged ${a.tier}.`,
url: (a) => `https://example.com/${a.score}`,
intent: (text, url) => buildMastodonIntent(text, url, 'mas.to')
}
});
Analytics
The library is analytics-agnostic. Wire onStart / onComplete / onShare into whatever you use:
createAdventure({
// ...
onComplete: ({ score, tier }) =>
analytics.track('adventure_completed', { score, tier })
});
Hooks fire raw — no built-in dedupe. Wrap with sessionStorage if you want once-per-session-per-event semantics.
Loading an adventure from JSON
If you'd rather author narratives as data than as TypeScript object literals — useful for storing adventures in a CMS, hot-loading user-generated content, or feeding the output of a visual editor — createAdventureFromJson consumes a JSON-shaped config:
import { createAdventureFromJson } from 'console-adventure';
const adventure = createAdventureFromJson(
await fetch('/foundry.json').then((r) => r.json()),
{
// hooks + theme + logger stay code-side, passed as `extras`
onComplete: ({ score, tier }) => analytics.track('done', { score, tier })
}
);
The JSON shape mirrors AdventureConfig with three concessions for serialisability:
{
"$schema": "https://raw.githubusercontent.com/PaulNonatomic/console-adventure/main/adventure.schema.json",
"start": "entrance",
"scenes": { /* same shape as TS — heading, narration, choices */ },
"tiers": [{ "minScore": 8, "label": "Master", "color": "primary" }],
"share": {
"text": "Forged ${tier} (${score}/${max}) at example.com",
"url": "https://example.com/foundry?s=${score}",
"intent": "x"
},
"intro": ["..."]
}
| JSON field | What it becomes at runtime |
|---|---|
share.text |
Function that interpolates ${score} / ${max} / ${tier} |
share.url |
Function that interpolates ${score} / ${tier} |
share.intent |
Preset string: "x" (default), "bluesky", "mastodon", "mastodon:host.tld" |
onStart etc. |
Not in JSON — pass via the extras arg |
theme, logger |
Not in JSON — pass via the extras arg |
A canonical JSON Schema ships at the package root (adventure.schema.json) for IDE autocomplete, validators, and the upcoming console-adventure-studio visual editor.
A working JSON foundry example sits in examples/foundry/foundry.json alongside the TypeScript version.
Standalone vs bridged
The engine works either way:
| Need | Use |
|---|---|
| Game in the browser dev tools, with a banner | This package + console-shell via asShellPlugin() |
| Game in a custom in-page UI | This package standalone, pass a custom logger: |
| Game in a terminal (xterm.js / blessed / ink) | This package standalone, pass a logger that writes to the terminal |
| Game logic only, drive your own renderer | This package standalone, ignore the styles, use getState() |
Examples
A full Foundry-style adventure ships in examples/foundry/ — open index.html in a browser, pop dev tools, type foundry.start().
Dev
npm install
npm test # vitest
npm run typecheck # tsc --noEmit
npm run build # tsup → dist/
Support
If you like my work then please consider showing your support for console-adventure by giving the repo a star or buying me a brew

License
MIT © Paul Stamp / Nonatomic Digital Foundry.
See also
console-shell— the companion CLI shell. Pair them viaasShellPlugin().- nonatomic.co.uk — open dev tools, type
nonatomic.play().