Technical Design of a Quest System (Part 2)
This is the second part in a series where I discuss the technical implementation of Starcom's mission system. You can read the first part here.
Last week I explained how the mission system works overall. As a quick recap, several times a second the MissionManager iterates over all active missions. Each mission has one or more "lanes" that define a sequence of nodes, where each node is a set of conditions to wait for, then a set of actions to execute when all conditions are satisfied. Once a node is executed, the lane advances to the next node.
So what is actually inside these "MissionCondition" and "MissionAction" objects?
[h2]Mission Conditions[/h2]
Conditions inherit from the abstract "MissionCondition" class, shown here with some static utility methods removed for clarity. It is quite simple:
You may have wondered in the earlier screenshots from the tool how the nodes had plain English descriptions of what they did. Each subclass is responsible for providing a human-readable description of their behavior. So the tool can just call "condition.Description" when drawing the little node boxes and doesn't need to know what kind of condition it is.
Apart from that, the class defines an abstract method IsSatisfied, which all concrete implementations will need to define.
Let's look at an example:

The Cargo Spill mission waits for the player to be within 250 units of a particular ship.
This is handled by a PlayerProximityCondition:
Notice that the public properties of the method are marked with attributes like "EditInput". These serve a similar purpose to Unity's editor attributes: at design time, the tool uses Reflection to create an appropriate form field for editing, and can validate values accordingly:

