Skip to content

PaulNonatomic/console-adventure

Repository files navigation

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().
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.
  • share() — open the share intent; no-ops with a hint pre-finish.
  • getState(){ sceneId, score, finished } | 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.

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

About

A branching choice-based text-adventure engine for the browser console. Pairs with console-shell.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors