$ 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(). |
items? |
Record<string, ItemDef> |
Item catalogue. See Items + inventory. |
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.pickup(n),drop(n),use(n),inventory(),look()— see Items + inventory.share()— open the share intent; no-ops with a hint pre-finish.getState()—{ sceneId, score, finished, inventory, sceneItems } | 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.
Items + inventory
A small inventory system layers on top of the choice graph. Define items in a top-level items catalogue, place them in scenes via Scene.items, and gate / extend choices with requires / consumes / grants.
createAdventure({
start: 'foyer',
items: {
key: { name: 'brass key', description: 'cold to the touch' },
torch: {
name: 'torch',
onUse: { text: 'You light it. The corridor brightens.', points: 1 }
}
},
scenes: {
foyer: {
heading: 'foyer',
narration: ['A locked door. A key on the table.'],
items: ['key'],
choices: [
{ label: 'Try the door', next: 'foyer' },
{
label: 'Unlock the door',
requires: ['key'], // hidden until the player has the key
consumes: ['key'], // key vanishes on use
next: 'corridor'
}
]
},
corridor: { /* ... */ }
}
});
Five runtime verbs:
pickup(n)— take itemnfrom the current scene.drop(n)— drop itemnfrom your inventory onto the current scene's floor.use(n)— fire itemn'sonUseeffect (flavour text, score delta, scene jump, optional consume).inventory()— list what you're carrying.look()— reprint the current scene (useful after picking up to refresh the visible choice list).
Choices with unmet requires are hidden, not greyed. choose(n) indexes into the visible list, so the numbering the player sees always matches the engine. New items unlocking a choice make it appear at the next visible slot, no renumbering for the user to track.
onUse mirrors choice mechanics on purpose. An item with { onUse: { goTo, points, text, consumed } } is essentially a self-contained choice you carry around. inScenes restricts where it can fire; outside that list, use(n) prints a dim "can't use that here" line.
State + conditional branches
Adventures can track named numeric state variables (a flag is just a variable set to 1/0), mutate them with effects, and route choices conditionally with branches.
createAdventure({
start: 'gate',
initialState: { trust: 0 },
scenes: {
gate: {
heading: 'gate',
narration: ['The guard eyes you.'],
choices: [
{ label: 'Offer a gift', effects: [{ var: 'trust', op: 'add', value: 2 }], next: 'gate' },
{
label: 'Ask to pass',
branches: [
// first branch whose conditions ALL hold wins…
{ when: [{ kind: 'var', var: 'trust', op: '>=', value: 2 }], goTo: 'inside' }
],
next: 'rebuffed' // …otherwise this fallback
}
]
},
inside: { /* ... */ },
rebuffed: { /* ... */ }
}
});
initialState— starting variable values (omitted vars read as0).effectson a choice or an item'sonUse—{ var, op: 'set' | 'add', value }. Choice effects run before the transition resolves, so a branch on the same choice can read a value that choice just set.brancheson a choice — an ordered list of{ when: Condition[], goTo }. The first branch whose conditions all hold (ANDed; emptywhenalways matches) determines the next scene; if none match, the choice's plainnextis the fallback. This is how one choice routes to A when a condition holds and B otherwise.
Condition kinds (the kind discriminator keeps the set extensible):
kind |
shape | tests |
|---|---|---|
hasItem |
{ item, negate? } |
inventory contains (or lacks) an item |
var |
{ var, op, value } |
a state variable vs a constant |
score |
{ op, value } |
running score vs a constant |
visited |
{ scene, negate? } |
player has (or hasn't) been to a scene |
op is one of == != >= <= > <. getState() exposes the live vars and visited arrays alongside inventory so tooling can introspect a run.
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().