I won't go into detail here as to how this reflection edit system works since it's a very big topic. And I would strongly recommend developers implementing their first quest/mission system to leverage existing tools rather than try to build something this bespoke. I had a clear set of requirements from having already developed Starcom: Nexus (in which I used a Unity plug-in called xNode for the same effect) and as a result had a clearer roadmap as to what I needed to accomplish with the mission system.
The important thing when defining Conditions is to choose the correct level of abstraction. The goal is to make conditions that are granular enough that they can be reused in different combinations to handle different scenarios, while at the same time providing enough abstraction that the mission designer (in my case, also me) doesn't need to be thinking about the underlying engine-level logic. Generally speaking, conditions are the translation of a task as the player understands it into the language of the game's logic. E.g., the player might think (or have been explicitly told) "I need to talk to the Ermyr", so we need a condition that can detect whether the dialogue window is open and the active actor is part of the Ermyr faction. Having the "active actor" be an editable variable means that this condition can be reused for any actor.
There are a little under 100 different Condition classes, but most missions rely on only about two dozen common conditions.
Examples of commonly used conditions:
[h2]Mission Actions[/h2]
As you might guess, Mission Actions work similarly to conditions, except for rather than returning a boolean based on the current state of the game, they execute code effecting some change.
The abstract Mission Action base class:
A simple, specific example is having the first officer "say" something (command crew members and other actors can also notify the player via the same UI, but the first officer's comments may also contain non-diegetic information like controls):
I chose this action as an example because it's: a) simple, b) shows how and why an action might "block," and c) gives a clue how the mission system handles the challenge of localization:
Every part of the game that potentially can show text needs some way of identifying at localization time what text it can show and then at play time display the text in the user's preferred language.
In the mission system, any MissionAction that can "emit" text is expected to implement ILocalizableMissionAction. During localization, I can push a button that scans all mission nodes for actions that implement that interface and gets a list of any "Symbol Pairs". Symbol pairs consist of a string that uniquely identifies some text and its default (English) value. The "prefixChain" is built from the mission id and a unique identifier for the node. This is necessary because we want every localization symbol to have a unique symbol id. In the above example, the full localization symbol is "ALPHA->MISSIONS->CARGO_SPILL->NODES->1->FIRST_OFFICER->MESSAGE".
There are currently 140 different MissionAction classes, although some of these are only used during automated tests to help get the game into a particular state or progress through some sequence without user input.
Commonly used MissionActions:
Okay, by this point hopefully readers will have a pretty clear understanding of how the Mission system works. Next time, I'll go over some of the things I might do differently if and when I were to re-create this system.
Also, after the last post, I received some questions about the system. My plan is to try to answer them in the next post in the series. If you have any questions about how the system works, feel free to ask them in the comments.
Until next week!
- Kevin
Last week I explained how the mission system works overall. As a quick recap, several times a second the MissionManager iterates over all active missions. Each mission has one or more "lanes" that define a sequence of nodes, where each node is a set of conditions to wait for, then a set of actions to execute when all conditions are satisfied. Once a node is executed, the lane advances to the next node.
So what is actually inside these "MissionCondition" and "MissionAction" objects?
[h2]Mission Conditions[/h2]
Conditions inherit from the abstract "MissionCondition" class, shown here with some static utility methods removed for clarity. It is quite simple:
[noparse]
public abstract class MissionCondition
{
[JsonIgnore]
public virtual string Description
{
get
{
return "No description for " + this;
}
}
public abstract bool IsSatisfied(MissionUpdate update);
}
[/noparse]
You may have wondered in the earlier screenshots from the tool how the nodes had plain English descriptions of what they did. Each subclass is responsible for providing a human-readable description of their behavior. So the tool can just call "condition.Description" when drawing the little node boxes and doesn't need to know what kind of condition it is.
Apart from that, the class defines an abstract method IsSatisfied, which all concrete implementations will need to define.
Let's look at an example:

The Cargo Spill mission waits for the player to be within 250 units of a particular ship.
This is handled by a PlayerProximityCondition:
[noparse]
///
/// Detect when the player is within a certain range of a persistent object
///
[MissionConditionCategory("Persistents")]
public class PlayerProximityCondition : MissionCondition
{
[EditInput]
public string persistentId;
[EditNumber(min]
public float atLeast = 0f;
[EditNumber(min]
public float atMost = 0f;
public override string Description
get
{
if(atMost {
return string.Format("Player is at least {0} units from {1}", atLeast, persistentId);
}
else
{
return string.Format("Player is at least {0} and at most {1} units from {2}", atLeast, atMost, persistentId);
}
}
}
public override bool IsSatisfied(MissionUpdate update)
{
SuperCoordinates playerPos = update.GameWorld.Player.PlayerCoordinates;
string id = update.FullId(persistentId);
SuperCoordinates persistPos = update.GameWorld.GetPersistentCoord(id);
if (playerPos.IsNowhere || persistPos.IsNowhere) return false;
if (playerPos.universe != persistPos.universe) return false;
float dist = SuperCoordinates.Distance(playerPos, persistPos);
if (atLeast > 0 && dist < atLeast) return false;
if (atMost > 0 && dist > atMost) return false;
return true;
}
}
[/noparse]
Notice that the public properties of the method are marked with attributes like "EditInput". These serve a similar purpose to Unity's editor attributes: at design time, the tool uses Reflection to create an appropriate form field for editing, and can validate values accordingly:

I won't go into detail here as to how this reflection edit system works since it's a very big topic. And I would strongly recommend developers implementing their first quest/mission system to leverage existing tools rather than try to build something this bespoke. I had a clear set of requirements from having already developed Starcom: Nexus (in which I used a Unity plug-in called xNode for the same effect) and as a result had a clearer roadmap as to what I needed to accomplish with the mission system.
The important thing when defining Conditions is to choose the correct level of abstraction. The goal is to make conditions that are granular enough that they can be reused in different combinations to handle different scenarios, while at the same time providing enough abstraction that the mission designer (in my case, also me) doesn't need to be thinking about the underlying engine-level logic. Generally speaking, conditions are the translation of a task as the player understands it into the language of the game's logic. E.g., the player might think (or have been explicitly told) "I need to talk to the Ermyr", so we need a condition that can detect whether the dialogue window is open and the active actor is part of the Ermyr faction. Having the "active actor" be an editable variable means that this condition can be reused for any actor.
There are a little under 100 different Condition classes, but most missions rely on only about two dozen common conditions.
Examples of commonly used conditions:
- Are there any hostile ships within [X] units of the player?
- Is there a ship from faction [Y] within the camera frustum?
- Has the mission flag [VISITED_CELAENO] been set?
- Does the player have the item [QUANTUM_DEFIBRILATOR]?
- Is the player talking to [DR_RAMA]?
[h2]Mission Actions[/h2]
As you might guess, Mission Actions work similarly to conditions, except for rather than returning a boolean based on the current state of the game, they execute code effecting some change.
The abstract Mission Action base class:
[noparse]
public abstract class MissionAction
{
[JsonIgnore]
public virtual string Description
{
get
{
return "No description for " + this;
}
}
///
/// If a mission action can be blocked (unable to execute)
/// it should override this. This mission will only execute
/// actions if all actions can be executed.
///
public virtual bool IsBlocked(MissionUpdate update) { return false; }
public abstract void Execute(MissionUpdate update);
}
[/noparse]
A simple, specific example is having the first officer "say" something (command crew members and other actors can also notify the player via the same UI, but the first officer's comments may also contain non-diegetic information like controls):
[noparse]
[MissionActionCategory("Crew")]
public class FirstOfficerNotificationAction : MissionAction, ILocalizableMissionAction
{
[EditTextarea]
public string message;
[EditInput]
public string extra;
[EditInput]
public string gamepadExtra;
[EditCheckbox]
public bool forceShow = false;
public override string Description
{
get
{
return string.Format("Show first officer notification '{0}'", Util.TrimText(message, 50));
}
}
public override bool IsBlocked(MissionUpdate update)
{
if (!forceShow && !update.GameWorld.GameUI.IsCrewNotificationFree) return true;
return base.IsBlocked(update);
}
public override void Execute(MissionUpdate update)
{
string messageText = LocalizationManager.GetText($"{update.GetPrefixChain()}->FIRST_OFFICER->MESSAGE", message);
string extraText = LocalizationManager.GetText($"{update.GetPrefixChain()}->FIRST_OFFICER->EXTRA", extra);
if (InputManager.IsGamepad && !string.IsNullOrEmpty(gamepadExtra))
{
extraText = LocalizationManager.GetText($"{update.GetPrefixChain()}->FIRST_OFFICER->GAMEPAD_EXTRA", gamepadExtra);
}
update.LuaGameApi.FirstOfficer(messageText, extraText);
}
public List GetSymbolPairs(string prefixChain)
{
List pairs = new List();
pairs.Add((string.Format("{0}->FIRST_OFFICER->MESSAGE", prefixChain), message));
if (!string.IsNullOrEmpty(extra))
{
pairs.Add((string.Format("{0}->FIRST_OFFICER->EXTRA", prefixChain), extra));
}
if (!string.IsNullOrEmpty(gamepadExtra))
{
pairs.Add((string.Format("{0}->FIRST_OFFICER->GAMEPAD_EXTRA", prefixChain), gamepadExtra));
}
return pairs;
}
}
[/noparse]
I chose this action as an example because it's: a) simple, b) shows how and why an action might "block," and c) gives a clue how the mission system handles the challenge of localization:
Every part of the game that potentially can show text needs some way of identifying at localization time what text it can show and then at play time display the text in the user's preferred language.
In the mission system, any MissionAction that can "emit" text is expected to implement ILocalizableMissionAction. During localization, I can push a button that scans all mission nodes for actions that implement that interface and gets a list of any "Symbol Pairs". Symbol pairs consist of a string that uniquely identifies some text and its default (English) value. The "prefixChain" is built from the mission id and a unique identifier for the node. This is necessary because we want every localization symbol to have a unique symbol id. In the above example, the full localization symbol is "ALPHA->MISSIONS->CARGO_SPILL->NODES->1->FIRST_OFFICER->MESSAGE".
There are currently 140 different MissionAction classes, although some of these are only used during automated tests to help get the game into a particular state or progress through some sequence without user input.
Commonly used MissionActions:
- Give a ship a specific AI objective
- Spawn an ecounter
- Set a planet's anomaly
- Create an entry for a mission objective in the player's Mission Log
- Have a faction hail the player
Okay, by this point hopefully readers will have a pretty clear understanding of how the Mission system works. Next time, I'll go over some of the things I might do differently if and when I were to re-create this system.
Also, after the last post, I received some questions about the system. My plan is to try to answer them in the next post in the series. If you have any questions about how the system works, feel free to ask them in the comments.
Until next week!
- Kevin