Lua for Game Development — Chapter 12: Quests, Missions, Dialogue Trees & Story Progression
Leeting Yan
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.