$ git show console-adventure

Console Adventure

ACTIVE
year
2026
★ stars
0
stack
TypeScript · console-shell

$ 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.

License: MIT PullRequests Releases CI

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-adventure depends on console-shell for Theme, Logger, DEFAULT_THEME, and the style helpers — installing console-adventure pulls console-shell automatically. 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 from console-adventure, so import { 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 option n (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 } | null for 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 for console-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 item n from the current scene.
  • drop(n) — drop item n from your inventory onto the current scene's floor.
  • use(n) — fire item n's onUse effect (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 as 0).
  • effects on a choice or an item's onUse{ 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.
  • branches on a choice — an ordered list of { when: Condition[], goTo }. The first branch whose conditions all hold (ANDed; empty when always matches) determines the next scene; if none match, the choice's plain next is 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

Buy Me A Coffee


License

MIT © Paul Stamp / Nonatomic Digital Foundry.


See also

★ star · 3 lab · 5 whoami rss

rotate to portrait

we're still polishing landscape · in the meantime, portrait reads best

$ waiting for orientation_change…