Language Guide

This guide teaches the plotknot language by example. Each section introduces a concept with working code you can compile and run.

Sections

Every plotknot document is made of sections — named blocks of narrative content. Sections start with a heading:

# The Beginning

This is the first section. The story starts here.

## The Middle

This is a subsection. The # level doesn't affect behavior,
only organization.

The first section in a file is always the start section. Section IDs are derived from titles: lowercased, spaces become underscores, special characters removed. The Beginning becomes the_beginning.

Text

Plain lines become text shown to the player:

# Opening

The door creaks open. Dust motes float in the pale light.

You step inside.

Each paragraph is delivered as a separate text line to the runtime.

Text interpolation

Embed variable values and expressions directly in narrative text:

# Shop

set gold = 100
set player_name = "Ada"

Welcome, $player_name! You have $gold gold coins.
Your next purchase will leave you with ${gold - 30} coins.

Interpolation also works in choice text:

choice:
- Buy potion for $price gold -> buy [if gold >= price]
- Leave with $gold gold -> exit

The runtime assembles interpolated text at display time, so values always reflect current state.

Inline tags

Attach styling metadata to text lines with inline tags:

# Tavern

<friendly> Welcome to the Prancing Pony!
<whisper volume=low> The barista leans in close.
<shake intensity=3> The ground trembles beneath your feet.

Tags appear at the start of a text line, wrapped in angle brackets. They can carry properties:

<mood=angry> Get out of my shop!
<wave size=2 speed=fast> Hello there, traveler!

Tags are extracted by the compiler and delivered to the runtime as metadata alongside the text. Your game code reads them to drive animations, audio, or visual effects. Escape a literal angle bracket with \<.

Choices

Present the player with branching options:

# Crossroads

The road splits ahead.

choice:
- Go north -> north_road
- Go south -> south_road
- Stay here -> stay

Each choice has display text and a target section. When the player picks a choice, the story jumps to that section.

Dismissible choices

Prefix a choice item with an extra - to make it disappear after being chosen:

# Market Square

A merchant displays her wares.

choice:
-- Ask about the amulet -> amulet_info
-- Ask about the map -> map_info
- Browse silently -> browse

Dismissible choices (marked with --) are shown once. After the player selects one, it never appears again — even if the player returns to this section. Regular choices (single -) always remain available.

Nested choice content

Indent statements under a choice item to execute them when that choice is selected:

# Crossroads

choice:
- Take the forest path -> forest
  set route = "forest"
  You shoulder your pack and head for the trees.
- Take the river path -> river
  set route = "river"
  You follow the sound of running water.

The indented statements run before the story navigates to the target section. This is useful for setting variables, showing transition text, or triggering side effects tied to a specific choice.

Jump to another section without a choice:

# Intro

A loud crash echoes from below.

-> investigate

The -> arrow navigates immediately. The runtime follows goto chains automatically.

Stop

End the story explicitly with the stop keyword:

# Game Over

if health <= 0:
  Your vision fades to black. The adventure ends here.
  stop

You press onward.

When the runtime encounters stop, it halts all further processing. The game code can check story:stopped() to detect that the story has ended and show a game-over screen, credits, or return to the main menu.

Variables

Store and manipulate values:

# Setup

set gold = 100
set name = "Adventurer"
set health = 50

# Shop

choice:
- Buy potion (30 gold) -> buy_potion [if gold >= 30]
- Leave -> exit

## Buy Potion

set gold = gold - 30
set health = health + 25
You drink the potion. Warmth spreads through your limbs.

-> shop_return

Variables persist across sections. Use them in conditions and expressions.

Declarations

Declare variables with initial values using declare:

# Setup

declare score = 0
declare player_name = "Ada"
declare has_key = false
declare gold = 100

Declarations are collected by the compiler and emitted as the variables table in the compiled output. The runtime seeds its variable store from this table at load time. Declared variables suppress "used before being set" warnings. Duplicate declarations are a compile error.

Use declare for variables that need an initial value before any section runs. Use set for assignments that happen during story playback.

Character properties

Access character properties with dot notation:

# Cafe

set barista.friendship = 0

if barista.friendship > 2:
  <friendly> Hey! Good to see you again.
else:
  What'll it be?

choice:
- Chat with the barista -> chat
  set barista.friendship = barista.friendship + 1
- Order coffee -> order

Dot-access expressions like barista.friendship read from and write to a separate character property store (the chars table in the runtime). This keeps character state organized and separate from global variables.

In interpolated text, $char inserts the character's name property:

$barista slides the cup across the counter.

Expressions

Variables support arithmetic and comparison:

set score = score + 10
set ratio = hits / attempts
set is_alive = health > 0
set can_enter = has_key and door_locked

Supported operators: +, -, *, /, ==, !=, <, >, <=, >=, and, or, not.

Built-in functions

The compiler recognizes a set of built-in functions available in any expression:

# Dungeon

set roll = dice(20)
set loot = random_range(5, 15)
set health = min(health + 30, 100)
set depth = max(depth, 1)
set ratio = round(hits / attempts)

if chance(4):
  A critical hit!
Function Description
visited(section) Returns the visit count for a section
visited_count(section) Same as visited()
seen(section) Returns true if the section was visited at least once
dice(sides) Random integer from 1 to sides
random() Random float from 0 to 1
random_range(a, b) Random integer from a to b
chance(n) Returns true with 1-in-n probability
min(a, b) Returns the smaller value
max(a, b) Returns the larger value
round(n) Rounds to the nearest integer
floor(n) Rounds down
ceil(n) Rounds up

