Runtime API

The plotknot runtime is a pure Lua library that loads compiled story data and drives narrative playback. It has no engine dependencies — it works in Defold, standalone Lua, LuaJIT, or any Lua 5.1+ environment.

Setup

Copy runtime/defold/plotknot.lua into your project. No other files needed.

local plotknot = require("plotknot")
local story_data = require("compiled_story")  -- your compiled .lua file

Loading a story

local story = plotknot.load(story_data)

load validates the data structure and returns a story object. It does not start playback.

Starting playback

story:start()

Positions the story at the start section and evaluates its lines.

Getting current lines

local lines = story:current()

Returns an array of line tables for the current position. Each line has a type field:

Text lines

{ type = "text", text = "Hello, world!" }
{ type = "text", text = "Bonjour!", key = "greeting_001" }
{ type = "text", text = "Welcome.", speaker = "Alice" }
Field Type Description
text string The text to display
key string? Localization key (optional)
speaker string? Character name (optional)
tags array? Inline tags with styling metadata (optional)

Interpolated text

When a text line contains interpolation ($var or ${expr}), the compiled output uses a parts array instead of a plain text string:

{ type = "text", parts = {
  { type = "text", text = "You have " },
  { type = "expr", value = "vars.gold" },
  { type = "text", text = " gold coins." }
}}

The runtime assembles the final text at display time by evaluating each expr part against the current variable store. The resulting line delivered by story:current() always has a plain text field with the assembled string.

Inline tags

Text lines can carry tags for styling metadata:

{ type = "text", text = "Welcome!", tags = {
  { name = "friendly" }
}}
{ type = "text", text = "Hello!", tags = {
  { name = "wave", props = { ["size"] = "2", ["speed"] = "fast" } }
}}
Field Type Description
tags array? Array of tag tables (optional)
tags[].name string Tag name (e.g. "friendly", "wave")
tags[].props table? Key-value properties (optional)

Your game code reads tags to drive animations, audio cues, or visual effects. Tags are metadata only — they don't affect the displayed text.

Choice lines

{ type = "choice", choices = {
  { text = "Go north", target = "north_road" },
  { text = "Go south", target = "south_road", condition = "vars.has_map" },
  { text = "Secret path", target = "secret", dismissible = true },
  { text = "Forest", target = "forest", lines = {
    { type = "set", var = "route", value = "forest" }
  }}
}}
Field Type Description
choices array Available choices
choices[].text string Display text
choices[].target string Section to navigate to
choices[].condition string? Condition expression (optional)
choices[].dismissible bool? If true, choice disappears after being chosen (optional)
choices[].lines array? Nested statements executed when this choice is selected (optional)

The runtime evaluates conditions and only includes passing choices.

Dismissible choices: When a choice has dismissible = true, the runtime tracks it internally. After the player selects it, it is permanently hidden on subsequent visits to the same section. The dismissible flag is passed through to the delivered choice entry so your UI can style it differently (e.g. show a "new" indicator).

Nested choice content: When a choice has a lines array, the runtime executes those statements before navigating to the target section. This runs set statements, processes text, and handles any other line types in the nested content.

Variation lines

{ type = "variation", mode = "cycle", items = {
  { { type = "text", text = "First visit." } },
  { { type = "text", text = "Second visit." } },
  { { type = "text", text = "Third visit." } }
}}
Field Type Description
mode string One of "sequence", "cycle", "once", "pick", "shuffle"
items array Array of statement lists (each item is an array of lines)

The runtime tracks variation state per block. On each encounter, it selects one item based on the mode:

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

The selected item's statements are processed inline — the resulting text, choices, or other lines appear in the output as if they were written directly in the section.

Stop lines

{ type = "stop" }

When the runtime encounters a stop line, it sets an internal stopped flag and halts all further processing. No subsequent lines in the current section are evaluated. Query the stopped state with story:stopped().

Advancing the story

For choices

story:advance(choice_index)

Pass the 1-based index of the player's choice. The story navigates to the target section.

For linear progression

story:advance()

Without an argument, advances past the current lines (follows gotos).

Querying state

local section = story:section()    -- current section ID (string)
local vars = story:variables()     -- variables table (live reference)
local chars = story:chars()        -- character properties table (live reference)
local stopped = story:stopped()    -- true if story was terminated by `stop`
local warnings = story:warnings()  -- runtime warnings (seq of strings)

Character properties

The chars table stores character properties set via dot notation (set barista.friendship = 1). It is separate from the vars table:

local chars = story:chars()
print(chars.barista.friendship)  -- 3
print(chars.keeper.mood)         -- "happy"

Character properties are created on first assignment. The table is a live reference — mutations are visible to the story engine.

Callbacks

Game function calls

Register handlers for call statements:

story:on_call("calculate_damage", function(attack, defense)
  return math.max(1, attack - defense)
end)

story:on_call("play_sound", function(name)
  audio.play(name)
end)

When the story encounters call calculate_damage(10, 5) -> result, the runtime invokes your handler and stores the return value.

Section entry

React to section changes:

story:on_section("boss_room", function()
  music.play("boss_theme")
  camera.shake()
end)

Built-in functions

The runtime provides built-in functions automatically (no registration needed):

New in v0.2.0

Function Signature Description
dice(sides) dice(20) Random integer from 1 to sides
random_range(a, b) random_range(5, 15) Random integer from a to b (inclusive)
seen(section) seen("tavern") Returns true if the section was visited at least once

dice(sides) is equivalent to math.random(1, sides). random_range(a, b) is equivalent to math.random(a, b). Both are recognized by the compiler and available in conditions, set expressions, and interpolated text.

See Extensibility for full signatures and examples.

Custom functions

Register your own functions for use in expressions and calls:

story:register_function("npc_mood", function(name)
  return game_state.npcs[name].mood
end)

Custom functions can be used in conditions:

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

Variation support

The runtime handles variation line types automatically. See Variation lines above for the line format and mode table.

# Crossroads

cycle
  First visit text.
--
  Second visit text.

Complete example

local plotknot = require("plotknot")
local data = require("my_story")

local story = plotknot.load(data)

story:on_call("save_game", function()
  save_system.save(story:variables())
end)

story:on_section("ending", function()
  ui.show_credits()
end)

story:start()

local function show_current()
  local lines = story:current()
  for _, line in ipairs(lines) do
    if line.type == "text" then
      print(line.text)
    elseif line.type == "choice" then
      for i, c in ipairs(line.choices) do
        print(i .. ") " .. c.text)
      end
    end
  end
end

show_current()

-- Player input loop (simplified)
local choice = io.read("*n")
story:advance(choice)
show_current()

Error handling

The runtime is defensive: