1. Starcom: Unknown Space
  2. News

Starcom: Unknown Space News

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:

[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

Technical Design of a Quest System (Part 1)

This week I'm going to start a multi-part deep dive to talk about how I implemented mission logic, with code examples.

But first I wanted to remind new players that there's an updated build on the beta branch. It's mostly changes to improve Steam Deck support, but also includes a few minor fixes.


I've been doing some kind of software development for about 25 years and game development for maybe half that. As an indie developer, I have to learn to be good enough in a lot of different skills, which reduces the potential to gain expertise of any specific discipline. So sometimes I come across a topic or question in game dev forums and think, "I know how to do that" but then check myself with, "there are definitely people who are better qualified to talk about this than me."

That said, there are topics where I haven't seen the kind of resources that would have helped me when I first started to tackle them.

One of the areas where I've learned an enormous amount that I think would of use to aspiring developers is in the area of missions and quests, not only from the design perspective but also from a technical architecture standpoint. Like, "what should mission code even look like"? This is an area where I went down some definitely wrong paths when starting out.

I'm very happy with the mission system which is both flexible enough to create a lot of interesting stories and gameplay, robust enough to have very few game-breaking issues, and efficient enough to allow a single designer (me) to create all of the game's content. While I think most of the decisions I made ended up being good ones, there are still things I would do differently if and when I were to create another game in this space.

But before I share the things I would do differently, I wanted to spend a couple updates talking about how the current mission system works at a technical level, both to give some context and as a potential starting point for other developers.

[h2]Mission Logic atop Game Logic atop Engine Logic[/h2]

Starcom: Unknown Space is built in the Unity game engine. Without going into a lengthy segue, there are things that virtually every modern game needs to do. It rarely makes sense for every game developer to re-invent all of:

  • Reading various asset file formats from disk
  • Calculating what's in the view frustum
  • Sending a group of triangles that represent some model to the GPU
  • Processing and outputting sound
  • Reading input from devices
  • Simulating rigidbody physics
  • Calculating collisions
  • Modeling and rendering particle systems
  • Etc., etc.

An engine provides these common features and functions within a standardized API, plus other tools intended to streamline the development process.

Starting from a point with all this functionality already available is an enormous leg up.

Using the engine's functionality, I implement the logic that's specific to my games:

  • What information belongs in save file and how is it formatted?
  • How to make a model of a sphere look like a planet in space?
  • When a player pushes the "fire missile" button, how does a missile come into existence in the game and what does it do?
  • When a projectile impacts an enemy ship, what happens? How do we determine which modules are still attached after some have broken off?

This is all game logic: the code that defines how the game works.

But on top of that, the game needs an additional layer of logic that can examine and take control of those systems in very specific scenarios to create the illusion of a narrative driven story that the player is a part of and being driven by. This "ad hoc" logic applies only in very specific scenarios: While it would be technically possible to put code inside the projectile logic to see if the thing we just blew up was a piece of debris that kicks off the "Priority Override" mission, it would be a nightmare from a software maintainability perspective. So it would be good to have a more abstracted level of logic that runs atop the game logic and can look at and modify what's going on in the game without being a part of the core game code.

The following is not the only way to implement a mission system, but at this point I feel confident saying that it is a good way. The game has had over 100,000 players, the vast majority of whom loved the overall experience. While there are have been some complaints related to missions, these are almost all on the design/guidance side and not the result of the technical architecture.

[h2]Mission Overview[/h2]

The Mission Manager keeps track of all missions that are active for the player's current game. These may be actual missions that are visible in the game's Mission Log (or Journal), but they also handle many invisible paths where the game flow needs to deviate from the default game logic: adding in helpful prompts for game controls that some players may have missed, triggering achievements, activating random void encounters, expediting playtests, etc.

Each mission consists of one or more "lanes". A lane is a linear sequence of nodes that must progress in order.

A node consists of any number of conditions and any number of actions. When ALL conditions are true, ALL the actions are executed, then the lane progresses to the next node.

The save state for the mission system is simply the current index for each lane. The game persists other kinds of data about the world, and the mission system can know and interact with that data, but from the perspective of the save system, the state of a mission is just a list of integers.

Here is the first mission in the game as visualized through the mission authoring tool. This particular mission is also the basic controls tutorial. The player is asked to investigate a cargo ship that has spilled debris nearby. From the ship's manifest they learn it has spilled some dangerous materials and the first officer suggests destroying the containers.

The grey blocks are nodes. The green entries in each block are the conditions that the nodes wait for and the pink entries are the actions that will execute:



As you can see from a close up of the last node in the first lane, as soon as the player is within 250 units of the ship with id "ALPHA.CARGO", they will receive another notification from their first officer, the game will autosave and that lane will be done:



If you're curious, you can see the object structure of the above mission as JSON in your game's install folder at:

Starcom Unknown Space_Data\StreamingAssets\Content\Kepler\Stories\ALPHA\Missions\CARGO_SPILL.json

In the tool, I can visualize the state of a current in-progress game like so:



Here the red blocks are the conditions that each active node is waiting on. This view is helpful when players submit their save in-game with bugs or other issues, I can immediately see what's blocking any particular mission. It's also helpful during mission design and testing.

[h2]Mission Update Loop[/h2]

Several times a second (there's no particular reason to tie the mission update loop to the frame rate and we can save a lot on performance by doing it less frequently) the mission manager iterates over every active mission for the player and calls UpdateMission method on it. Again, the state of any particular mission is defined solely by a single integer for each lane, representing the current active node.

Here is the code, (warts and all, slightly simplified for clarity):

Weekly Update: Mar 28, 2025

Over the past three weeks I've been working on improving Deck / controller support and the updated build is now deployed (along with a few other unrelated fixes) on the beta test branch.

In the process I had to make a number of small changes to how UI / and input were handled, so there's the potential that this has introduced new bugs. For that reason I'm going to leave this build on the opt-in branch for a bit longer. I'd encourage any brave commanders who want to help to switch to that branch to help flush out any bugs.

Compared to the current default build, this changes/adds:
  • Improved Save & Load menus to enable all functionality via controller inputs, including toggling selections for delete, deleting old autosaves, etc.
  • When text scaling is set to maximum (set by default on the Deck) all text should be a minimum of 9 pixels high at 1280x800 resolution.
  • Eliminated areas where scaled text overlaps other UI elements
  • Dropdowns now keep current selection in view when using controller
  • All text input fields should bring up the virtual keyboard on the Deck
  • Now possible to toggle highlight, fog in map mode
  • Research panel correctly keeps research selection in view
  • In cases where there are two scrollviews visible (such as the ship log), right stick scrolls the first, dpad scrolls the second.
  • Cargo screen allows for quickly analyzing items and proceeding to the next
  • Improved input handling in the shipyard
  • Chromaplate swatch customization now works with controller
  • Crew help should correctly reference controller inputs instead of keyboard if the controller is being used

In addition there are a few general fixes:
  • If the player has set a "design goal", the trade screen previously did not show short falls in the faction's own reserve currency. Now it does.
  • Rendering optimization when in map mode
  • Several minor typos

Until next week!
Kevin

Weekly Update: Mar 21, 2025

As discussed last week, I'm continuing to work on changes necessary for Steam Deck Verification.

Overall, almost all of the game is playable on the Deck, but there are still some parts of the UI that cannot be controlled using only standard Deck inputs (i.e., without using the touchscreen). And some of these have been tricky to get a decent implementation.

To give a specific example: Ship color customization.

You've got a collection of palettes to choose the color for each of the layers, plus a slider for smoothness. All of which is easy to use with a mouse, but the color palette is a third-party widget which doesn't really allow for intuitive color selection using a controller.

I could modify the code of the palette widgets, but in general I try not to make modifications of third-party packages as it can cause unexpected problems with any updates. But the way the palette is coded relies on Unity's "drag events". So I eventually settled on making a helper component that responds to right-joystick input when a particular palette is selected and then sends a "fake" drag event to the widget so the player can steer the palette cursor with the right joystick.

And while I can sort of test most Deck-like behavior in the Editor by using an XBox controller and setting the resolution to 1280x800, there are some features that can only be tested on the Deck itself, like invoking and responding to the Deck's onscreen keyboard. This adds an additional layer of friction in development: normally I can make a change in code or the editor and just hit "play" in Unity to see how it works. For the Deck I have to do a build, deploy it to Steam, set it to a branch, wait for the branch to get updated on my Deck and then reload the game.

So last week I said I was about 2/3 done with the Deck compatibility work. Now I've done the next 2/3.

Incidentally, if you are using the Deck, there is an opt-in beta that I'm using specifically for the deck/controller. It can be accessed using the normal opt-in system. See the pinned discussion topic for how to access but use the password "111111111111". (Those are 1's for easier entry on the deck and there are 12 of them because that's the minimum password length for betas. It's not meant to be secure, just to avoid people switching to it by mistake.)

Until next week!
Kevin

Weekly Update: Mar 14, 2025

Happy Pi Day!

Since the 1.0 graduation update of Starcom: Unknown Space back in September, I've made a lot of updates to the game in terms of both content, bug fixes and QoL improvements. In the last category are a number of incremental changes to controller/Steam Deck support.

During Early Access, Steam had evaluated the game as "playable" on the Deck but not "verified".

After the most recent patch, I submitted the game again for verification. Steam came back with a lengthy list of mostly small things necessary for verification, along with a helpful set of screenshots showing specific parts of the game that did not meet verification.

I've spent the past week going through this list and addressing these issues, as well as finding areas in the game that exhibited the same type of issues, but they had not specifically identified. (Presumably because they can't have their team play every single game through to completion.)

