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 stages. 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 stage
  • Stage 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 to name.plotknot in the same directory
  • import "path" — uses the literal file path
  • Imported sections are namespaced: characters.tavern, scenes.intro
  • Import directives must appear before any stages
  • 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 -> target with optional [if expr]
  • Target must reference an existing stage 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_var stores the return value
  • Arguments are expressions

If/Else

if expression:
  statements...
else:
  statements...
  • The else branch 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 stage
  • 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, or shuffle
  • 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 variables table 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
  • name is the function identifier
  • param_count is 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 functions manifest 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.ident accesses a character property
  • Reads from the chars table in the runtime (separate from vars)
  • Usable in expressions, conditions, and set targets:
    set barista.friendship = barista.friendship + 1
    if keeper.mood == "happy":
      ...
    
  • In interpolated text, $char inserts the character’s name property

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 stage 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"
  },
  stages = {
    ["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 variables table is populated from declare statements; the runtime seeds its variable store from it at load time
  • Non-literal expressions in set statements are stringified with expr = true; the runtime evaluates them at execution time
  • Dot-access variables (e.g. barista.friendship) compile to chars.barista.friendship expressions; the runtime stores them in a separate chars table
  • Interpolated text uses a parts array instead of a text string; the runtime assembles the final text at display time
  • Tags are attached to text lines as a tags array with name and optional props
  • Dismissible choices carry dismissible = true; the runtime tracks dismissed choices and hides them on revisit
  • Nested choice content appears as a lines array on the choice entry; the runtime executes it before navigating
  • Variation blocks carry a mode string and an items array 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)*