These functions work in conditions, set expressions, and interpolated text.

Function declarations

Declare external functions (implemented by the runtime) so the compiler and LSP know about them:

# NPC Interactions

function npc_mood(1)
function calculate_damage(2)
function play_sound(1)

if npc_mood("blacksmith") == "friendly":
  "Good to see you again!"

set damage = calculate_damage(attack, defense)

The syntax is function name(param_count). This tells the compiler:

The runtime provides the actual implementation via story:register_function(name, fn). See Extensibility for the runtime side.

Conditions

Show content dynamically with if/else:

# Throne Room

if has_crown:
  The king bows before you.
else:
  The king barely glances your way.

if gold >= 100:
  A merchant waves you over. "Special prices for the wealthy!"

Conditions can contain any expression. The then/else branches contain statements (text, set, goto, nested if).

Conditional choices

Gate choices behind conditions:

choice:
- Open the door -> door [if has_key]
- Pick the lock -> lockpick [if dexterity > 12]
- Walk away -> leave

The runtime only shows choices whose conditions pass. Choices without conditions always appear.

Game interop

Call functions in your game code and receive results:

# Battle

call calculate_damage(attack, defense) -> damage
set health = health - damage

if health <= 0:
  -> game_over
else:
  -> continue_fight

The call statement invokes a function registered in the runtime. The -> result syntax stores the return value in a variable.

Localization

Tag lines with localization keys for multi-language support:

# Greeting

Hello, traveler! greeting_001

The identifier after the text becomes the localization key. The runtime delivers both the text and the key, so your game can look up translations.

Comments

Document your stories without affecting output:

# Boss Fight

// This section requires the silver sword quest to be complete
// Author note: keep dialogue short here, pacing is critical

if has_silver_sword:
  You raise the gleaming blade.
else:
  Your weapon bounces off its hide. Useless.

Lines starting with // are ignored by the compiler.

Metadata

Attach key-value data to sections:

# Village

mood: peaceful
music: village_theme
time: day

The village square bustles with morning activity.

Metadata appears in the compiled output and can be read by the runtime via the section's metadata table.

Variation blocks

Show different text on repeated visits using variation blocks. Five modes are available:

# The Bridge

cycle
  You cross the creaking bridge for the first time.
--
  The bridge groans under your feet. Again.
--
  You don't even look down anymore.

choice:
- Continue north -> north_bank
- Turn back -> south_bank
Mode Behavior
sequence Play items in order, stick on the last one
cycle Play items in order, loop back to the first
once Play items in order, then produce nothing
pick Choose a random item each time
shuffle Play all items in random order, then reshuffle

Items are separated by -- on its own line. Each item can contain multiple lines and statements:

# Guard Post

sequence
  The guard eyes you suspiciously.
  set suspicion = suspicion + 1
--
  The guard nods. You're a familiar face now.
--
  The guard waves you through without a word.

Variation blocks are a core language feature. See Extensibility for runtime customization.

Imports

Split large stories across multiple files with import:

import characters
import "scenes/intro.plotknot"

# Main Hub

The village square bustles with activity.

choice:
- Visit the tavern -> characters.tavern
- Explore the ruins -> scenes.intro

Imported sections are namespaced by import name. A section tavern in characters.plotknot becomes characters.tavern. This prevents ID collisions between files.

Import directives must appear at the top of the file, before any sections. Circular imports are a compile error.

Putting it together

Here's a complete mini-story using everything:

declare torches = 3
declare depth = 0
declare health = 100

# The Tower

set keeper.friendship = 0

<tense> The tower looms above you. Its entrance gapes like a hungry mouth.

cycle
  You approach for the first time. The stones hum with old magic.
--
  The tower again. The hum is almost familiar now.
--
  You know every crack in these walls.

choice:
- Enter the tower -> enter
  set route = "brave"
- Ask the keeper for advice -> keeper
- Walk away -> leave

## Keeper

set keeper.friendship = keeper.friendship + 1

if keeper.friendship > 1:
  <friendly> $keeper smiles. "Back again? Take this."
  set torches = torches + 1
else:
  The keeper squints at you. "Torches cost extra."

choice:
-- Take the free torch -> enter [if keeper.friendship > 1]
- Enter the tower -> enter
- Walk away -> leave

## Enter

set depth = depth + 1

if torches > 0:
  set torches = torches - 1
  You light a torch and descend. You have $torches left.
else:
  <dark> Darkness swallows you whole.
  -> ending

set roll = dice(20)

if roll <= 5:
  A trap springs! You lose ${10 + depth} health.
  set health = health - (10 + depth)

if health <= 0:
  Your vision fades. The tower claims another soul.
  stop

choice:
- Go deeper -> enter
- Search for treasure -> treasure [if depth >= 3]
- Retreat to the surface -> ending

## Treasure

set loot = random_range(10, 50)
set gold = gold + loot

You find a chest containing $loot gold!

-> enter

## Leave

You turn your back on the tower. Some mysteries are best left alone.

-> ending

## Ending

Your adventure ends here. You reached depth $depth.

stop

This example uses: declare for initial values, cycle variation, inline tags (<tense>, <friendly>, <dark>), text interpolation ($torches, ${10 + depth}), character properties (keeper.friendship), built-in functions (dice(), random_range()), dismissible choices (--), nested choice content, and stop.

Next steps