The most common issue was font scaling. If the game detects that it's being played on a Deck it automatically sets the text scaling in Options to maximum. But depending on the UI layout elements, there are some pieces of text that either don't get scaled, don't get scaled enough to meet the required minimum, or scales in a way that looks bad, like a line wrap that overflows the UI container element.

If you're curious how this works technically, every UI element in the game that can display text already has a component attached to it that identifies itself to the LocalizationManager. This is so that if a player changes their language, the game can replace the text with the appropriate string for that language, and if necessary, change the texture atlas which contains the actual glyphs for that language.

When I implemented text scaling, I modified the logic of these components to also scale their text up to a maximum size depending on the player's Text Scaling option. That works most of the time, but there are a number of places where the UI layout simply didn't have enough room to guarantee the text was always at least 9 pixels high at 1280 x 800 (the minimum text size for Deck verification).

The second most common issue was where the in-game help might reference the keyboard or mouse. In general, the tutorial system knows whether the player is using a controller and substitutes in the appropriate input description, but again there are edge cases where either the text was not correct or the help is referencing something that only applies to keyboard/mouse controls.

Finally there are a few menus in the game where some functionality is currently inaccessible via a controller, such as toggling saves for deletion in the save/load menus.

So all that is what I've primarily been working on for the past week and I'm probably (hopefully?) about 2/3 done at this point.

Until next week!
Kevin