Extensibility
Plotknot has a fixed language and a fixed compiler. All customization happens at runtime through the Lua runtime library.
Why no compiler plugins?
The compiler’s job is: parse → validate → emit data. The plotknot language is defined by its grammar. New syntax requires a compiler change — the compiler is small enough (~1500 lines of Nim) to fork if you need custom syntax.
This is the same model used by Yarn Spinner and Loreline: fixed language, extensible runtime.
Runtime extension points
Game function calls (on_call)
Handle call statements in your game code:
story:on_call("play_sound", function(name)
audio.play(name)
end)
story:on_call("calculate_damage", function(attack, defense)
return math.max(1, attack - defense)
end)
When the story encounters call play_sound("explosion"), the runtime invokes your handler. If the call has -> result, the return value is stored in a variable.
Custom functions (register_function)
Add functions usable in expressions and conditions:
story:register_function("npc_mood", function(name)
return game_state.npcs[name].mood
end)
Then in plotknot scripts:
if npc_mood("blacksmith") == "friendly":
"Good to see you again!"
set best_price = min(price, npc_mood("merchant"))
Section entry (on_stage)
React to stage changes for side effects like music, animations, or UI updates:
story:on_stage("boss_room", function()
music.play("boss_theme")
camera.shake()
end)
story:on_stage("ending", function()
ui.show_credits()
end)
The handler fires when the story enters the named stage. Use it for side effects — not for game logic that affects story state (use declare/set in the script for that).
Custom line types (on_line_type)
Handle custom line types in the compiled output:
story:on_line_type("flashback", function(line, story)
ui.show_flashback(line.scene)
return { { type = "text", text = "A memory surfaces..." } }
end)
story:on_line_type("shake", function(line, story)
camera.shake(line.intensity or 1)
end)
The handler receives the full line table and the story instance. It may return an array of display items to add to the output. If the handler raises an error, the runtime catches it and produces a warning.
Custom line types don’t require compiler changes. They appear in the compiled output as {type = "your_type", ...} tables. You can produce them by post-processing the compiler’s Lua output or writing a tool that generates plotknot-compatible data.
Built-in functions
The runtime provides these functions automatically (no registration needed):
| Category | Functions |
|---|---|
| Math | floor, ceil, round, abs, min, max, clamp, pow |
| Random | random, chance, random_float, dice, random_range |
| String | string_upper, string_lower, string_contains, string_length, string_trim |
| Type | int, float, string, bool |
| Story | visited, visited_count |
Compiled output format
The compiler produces a Lua table with start, variables, and stages. Each stage has title, metadata, and lines. Line types include: text, choice, set, call, if, goto, variation, stop. Unknown types dispatch to on_line_type handlers.
See Runtime API for the full line type reference.
Common patterns
Quest tracking via on_call
Use call statements to update quest state from the story:
// In the story
call accept_quest("crypt", 500)
call complete_quest("crypt")
-- In game code
story:on_call("accept_quest", function(name, reward)
quest_log.add(name, reward)
ui.show_notification("Quest accepted: " .. name)
end)
story:on_call("complete_quest", function(name)
quest_log.complete(name)
ui.show_notification("Quest complete: " .. name)
end)
Dialogue styling via on_line_type
Use inline tags in the story and handle them as custom line types:
// In the story
<whisper volume=low> The secret is under the floorboards.
<shake intensity=3> The ground trembles.
-- In game code
story:on_line_type("whisper", function(line, story)
audio.set_volume("dialogue", 0.3)
return { { type = "text", text = line.text } }
end)
story:on_line_type("shake", function(line, story)
camera.shake(line.intensity or 1)
end)
Audio triggers via on_stage
story:on_stage("tavern", function()
music.play("tavern_theme")
end)
story:on_stage("crypt", function()
music.play("crypt_ambient")
audio.play("dripping_water")
end)
Save/load via the variables table
The variables() and chars() methods return live references. Serialize them for save/load:
-- Save
local state = {
section = story:stage(),
vars = story:variables(),
chars = story:chars(),
}
save_to_disk(state)
-- Load
local state = load_from_disk()
local vars = story:variables()
for k, v in pairs(state.vars) do vars[k] = v end
local chars = story:chars()
for k, v in pairs(state.chars) do chars[k] = v end
story:goto_section(state.section)
Choosing the right hook
| I want to… | Use |
|---|---|
| Run game code when the story says so (play a sound, spawn an enemy) | on_call |
| Return a value the story can use in conditions or expressions | register_function |
| React to entering a stage (music, camera, UI) | on_stage |
| Handle a custom line type in the compiled output | on_line_type |
Add a function the story can call in if conditions |
register_function |
| Trigger a one-shot effect with no return value | on_call (no -> result) |
| Trigger a one-shot effect with a return value | on_call (with -> result) |
Rule of thumb: if the story needs a value back, use register_function or on_call with -> result. If the story just triggers something, use on_call without a result or on_stage. If you need to handle a custom data shape in the compiled output, use on_line_type.
Next steps
- Runtime API — full Lua runtime reference
- Defold Integration — Defold-specific patterns
- Best Practices — runtime integration conventions