Lua for Game Development โ€” Chapter 12: Quests, Missions, Dialogue Trees & Story Progression

A complete system for quests, missions, dialogue trees, branching narrative, objectives, triggers, rewards and story progression using Lua.

Modern games rely heavily on structured progression systems:

  • Main story quests
  • Side quests
  • Dynamic missions
  • Dialogue trees
  • NPC interactions
  • World state & flags
  • Branching narrative
  • Story checkpoints
  • Rewards

Lua is ideal for this:

  • Data-driven quest definitions
  • Easy branching logic
  • Hot reload for dialogue systems
  • Clean JSON-like scripting
  • Integrates perfectly with triggers, inventory, UI, and levels

This chapter introduces a complete quest system used in RPGs, action games, and open-world titles.

1. Quest Data Structure (Data-Driven Design)

Define quests in plain Lua tables.

quests/
    main_01.lua
    side_merchant.lua
    town_rescue.lua
    bounties.lua

1.1 Example Quest Definition

main_01.lua:

return {
  id = "main_01",
  name = "The Journey Begins",
  description = "Speak to the elder in the village.",
  
  objectives = {
    {
      id = "speak_elder",
      type = "talk",
      npc = "elder",
      desc = "Find and talk to the elder.",
    }
  },

  rewards = {
    xp = 50,
    items = {
      {id="potion_small", count=2}
    }
  }
}

Game designers can edit this without touching engine code.

2. Quest Manager

Handles:

  • starting quests
  • tracking progress
  • updating objectives
  • checking completion
  • rewards
  • story state

2.1 QuestManager Class

local QuestManager = {}
QuestManager.__index = QuestManager

function QuestManager.new()
  return setmetatable({
    active = {},
    completed = {},
    flags = {}
  }, QuestManager)
end

2.2 Start a Quest

function QuestManager:start(id, data)
  if self.completed[id] then return end
  self.active[id] = {
    data = data,
    progress = {},
  }
  for _, obj in ipairs(data.objectives) do
    self.active[id].progress[obj.id] = false
  end
end

2.3 Complete Objective

function QuestManager:complete_objective(quest_id, obj_id)
  local q = self.active[quest_id]
  if not q then return end

  q.progress[obj_id] = true

  -- check quest finished
  for _, done in pairs(q.progress) do
    if not done then return end
  end

  self:finish(quest_id)
end

2.4 Finish Quest

function QuestManager:finish(id)
  local q = self.active[id]
  if not q then return end

  -- give rewards
  self:grant_rewards(q.data.rewards)

  -- move to completed
  self.completed[id] = true
  self.active[id] = nil

  -- set story flag
  self.flags[id] = true

  event:emit("quest_completed", id)
end

2.5 Rewards

function QuestManager:grant_rewards(rewards)
  if rewards.xp then
    player:add_xp(rewards.xp)
  end
  if rewards.items then
    for _, it in ipairs(rewards.items) do
      player.inventory:add(it.id, it.count)
    end
  end
end

3. Triggering Quest Progress

Quest objectives typically complete when:

  • Talking to NPC
  • Entering region
  • Killing specific enemy
  • Collecting item
  • Reaching a location
  • Trigger event

Integrate with event bus.

3.1 Example: NPC Dialogue Triggers Objective

event:on("npc_talked", function(npc)
  for id, q in pairs(quest_manager.active) do
    for _, obj in ipairs(q.data.objectives) do
      if obj.type == "talk" and obj.npc == npc then
        quest_manager:complete_objective(id, obj.id)
      end
    end
  end
end)

3.2 Example: Kill Objective

event:on("enemy_killed", function(enemy_type)
  for id, q in pairs(quest_manager.active) do
    for _, obj in ipairs(q.data.objectives) do
      if obj.type == "kill" and obj.enemy == enemy_type then
        quest_manager:complete_objective(id, obj.id)
      end
    end
  end
end)

4. Branching Dialogue Trees (Data-Driven)

Dialogue is best defined using dialogue nodes.

4.1 Example Dialogue Tree

elder_intro.lua:

