Language Reference
Formal reference for the plotknot language. For a tutorial-style introduction, see the Language Guide.
Document structure
A plotknot document is a sequence of sections. Each section begins with a heading and contains statements.
# Section Title
statements...
## Another Section
statements...
#and##both define sections (level has no semantic effect)- The first section is the start section
- Section IDs are derived from titles: lowercase, spaces to underscores, non-alphanumeric removed
Imports
Stories can be split across multiple files:
import characters
import "scenes/intro.plotknot"
import name— resolves toname.plotknotin the same directoryimport "path"— uses the literal file path- Imported sections are namespaced:
characters.tavern,scenes.intro - Import directives must appear before any sections
- Circular imports are a compile error
Statements
Text
Any line that doesn't start with a keyword or special character:
This is shown to the player.
Text lines can carry a localization key (a trailing identifier):
Hello, world! greeting_key
Interpolation
Text supports variable and expression interpolation:
Hello $name!
Your score is ${score + 10} points.
$ident— inserts the value of a variable${expr}— inserts the result of an expression$$— literal dollar sign\$— literal dollar sign (alternative escape)
Interpolation is also valid in choice text.
Inline tags
Text lines support inline tags for styling metadata:
<friendly> Welcome to the shop!
<wave size=2> Hello there!
<mood=angry> Grr!
- Tags appear at the start of a text line
- Tags can carry properties:
<tag prop=value> - Multiple tags can appear on one line
\<escapes a literal angle bracket- Tags are extracted and delivered to the runtime as metadata; the displayed text has tags stripped
Choice block
choice:
- Display text -> target_section
- Conditional text -> target [if expression]
-- Dismissible text -> target
- Nested text -> target
set x = 1
Transition text here.
- Requires at least one choice item
- Each item:
- text -> targetwith optional[if expr] - Target must reference an existing section ID
--prefix marks a choice as dismissible: it disappears after being chosen- Indented statements under a choice item form nested content, executed when that choice is selected (before navigating to the target)
Set (variable assignment)
set variable = expression
- Variable names: identifiers (
[a-zA-Z_][a-zA-Z0-9_]*) - Assignment uses
=(not==) - Expression can reference other variables and function calls
Call (game interop)
call function_name(arg1, arg2) -> result_var
call function_name(arg1)
- Invokes a function registered in the runtime
- Optional
-> result_varstores the return value - Arguments are expressions
If/Else
if expression:
statements...
else:
statements...
- The
elsebranch is optional - Branches can contain any statement (text, set, call, goto, nested if)
- Indentation defines the block (consistent with Python-style)
Goto (navigation)
-> target_section
- Immediately navigates to the target section
- The runtime follows chains of gotos automatically
Comment
// This is ignored by the compiler
Variation block
cycle
First visit text.
--
Second visit text.
--
Third and later visit text.
- Mode keyword:
sequence,cycle,once,pick, orshuffle - Items separated by
--on its own line - Each item can contain multiple lines and statements
- The runtime tracks visit state per variation block
| Mode | Behavior |
|---|---|
sequence |
Play items in order, stick on last |
cycle |
Play items in order, loop back to 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 |
Declare (variable declaration)
declare score = 0
declare name = "Ada"
declare has_key = false
- Declares a variable with an initial value
- Collected at document level and emitted as the
variablestable in compiled output - The runtime seeds its variable store from this table at load time
- Duplicate declarations are a compile error
- Suppresses "used before being set" warnings
Stop (story termination)
stop
- Explicitly terminates the story
- The runtime halts all further processing
- Queryable via
story:stopped()in the runtime API
Function declaration (external functions)
function name(param_count)
- Declares an external function implemented by the runtime
nameis the function identifierparam_countis an integer specifying the expected number of arguments- Suppresses "unknown function" warnings for calls to this function
- Validates argument count at compile time
- Appears in LSP autocompletion
- Emitted as a
functionsmanifest in the compiled output
Expressions
Precedence (lowest to highest)
| Level | Operators | Associativity |
|---|---|---|
| 1 | or |
left |
| 2 | and |
left |
| 3 | not |
right (unary) |
| 4 | == != < > <= >= |
left |
| 5 | + - |
left |
| 6 | * / |
left |
| 7 | primary (literals, identifiers, calls, parens) | — |
Literals
| Type | Examples |
|---|---|
| Integer | 42, 0, -1 |
| Float | 3.14, 0.5 |
| String | "hello", "world" |
| Boolean | true, false |
Identifiers
Variable references: gold, player_name, is_alive.
Dot access (character properties)
barista.friendship
keeper.mood
ident.identaccesses a character property- Reads from the
charstable in the runtime (separate fromvars) - Usable in expressions, conditions, and
settargets:
set barista.friendship = barista.friendship + 1
if keeper.mood == "happy":
...
- In interpolated text,
$charinserts the character'snameproperty
Function calls
function_name(arg1, arg2, ...)
Functions can appear in any expression position.
Grouping
Parentheses override precedence:
set result = (a + b) * c
Metadata
Key-value pairs at the top of a section body (before any statements):
# Section
key: value
another_key: another value
First text line here.
Metadata is included in the compiled output as a table.
Keywords
| Keyword | Context | Purpose |
|---|---|---|
choice: |
statement | Begin a choice block |
set |
statement | Assign a variable |
call |
statement | Invoke a game function |
if |
statement/expression | Conditional branch |
else |
statement | Alternative branch |
declare |
statement | Declare a variable with an initial value |
stop |
statement | Explicitly terminate the story |
function |
statement | Declare an external function (runtime-provided) |
import |
top-level | Import sections from another file |
sequence |
statement | Variation mode: play in order, stick on last |
cycle |
statement | Variation mode: play in order, loop |
once |
statement | Variation mode: play in order, then nothing |
pick |
statement | Variation mode: random item each time |
shuffle |
statement | Variation mode: random order, reshuffle |
and |
expression | Logical conjunction |
or |
expression | Logical disjunction |
not |
expression | Logical negation |
true |
expression | Boolean literal |
false |
expression | Boolean literal |
Special tokens
| Token | Purpose |
|---|---|
# / ## |
Section heading |
-> |
Navigation / choice target / call result |
- |
Choice item marker |
-- |
Dismissible choice marker / variation item separator |
[if ...] |
Inline choice condition |
// |
Comment |
: |
Metadata separator / block opener |
$ident |
Variable interpolation in text |
${expr} |
Expression interpolation in text |
$$ |
Literal dollar sign |
<tag> |
Inline text tag |
<tag prop=val> |
Inline text tag with property |
. |
Character property access (char.prop) |
Compiled output format
The compiler produces a Lua table:
return {
start = "section_id",
variables = {
["score"] = 0,
["name"] = "Ada"
},
sections = {
["section_id"] = {
title = "Section Title",
metadata = { ["key"] = "value" },
lines = {
-- Plain text
{ type = "text", text = "Hello" },
{ type = "text", text = "Bonjour!", key = "greeting_001" },
-- Interpolated text (parts array)
{ type = "text", parts = {
{ type = "text", text = "You have " },
{ type = "expr", value = "vars.gold" },
{ type = "text", text = " gold." }
}},
-- Text with inline tags
{ type = "text", text = "Welcome!", tags = {
{ name = "friendly" }
}},
{ type = "text", text = "Hello!", tags = {
{ name = "wave", props = { ["size"] = "2" } }
}},
-- Choice block (with dismissible and nested content)
{ type = "choice", choices = {
{ text = "Go", target = "other_section" },
{ text = "Secret", target = "secret", dismissible = true },
{ text = "Path", target = "path", lines = {
{ type = "set", var = "route", value = "forest" }
}}
}},
-- Variable assignment
{ type = "set", var = "x", value = 5 },
{ type = "set", var = "y", value = "vars.x + 1", expr = true },
-- Character property assignment (dot access)
{ type = "set", var = "barista.friendship",
value = "chars.barista.friendship + 1", expr = true },
-- Game interop call
{ type = "call", name = "fn", args = {1, 2}, result = "res" },
-- Conditional block
{ type = "if", condition = "vars.x > 3",
then_lines = { ... }, else_lines = { ... } },
-- Navigation
{ type = "goto", target = "other_section" },
-- Variation block
{ type = "variation", mode = "cycle", items = {
{ { type = "text", text = "First visit." } },
{ { type = "text", text = "Second visit." } }
}},
-- Story termination
{ type = "stop" }
}
}
}
}
Key notes:
- The
variablestable is populated fromdeclarestatements; the runtime seeds its variable store from it at load time - Non-literal expressions in
setstatements are stringified withexpr = true; the runtime evaluates them at execution time - Dot-access variables (e.g.
barista.friendship) compile tochars.barista.friendshipexpressions; the runtime stores them in a separatecharstable - Interpolated text uses a
partsarray instead of atextstring; the runtime assembles the final text at display time - Tags are attached to text lines as a
tagsarray withnameand optionalprops - Dismissible choices carry
dismissible = true; the runtime tracks dismissed choices and hides them on revisit - Nested choice content appears as a
linesarray on the choice entry; the runtime executes it before navigating - Variation blocks carry a
modestring and anitemsarray of statement lists
Grammar
document = import* section+
import = 'import' (IDENT | STRING) NEWLINE
section = heading metadata* stmt*
heading = ('#' | '##') TEXT NEWLINE
metadata = IDENT ':' TEXT NEWLINE
stmt = text | choice_block | set_stmt | call_stmt | if_block | goto
| variation | declare_stmt | stop_stmt
text = (TEXT | interp | tag)* ('#' IDENT)? NEWLINE
interp = '$' IDENT | '${' expr '}'
tag = '<' IDENT ('=' value)? '>'
choice_block = 'choice' ':' NEWLINE choice+
choice = ('-' | '--') ('[if' expr ']')? text '->' IDENT NEWLINE stmt*
set_stmt = 'set' (IDENT | IDENT '.' IDENT) '=' expr NEWLINE
call_stmt = 'call' IDENT '(' args? ')' ('->' IDENT)? NEWLINE
if_block = 'if' expr ':' NEWLINE stmt* ('else' ':' NEWLINE stmt*)?
goto = '->' IDENT NEWLINE
variation = ('sequence' | 'cycle' | 'once' | 'pick' | 'shuffle') NEWLINE
(stmt+ ('--' NEWLINE stmt+)*)?
declare_stmt = 'declare' IDENT '=' expr NEWLINE
stop_stmt = 'stop' NEWLINE
expr = or_expr
or_expr = and_expr ('or' and_expr)*
and_expr = not_expr ('and' not_expr)*
not_expr = 'not' not_expr | comparison
comparison = primary (('==' | '!=' | '<' | '>' | '<=' | '>=') primary)?
primary = IDENT | IDENT '.' IDENT | STRING | INT | FLOAT | BOOL
| '(' expr ')' | call_expr
call_expr = IDENT '(' args? ')'
args = expr (',' expr)*