npm.io
0.2.3 • Published yesterdayCLI

@elvishscout/mdstory

Licence
ISC
Version
0.2.3
Deps
12
Size
501 kB
Vulns
0
Weekly
0
Stars
1

English | 中文 | Writing Guide

MdStory

An interactive fiction scripting format based on Markdown and Handlebars.

Online demo: https://mdstory.elvish.cc

Installation

npm install @elvishscout/mdstory

Quick Start

An MdStory file is a Markdown document with three heading levels — # story, ## chapter, ### scene:

---
title: The Crossing
---

# The Crossing

### Crossroads {#start}

A stranger approaches you.

{{input "string" $name="traveler"}}

{{#nav "forest.path"}}Enter the forest{{/nav}}
{{#nav "river.bridge"}}Cross the bridge{{/nav}}

## Forest {#forest}

### Deep Woods {#path}

You walk among ancient trees, {{name}}.

The forest whispers your name.

{{#nav "start"}}Turn back{{/nav}}
{{#nav null}}Rest here forever{{/nav}}

## River {#river}

### Old Bridge {#bridge}

The wooden bridge creaks under your weight, {{name}}.

On the far side, you see a light.

{{#nav "start"}}Go back{{/nav}}
{{#nav null}}Cross into the light{{/nav}}

Save as story.md and run:

npx mdstory play story.md

Or build a standalone HTML file:

npx mdstory build story.md

CLI

# Play a story in the terminal
npx mdstory play my-story.md

# Play with debug output
npx mdstory play my-story.md --debug

# Build a standalone HTML file and open in browser
npx mdstory build my-story.md

# Build to a specific output path
npx mdstory build my-story.md -o dist/story.html

# Build without opening browser
npx mdstory build my-story.md --no-open

# Build with debug output in browser console
npx mdstory build my-story.md --debug

# Install MdStory writing skills to coding agents
npx mdstory skills

# Install to specific agents (non-interactive)
npx mdstory skills --agent claude
npx mdstory skills --agent claude --agent codex

# Install to a custom directory
npx mdstory skills --dir ./my-skills

# Skip confirmation prompt
npx mdstory skills --agent claude --yes

Skills

After installing skills, use /mdstory-write in your coding agent to create interactive stories:

/mdstory-write Write a mystery set in an abandoned space station. The player discovers clues about what happened to the crew. Include 3 chapters with multiple endings.

The agent will design the structure, write each chapter, run validation checks, and deliver a complete playable story.

Guide

Story Structure
Level Heading Purpose
# Story title Optional. Holds story-level <script> hooks.
## Chapter Groups scenes. Has its own hooks and locals.
### Scene A renderable unit with a Handlebars template.

Frontmatter — YAML at the top of the file sets static metadata and initial globals:

---
title: My Story
globals:
  name: Alice
---

If no # heading is present, the story title comes from metadata title, or is empty. Scenes before any ## are grouped into an implicit default chapter.

Story & chapter templates — content between # and the first ##/### renders once at story start. Content between ## and its first ### renders once when entering that chapter.

# The Dungeon

_You open a dusty tome..._

## Chapter One {#ch1}

_The air grows cold as you descend._

### The Entrance {#entrance}

You stand before a massive iron door.
Navigation

Use {{#nav target}}label{{/nav}} to move between scenes:

{{#nav "forest"}} Go back to the forest {{/nav}} ← same chapter
{{#nav "chap2.cave"}} Enter the cave {{/nav}} ← cross-chapter
{{#nav "chap2"}} Go to Chapter 2 {{/nav}} ← chapter entry scene
{{#nav null}} The end {{/nav}} ← end story
Input & Variables

input does not pause the story where it appears. When the reader leaves the scene, all inputs are submitted together with the chosen navigation target.

Inputs write to chapter locals by default. Prefix the variable name with $ to write to globals:

{{input "string"  name="Alice"}} ← local text input
{{input "number"  age=30}} ← local number input
{{input "boolean" brave=true}} ← local checkbox
{{input "string"  $name="Alice"}} ← global text input

Reference variables in templates with {{name}}.

Conditionals

Use {{#if}} for branching:

{{#if hasKey}}
You unlock the door.
{{else}}
The door is locked.
{{/if}}
  • Globals — persist across the whole story. Set via frontmatter, $-prefixed inputs, or hook side effects.
  • Locals — reset and re-computed each time a chapter is entered. Set via locals().
  • View values — computed on each scene render. Set via view(). Read-only display helpers.
Hooks

Hooks are JavaScript functions exported from <script> tags. Multiple <script> tags per scope are merged — later exports take precedence for same-named hooks.

Scope Hook Signature Purpose
Story globals () Return initial global variables
Story onStart ({ globals }) Side effect when story begins
Chapter locals ({ globals }) Return chapter-local variables
Chapter onEnter ({ globals, locals }) Side effect when entering chapter
Chapter onLeave ({ globals, locals, target }) Side effect when leaving chapter
Scene view ({ globals, locals }) Return render-only values
Scene onEnter ({ globals, locals }) Side effect when entering scene
Scene onLeave ({ globals, locals, target }) Side effect when leaving scene

Hooks with return values support both sync and async. globals() receives no arguments. view() receives the current scopes; its return value is only used for the current render.

Example — chapter locals with a counter:

## The Dungeon {#dungeon}

<script>
  let attempts = 0;
  export default {
    locals() {
      attempts++;
      return { attempt: attempts };
    },
  };
</script>

### First Room {#room}

This is your {{attempt}}th attempt.

Example — scene view hook for conditional display:

### Treasure Chest {#chest}

<script>
  export default {
    view({ globals }) {
      const opened = globals.chestOpened || false;
      return { alreadyOpened: opened, coins: opened ? 0 : 50 };
    },
    onLeave({ globals }) {
      globals.chestOpened = true;
    },
  };
</script>

{{#if alreadyOpened}}
The chest is empty.
{{else}}
You found {{coins}} gold pieces!
{{/if}}
Assets & Styles

Reference assets defined in YAML metadata:

assets:
  map: "https://example.com/map.png"
  bgm: { url: "https://example.com/audio.mp3", mime: "audio/mpeg" }
![]({asset "map"})
{{asset "bgm"}} → outputs the URL
{{mime "bgm"}} → outputs "audio/mpeg"

Add CSS with a <style> tag under the story heading:

<style>
  .clue {
    color: #ffd700;
  }
</style>
File Includes

Use !include("target") to splice another Markdown source before parsing:

!include("./chapter-1.md")
!include("/stories/common.md")
!include("https://example.com/shared.md")

Includes are resolved relative to the file that contains them. For custom include loading, pass base or resolveInclude to fromPath(), fromSource(), or parseStorySource().

Line Breaks
{{linebreak}} ← one blank line
{{linebreak 3}} ← three blank lines

Produces <br> in both terminal and HTML output.

More Resources