return {
  start = "hello",

  nodes = {
    hello = {
      text = "Greetings, traveler. What brings you to our village?",
      responses = {
        {text="I'm looking for adventure.", next="adventure"},
        {text="Just passing through.", next="farewell"},
      }
    },

    adventure = {
      text = "Then you must seek the ancient ruins.",
      next = "quest_start"
    },

    quest_start = {
      text = "Take this map. It will guide you.",
      on_enter = function(player)
        quest_manager:start("main_01", require("quests.main_01"))
      end,
      next = "farewell"
    },

    farewell = {
      text = "Safe travels.",
      next = nil
    }
  }
}

4.2 Dialogue Runner

local Dialogue = {}
Dialogue.__index = Dialogue

function Dialogue.new(tree)
  return setmetatable({
    tree = tree,
    current = tree.start
  }, Dialogue)
end

function Dialogue:step(response_index)
  local node = self.tree.nodes[self.current]

  if node.on_enter then
    node.on_enter(player)
  end

  if node.responses then
    -- choose branch
    local r = node.responses[response_index]
    self.current = r.next
  else
    -- auto-next node
    self.current = node.next
  end

  if not self.current then
    return nil
  end

  return self.tree.nodes[self.current]
end

return Dialogue

This fully supports branching, triggers, and story events.

5. Mission Systems (Timed / Dynamic)

Mission types:

  • timed missions
  • escort missions
  • collection missions
  • survival waves
  • stealth detection missions

Lua makes mission logic flexible.

5.1 Timed Mission

mission = {
  time_limit = 60,
  on_fail = function() print("Mission failed!") end,
  on_success = function() print("Mission complete!") end
}

function mission:update(dt)
  self.time_limit = self.time_limit - dt
  if self.time_limit <= 0 then
    self.on_fail()
    return true
  end
end

5.2 Survival Mission

mission = {
  waves = {5,7,10},
  current = 1
}

event:on("enemy_killed", function()
  remaining = remaining - 1
  if remaining <= 0 then
    mission.current = mission.current + 1
    mission:start_wave(mission.current)
  end
end)

6. Story Progression & Flags

Flags mark story progress:

quest_manager.flags["main_01"] = true

Used to:

  • unlock new quests
  • spawn new NPCs
  • change dialogue
  • change world state
  • enable triggers

6.1 Conditional Dialogue

if quest_manager.flags["main_01"] then
  show_dialogue("You have retrieved the map. Good.")
else
  show_dialogue("Find the elder first.")
end

6.2 Conditional Spawns

if quest_manager.flags["boss_intro"] then
  spawner:start_wave("boss")
end

7. Story Checkpoints & Saving

This integrates with the save system from Chapter 9.

7.1 Save Story Progress

save.story = {
  flags = quest_manager.flags,
  completed = quest_manager.completed
}

7.2 Load Story Progress

quest_manager.flags = save.story.flags or {}
quest_manager.completed = save.story.completed or {}

Easy and robust.

8. Putting It All Together โ€” Full Story Flow Example

-- player talks to elder
event:emit("npc_talked", "elder")

-- quest starts
quest_manager:start("main_01", require("quests.main_01"))

-- player walks into ruins -> trigger zone
trigger("enter_ruins", function()
  show_dialogue("These ruins are ancient...")
end)

-- player kills 3 skeletons
event:emit("enemy_killed", "skeleton")
event:emit("enemy_killed", "skeleton")
event:emit("enemy_killed", "skeleton")

-- objective complete!
quest_manager:complete_objective("main_01", "kill_skeletons")

Fully supported by the systems above.

9. Summary of Chapter 12

You now understand:

  • Data-driven quest definitions
  • Multi-objective quest logic
  • Branching dialogue trees
  • NPC interactions
  • Story flags & world state
  • Dynamic missions
  • Trigger-based story systems
  • Quest rewards & progression
  • Save/load integration
  • Narrative scripting using Lua

This is a complete RPG/open-world storytelling system built entirely in Lua.

Keep Reading

Follow the engineering thread

Get the next practical Birdor note, or browse the archive for related systems, tooling, and architecture work.

Join newsletter Browse articles