What’s a game without an objective? Probably something, for sure, but my poorly-conceived question has a point (in my head)! Without any sort of goal, a game will lose its draw over time. Even if they’re self-given goals in a sandbox game, you as the player are always trying to accomplish something.
With that philosophical exercise out of the way, let’s get on with the blog post.
Quests
In Descent, each encounter has an objective for both the heroes and the overlord to accomplish. Whichever side accomplishes their goal first is the winner. Pretty standard stuff. We will need a way of defining these goals and then tracking the relevant game actions that will lead to accomplishing that goal in order to determine who wins the game (and when the game should end, for that matter).
For this post I’m going to keep things pretty simple and not try and overcomplicate quests, like I am wont to do with programming things. There are many actions that could define quest goals but we’ll just focus on a simple one today: kill count. How many monsters/heroes are you required to slay before you win? So let’s bust out a new script.
I’m going to name this script “quest.gd” and have it extend Resource
so that we can create and save them within the editor and potentially use them for more than just one scenario.
# quest.gd
extends Resource
# Define a signal that we can emit when the quest has been completed
signal completed
class_name Quest
# Only define one quest type for now
enum QuestType { kill }
# Define the quest type
export(QuestType) var type := QuestType.kill
# How many of the quest type does the player need to do?
export(int) var amount := 0
# Which groups will the targets be a part of?
export(Array) var objective_groups := []
# Keep track of how many [kills] the player has made so far
var _current_count := 0

So we’ve defined the outward-facing variables of the Quest script, but as it stands they’re just numbers. They don’t do anything in and of themselves — that’s where scripting comes in.
For our kill
quest type, we want to be notified whenever a unit dies (we’re not going to worry about how it died or who killed it). When that happens we need to check if the unit is within one of the groups defined by objective_groups
, and if so, to increase the _current_count
and check for quest completion.
func _on_unit_killed(unit: Unit):
print("unit was killed: " + unit.name)
if type != QuestType.kill:
return
var valid := false
for group in objective_groups:
if unit.is_in_group(group):
valid = true
break
if valid:
_update_count(1)
func _update_count(increase_by: int):
_current_count += increase_by
_check_objective_fulfilled()
func _check_objective_fulfilled():
if _current_count >= amount:
emit_signal("completed", self)
And badda-bing badda-boom, there we have it! A simple logic to update the Quest resource and check for completion.
Hooking it up
Of course, we still aren’t using the script anywhere so even if we create a Quest resource and define the fields, the game behavior isn’t going to change. Let’s fix that!
There are a couple of ways we could go about this, but in keeping with the simple-code strategy let’s just make a middle man script that can act as a sort of event emitter hub. We also need to define a generic Game Manager script to keep track of various game states, so I think it makes sense to combine those two for now.
# game_manager.gd
extends Node
var _hero_quest: Quest
var _overlord_quest: Quest
func set_hero_quest(quest: Quest):
_hero_quest = quest
Utils.connect_signal(_hero_quest, "completed", self, "_end_game", [true], CONNECT_ONESHOT)
func set_overlord_quest(quest: Quest):
_overlord_quest = quest
Utils.connect_signal(_overlord_quest, "completed", self, "_end_game", [false], CONNECT_ONESHOT)
func _end_game(quest, was_hero_quest: bool):
var who = "hero" if was_hero_quest else "overlord"
print("Game over, " + who + " player wins!")
We will want this script to be available for all other scripts so we’re going to turn to the tried-and-true AutoLoad feature again! Making this script an AutoLoaded script allows us to access its members and functions within any other script.
So we’ve completed half of this script’s purpose (holding game state), now let’s add some signals to act as a central event relay-er.
The reason we need an event relay is because when scripts want to listen for a certain signal, but that signal is only emitted by dynamic scripts that can be created and destroyed at any time, the would-be listener is unable to determine (easily) when to start and when to stop listening to script signals. By using a middle man event relay we add a level of abstraction to the process, allowing both the emitter and the listener to only care about the single static instance that will always be available.
It might make a little more sense in practice, so let’s do that. First let’s define a signal type in “game_manager.gd” that we know our quest wants to listen for.
signal unit_died(unit)
Then we can hook up both sides in their respective scripts.
# unit.gd
func _update_hp(newValue):
# ... existing code ...
if hp <= 0:
GameManager.emit_signal("unit_died", self)
# quest.gd
func begin_tracking():
_current_count = 0
Utils.connect_signal(GameManager, "unit_died", self, "_on_unit_killed", [])
And just like that, whenever a unit’s HP drops to 0 or below, any active quests will be notified and will run their _on_unit_killed()
function. Lastly we need to make sure that the quest’s begin_tracking()
function gets called, so we can do that in the GameManager
when setting the quests.
func set_hero_quest(quest: Quest):
_hero_quest = quest
Utils.connect_signal(_hero_quest, "completed", self, "_end_game", [true], CONNECT_ONESHOT)
# ADD THIS LINE!
_hero_quest.begin_tracking()
func set_overlord_quest(quest: Quest):
_overlord_quest = quest
Utils.connect_signal(_overlord_quest, "completed", self, "_end_game", [false], CONNECT_ONESHOT)
# ADD THIS LINE!
_overlord_quest.begin_tracking()
Game script
One last thing needs to happen before everything is set up and ready to play, and that is calling the set_hero_quest()
and set_overlord_quest()
functions! Where will those be called?
Up until now my main demo scene has been using a script called “demo_scene.gd.” I’m going to replace that with a new script I’ll just call “game.gd” since I anticipate this one to be more permanent. In that script I’ll create two exported variables for the hero quest and overlord quest respectively. Then in the _ready()
lifecycle hook I’ll call the GameManager
functions to set the quests. To get the game loop started, I’ll kick it off by calling the PhaseManager’s start_hero_turn()
.
extends Node
export(Resource) var hero_quest
export(Resource) var overlord_quest
onready var _phase_manager = $PhaseManager
func _ready():
GameManager.set_hero_quest(hero_quest)
GameManager.set_overlord_quest(overlord_quest)
_phase_manager.call_deferred("start_hero_turn")

This has been a fairly simple example showing how one might go about defining and tracking quest objectives, feel free to expand on the idea! Some things to think about:
- Maybe the heroes & overlord can have more than 1 quest to accomplish each; how would you allow for that?
- What if the quests should be sequential (one has to be completed before another)?
- How would you modify the hero quest to kill a full monster group instead of a single monster?
- Maybe there are optional side quests for each encounter; how would you acquire and accomplish those quests, and what reward would you get?
- Besides kill quests, what other quest types could you add?
The code for this post can be found here.
Leave a Reply