One of the things I discovered in the development of the original Starcom: Nexus is how difficult it is to create a good mission system.
I think of mission logic as a third “ad hoc” logic layer that’s built on top of game logic, similar to how game logic is built on top of engine logic:
- Engine logic applies to most or many games: A meshrenderer in the camera’s view frustum is always rendered whether the game is Hearthstone or Subnautica.
- Game logic applies just to a specific game, but it’s consistent: The exact way the player interacts with planet anomalies is unique to Starcom: Nexus, but the underlying logic applies to all planet anomalies in the game.
- Ad hoc logic applies only in specific, exceptional cases. The first officer only explains how cruise control works once, at the beginning of the game, when nothing important is nearby.
A mission system needs to be flexible enough to create a wide variety of interesting stories, but with enough reusable structure so that not every mission ends up as 100% bespoke C# code separated by yield statements. It needs to be able to interact with almost every system in the game, but also with enough separation that modifying a system has a low probability of breaking missions. It also needs to be able to save its state so that it can progress properly if the player saves and reloads the game at almost any point.
I initially conceived of missions as concrete implementations of an abstract base Mission class. The base class could handle “common” mission activities like detecting when a player was near an enemy or showing a crew notification, and concrete sub-classes would handle different types of missions, like fetch-quests or kill X enemies.
This idea didn’t work very well at all.
After implementing a few missions I realized that none were just the same class with different data. Even very similar missions wanted slightly different logic here and there, like when the First Officer would pop-in with a suggestion or a delay should be added. They also didn’t handle “out-of-flow” logic gracefully, where the player could execute some steps in any order.
Consider a real example, the very first mission in the game:
- The player is contacted by a nearby ship and given an assignment to deliver some goods to Kite Station.
- As they approach the station a custom prefab (the Rift) is spawned which pulls the player in and kicks off the game’s main storyline.
That’s pretty simple: a conversation starts the mission, then a one-off prefab effect gets spawned when the player comes within a certain distance of an objective.
But in actual implementation I discovered there were lots of little tweaks that made this mission unique. The conversation doesn’t start the instant the game begins, the first officer needed to make some observations to the player, etc.
I did notice a pattern, though: the mission logic could be very different, but all missions consisted of a sequence of waiting for a particular condition then doing something, then waiting for another condition, then doing something else.
The “wait/do” logic was simple enough to be re-usable, but could be combined with infinite variation to handle most stories.
So I built a node-based system using Unity’s graph editor and the XNode package:
Each node in the graph is a specific MissionNode class that detects some game state then does something, then the mission advances to the next node and repeats. The state of the mission can be saved simply by noting the indices of current nodes.
I say “indices” not “index” because if you look closely, there are actually two independent sequences in the mission. There’s the normal, expected path and a secondary path below that supplies some gentle nudging to the player if they aren’t heading toward their objective.
The MissionNode inherits from XNode’s Node class, which is a scriptable object, so exposed properties are editable at design time in Unity.
Overall, this system worked really well. There were a number of unexpected benefits that I didn’t anticipate. For example a lot of text snippets became organized in a consistent way that made localization easier.
Now I’m working on a sequel and I’m building on what I learned during development of Starcom: Nexus to create a custom mission editor.
Why not use the same system? A few reasons:
- I want to decouple content from Unity. While Unity is great, I don’t love some aspects of its asset management, particularly how it wants to serialize everything with every change (slowing down on large projects) and its tendency to “touch” assets with seemingly pointless modifications, potentially obscuring unintended changes in version control. I’d like content to be a completely separate Git project from the Unity game project.
- I’ve realized that nodes may be better defined as a collection of simple conditions and actions. For example “Start conversation when player is near faction” is better decomposed into “when player is near faction” and “start conversation”, allowing those two elements to be used separately.
- Missions weren’t well integrated into a solid mission tracking system for the player. As a result the mission log was more of an afterthought.
- A dedicated tool could potentially be given to modders, allowing them to create their own stories.
So I built a custom editor tool for different types of content, including missions.
Here’s a GIF of the new mission authoring tool in action:
The overall UI of the editor is a custom Unity UI “table” object that arranges contents into a flexible grid of rows and columns that adjust to the size of their content. Right-clicking on an element brings up a context-menu which is really just a collection of vertical layout groups. The edit dialogue is a form-like object with a collection of input prototypes that are instantiated based on the object being edited using reflection. Annotations on the object fields tell the form what UI element to use. Creating this editor was a great deal of work, but these features are used in all the other areas of content creation for the game such as dialogues, tech tree, discoveries, etc.
A mission is a collection of one or more “lanes,” each of which consists of one or more nodes. In the above image a lane is a horizontal row of nodes. Every mission update (which happens several times a second, not every frame), the MissionManager checks the active node for each lane and sees if all of its conditions (green blocks) are satisfied. If so, it executes all the actions (pink blocks) and then advances the active node for that lane.
Saving a mission’s state simply notes the active node index for each lane. If a node needs to store additional variables (e.g., for use in a dialogue tree, or another lane), it can use the GameVars object that acts as a persistent KeyValue store for named persistent booleans and numerics.
Some additional notes:
- Every Condition and Action implements a Description property that provides a human readable string, both for the edit tool as well as for in-game debugging. E.g., if I want to know why a particular mission isn’t advancing, I could easily see that the first lane is waiting for the player to talk to the Commodore.
- Lanes always execute in sequence, so I need to be careful that their events can only happen in a particular order. If not, they should be broken up into multiple lanes.
- Multiple lanes are also good for responding to “off-path” behavior. This could be providing a hint if the player might have missed something, rewarding the player for doing something unusual, etc.
- Special-case logic can be handled with Lua conditions/actions.
I’ve already created several dozen missions for the current build of Starcom: Unknown Space that have been tested in the first few small rounds of closed betas. A larger round is starting shortly which will put some new, more complex missions (from a design standpoint) to the test.
Thanks for reading!