Defold Integration

Reference for integrating plotknot into a Defold project. For a step-by-step walkthrough, see the Defold Integration Tutorial.

Project structure

A typical Defold + plotknot project:

my_game/
  main/
    plotknot.lua          -- runtime library (copy from runtime/defold/)
    stories/
      main.plotknot       -- source stories
    compiled/
      main.lua            -- compiled output (generated)
    dialogue.script       -- Defold script driving the story
    dialogue.gui          -- GUI with text label + choice buttons
    dialogue.gui_script   -- GUI script handling button input

Compiling stories

Compile during development:

plotknot compile main/stories/main.plotknot --output main/compiled/

Or use watch mode to recompile on save:

plotknot watch main/stories/main.plotknot --output main/compiled/

The compiled .lua file is a Defold-compatible module. Add it to your game.project dependencies or require it directly.

Loading compiled data

local plotknot = require("main.plotknot")
local story_data = require("main.compiled.main")

local story = plotknot.load(story_data)

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

Driving the story from a script

local plotknot = require("main.plotknot")
local story_data = require("main.compiled.main")

function init(self)
  self.story = plotknot.load(story_data)

  self.story:on_call("play_sound", function(name)
    msg.post("/sound#script", "play", { sound = name })
  end)

  self.story:on_stage("boss_room", function()
    msg.post("/music#script", "play_track", { track = "boss" })
  end)

  self.story:start()
  show_current(self)
end

function show_current(self)
  local lines = self.story:current()
  for _, line in ipairs(lines) do
    if line.type == "text" then
      gui.set_text(gui.get_node("story_text"), line.text)
    elseif line.type == "choice" then
      for i, choice in ipairs(line.choices) do
        local btn = gui.get_node("choice_" .. i)
        gui.set_text(btn, choice.text)
        gui.set_enabled(btn, true)
      end
      -- Hide unused buttons
      for i = #line.choices + 1, 4 do
        gui.set_enabled(gui.get_node("choice_" .. i), false)
      end
    end
  end
end

function on_message(self, message_id, message, sender)
  if message_id == hash("choice_pressed") then
    self.story:advance(message.index)
    show_current(self)
  end
end

GUI choice buttons

Connect GUI buttons to the story script:

-- dialogue.gui_script
function on_input(self, action_id, action)
  if action_id == hash("touch") and action.pressed then
    for i = 1, 4 do
      local btn = gui.get_node("choice_" .. i)
      if gui.is_enabled(btn) and gui.pick_node(btn, action.x, action.y) then
        msg.post("/dialogue#script", "choice_pressed", { index = i })
        return true
      end
    end
  end
end

Saving and loading story state

The story’s state lives in two tables: variables and character properties.

-- Save
local function save_story(story)
  local state = {
    section = story:stage(),
    vars = story:variables(),
    chars = story:chars(),
  }
  -- Serialize to JSON or Defold's sys.serialize
  local path = sys.get_save_file("game", "story_save")
  sys.save(path, state)
end

-- Load
local function load_story(story)
  local path = sys.get_save_file("game", "story_save")
  local state = sys.load(path)
  if state then
    -- Restore variables (live reference — mutations apply directly)
    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
    -- Navigate to saved section
    story:goto_section(state.section)
  end
end

The variables() and chars() methods return live references. Mutations are visible to the story engine immediately.

Common patterns

Dialogue trigger from collision

Start a conversation when the player enters a trigger zone:

-- player.script
function on_message(self, message_id, message, sender)
  if message_id == hash("trigger_response") and message.enter then
    if message.other_group == hash("npc") then
      msg.post("/dialogue#script", "start_story", { npc = "blacksmith" })
    end
  end
end

Quest state tracking

Use story variables to track quest progress, then read them from game code:

// In the story
set quest.crypt_accepted = true
set quest.crypt_reward = 500
-- In game code
local vars = story:variables()
if vars.quest and vars.quest.crypt_accepted then
  -- Update quest log UI
end

Conditional NPC dialogue

Register a function that reads game state:

story:register_function("npc_mood", function(name)
  local npc = game_state.npcs[name]
  if not npc then return "unknown" end
  return npc.mood
end)
// In the story
if npc_mood("blacksmith") == "friendly":
  <friendly> Good to see you again! What can I forge for you?
else:
  The blacksmith eyes you warily. "What do you want?"

Audio triggers via on_stage

story:on_stage("tavern", function()
  msg.post("/music#script", "play_track", { track = "tavern_theme" })
end)

story:on_stage("crypt", function()
  msg.post("/music#script", "play_track", { track = "crypt_ambient" })
end)

Error handling

The runtime is defensive:

  • Unknown function calls produce a warning and return nil
  • Invalid choice indices are ignored
  • Goto cycles are detected and broken (with a warning)
  • Missing sections produce a warning and halt advancement

Check story:warnings() during development to catch integration issues early.

Next steps