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.
$identinserts the value of a variable${expr}inserts the result of any expression$$produces a literal dollar sign
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.
Navigation
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 function exists (no "unknown function" warning)
- How many parameters it takes (arg count is validated)
- The LSP autocompletes it
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
import nameresolves toname.plotknotin the same directoryimport "path"uses the literal file path
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
- Language Reference — formal grammar and all keywords
- Runtime API — Lua runtime, line types, and state queries
- Examples — complete example stories