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):
- Math:
floor,ceil,round,abs,min,max,clamp,pow - Random:
random,chance,random_float,dice,random_range - String:
upper,lower,contains,length,trim - Type:
int,float,string,bool - Story state:
visited,visited_count,seen
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:
- Unknown function calls produce a warning (via
story:warnings()) and returnnil - Invalid choice indices are ignored
- Goto cycles are detected and broken (with a warning)
- Missing sections produce a warning and halt advancement