Starcom: Unknown Space cover
Starcom: Unknown Space screenshot
Genre: -

Starcom: Unknown Space

Kepler 21530 Controller / Deck Update

The default branch has now been updated to the Kepler 21530 build. The main goal of this build was to prepare the game for Steam Deck verification, although there are a few minor gameplay improvements as well:

Deck/Controller Improvements:

  • 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

General fixes:

  • If the player has set a "design goal", the trade screen previously did not show shortfalls in the faction's own reserve currency. Now it does.
  • Rendering optimization when in map mode
  • Several minor typos

Technical Design of a Quest System (Part 3)

This is the third and last post in a series on the technical details of the Mission System. But first a bit of regular news: As I've mentioned previously there's a build on the beta branch with a number of improvements to Deck / Controller support, plus some minor fixes. Barring any reports of issues, I plan to promote it to default early next Week.

In the first post in this series I explained how the Mission System works overall. In the second post, I showed how the Mission Conditions and Actions worked.

As I said in the intro, I'm very happy with how the mission system has worked out, allowing me to create interesting and varied story lines and quests efficiently. This last part is important: creating an open world RPG as a (mostly) solo developer is a huge amount of work and I being able to create, test, and improve content quickly is absolutely critical.

But development is always a process of discovery and learning. Even if I generally made good technical choices along the way, I've gained knowledge in the process.

So here are a list of things that I might do differently if I were rebuilding the mission system from scratch. Of course, since these are technical paths I didn't go down, they might have their own hidden challenges. In no particular order:

Mission Journal Objectives as Content Objects



In the existing system, the specific entries that the player sees beneath each mission are created and advanced by MissionActions. They are stored in the player's save, but overall within the game they do not exist as objects that the game's content system "knows about". If I were to revisit this system, I'd probably want to be able to create objectives with their own content ids. Advantages of doing this would be:


  • Automatically adding mission objectives to analytics.
  • Make it more easy to use progress on specific missions as conditions.
  • Allow mission details to reflect mid-game changes in localization (currently if a player changes their language mid-game, mission objectives remain in whatever language was active when they were added).
  • The Creator Tool would be able to present objectives as a dropdown choice instead of a text field, preventing the possibility of typos.
  • Prevents cyclic dependencies. One current risk in refactoring missions is accidentally creating a "cycle" in the mission graph where Objective A and Objective B both have each other as prerequisites and therefore create a soft-locked. While promoting journal objectives to content objects wouldn't prevent this, it would be possible to create a check for it at design time.


Items are content objects, so the tool can present them as a dropdown choice.

Improved IDs



Relatedly, there are some things I don't love about how Content objects are referenced. These objects, such as anomalies, items, factions, persistents, etc. all have human-readable string ids. If there is a specific ship that the mission system needs to interact with, it might have an id like "ARRIVAL.ALETHEIA". While these ids are invisible to the player, they are very useful for scripting, debugging, etc. They consist of the id of the "story" they are part of (stories are the content equivalent of namespaces) and some local identifier, so if the designer has previously used an identifier in some story months ago in another story, they won't conflict.

There are two issues with this system:

The first is that strings are a bit less efficient in some contexts than integers. In most cases this efficiency difference is trivial enough not to matter. But there are some where they do have a real cost. For example, in the combat and AI systems, entities need to know who's a friend, who's neutral, and who's an enemy. In a crowded battlefield, when performance matters the most, this can equate to hundreds of disposition checks per update. To mitigate this, I implemented various caches, but cache validation can be a tricky problem. I am aware of at least one bug (now fixed) that arose from situations where a faction was hostile to the player and would not send new ships while hostile. But the disposition cache was not updating for that faction if they had no ships.

The second and more annoying issue is that there's no easy way for the tool to refactor ids. So if I want to move "ARRIVAL.ALETHEIA" into its storyline as "ALETHEIA.ALETHEIA", I have to search and replace across the JSON content files and be careful not to reuse ids for different types of content (like a conversation and an anomaly).

I'm not 100% sure what the best replacement would be. I think maybe I'd change them to structs consisting of a string that contained the content type, story id, local id and a separate ulong GUID.

Negation for All Conditions



Several times through development I ended up either creating a condition that was essentially the logical opposite of an existing condition, or modifying an existing condition with a boolean toggle that inverted the logic. E.g., early on I added a HasItem condition, then later I added a flag allowing the logic to be inverted so that it failed if the player DID have the item. But in some cases I ended up with the negation as a separate condition: there is a condition to check if the player is near a particular faction and another to check if they are not near.

Instead I think I should have consistently made all conditions negatable by adding a negation flag to the base class.

Embedded Node Timings



As described in the first part, the save state of a mission is pretty much defined by the current node index for each lane. One of the most commonly used conditions is "Delay" and its variants, where the mission is simply waiting for some number of seconds to pass. This is used by storing a time value for the node as a named numeric value in the game's general purpose GameVar system. This works fine and kept the mission state simple, but was a bit of a hack. I think it might have been better to store time values for each node. I.e., each node would know how long it had been since it was entered, both in scaled and unscaled time (scaled time doesn't advance when the player is in modal interactions like the station or dialogues, whereas unscaled time does).

Allow Missions to be organized as sub-objectives of another Mission



In the current system, a mission's objectives are only one level deep. From a UX perspective, it might be better if they could be organized into trees so that the player could see that a group of objectives formed a connected part of a larger mission. From a design perspective, organizing missions into smaller sub-parts that could be iterated and tested in isolation would speed up development and allow for easier refactoring.


Organizing missions into smaller sub-objectives would make design and testing easier, as well as being clearer to the player.

Area Objectives



In the existing system, a mission objective can be associated with a posiiton in space, which is helpful for directing the player to check something out. But there are time when the player is tasked with finding something that could be in a certain area.

Variable Update Frequency



As a performance trade-off, missions are updated around 6 times per second. Missions are not intended to provide the logic for reflex-driven gameplay elements, just the mission-level "narrative" logic. I.e., players will not generally notice if an officer chimes in 2 seconds vs 2.16 seconds after anaylizing an item. But there are some edge cases where the mission might trigger later than expected, such as proximity logic if the player is moving at high speeds.

Alternately, if a node contains some CPU intensive logic but the timing is less important, it might be desirable to check only once a second.

So maybe nodes could have an update frequency override value.

Questions & Answers



As promised, here are answers to some readers' questions about the previous posts:

"I would be curious about the (more detailed) difference between your MissionManager way and what you described as "Singleton" approach. Aren't you in both cases just accessing one part of your code from somewhere else?"

Good question: Yes, that's right.

In software design, it is generally undesirable to tightly couple systems: this leads to brittle designs where changes to one system breaks another unrelated system. But at the same time, you will often need some way for completely unrelated systems to talk to each other. Let's say for example the Mission logic somehow needs to check whether the player is currently talking to the Ermyr.

One possible solution is to use a Singleton. A Singleton is a static object instance that allows any code to reference it from anywhere, and because it's static there only be one instance, so you know everybody's working from the same data. Maybe there's a Singleton that's used to see if the player is currently talking with a particular faction. This absolutely can work and if I recall correctly is how Starcom: Nexus worked.

Any code easily can do something like:

if (ConversationManager.ActiveFaction == "ERMYR")

A second possible solution is some type of Dependency Injection, where if Object A needs Object B to do something, it will have been given a reference to it either when it was created or at the time that it needs it. So the Mission Manager's MissionUpdate object contains a reference to the MissionManager, which exposes all of the Game manager subsystems, including the ConversationManager which is NOT a Singleton, just a property of a GameInstance object. So the MissionCondition's code would instead look something like:

if (update.GameWorld.ConversationManager.ActiveFaction == "ERMYR")

Which is a bit more verbose. So why do it that way if you know there will never be more than one ConversationManager?

Well, first of all, the Mission system really may need to access almost every part of the game: besides normal quest-like missions, it also handles tutorials, achievements and other ad hoc logic. Which means that if you decide the way that the Mission system accesses other parts of the game is via Singletons, then the easiest and most consistent way for any part of the game to talk to any other part will also be Singletons.

This will naturally encourage a dependency on Singletons. And they expect that there will always be exactly one instance of themselves. In the case of a game instance and its related sub-systems, there should never be more than one, but what if there are none? Cases where your singletons might not exist:

  • At the Main Menu, before the player has started or loaded a game
  • Within non-game tool systems, like the Creator
  • Test scenes where you're trying to test or debug one specific area of functionality

So you may need to create and initializing everything to be able to do anything.

I wouldn't necessarily advice aspiring game devs to avoid Singletons. They are convenient, but that convenience tends to encourage dependencies which have a trade-off. Based on my experiences with Nexus I decided not to use Singletons for game-subsystems in Unknown Space. But lots of good games do use them this way. And I still use them for some application-level functionality, such as:

  • Localization
  • Analytics / Error reporting
  • Content Manager
  • Sound Manager


"I'm surprised that the updateMission method that runs all the time actually checks missions for changes from the patch. Somehow I would think that patching the game would patch the player's current save? But maybe I don't know enough about the Steam patch process. Or maybe you're just doing it this way as an extra check to prevent bugs?"

This is a little tricky to explain, because it requires some understanding of the relationship between game logic, game content, and save content.

The Steam game patch process only updates the game's installation folder so that it matches the latest build installation files. This effectively updates the game logic (the rules of the game) and the game content (the game's universe and story which are the same for all players). But saves, which are the result of the player's specific experience in the game, are stored in a separate folder that Steam does not touch. There really would be no way for Steam to know how to update this folder.

Game developers must decide the content and format of their saves, as well as how to patch them. Consider a game where at some point the player finds a shop where they can gain access to upgrades:

In a game update, this is changed so that the shop is locked and first the player must complete a quest where the reward is the key to the shop.

What should a patch do with a player's save in this situation? How to patch this is as much a design problem as a technical one. Does the shop suddenly become locked and the player now must complete the quest? What if they were inside the shop at the time of their save? These are things the designer must now consider with content patches. (This is a major reason why during Early Access named updates were not save compatible)

With Unknown Space's mission system, because mission lanes progress from left to right, any new nodes added at the end of a lane can be assumed to be in the player's future. So if the designer keeps that in mind, they can patch missions at the end of a lane (or a new lane). If the player's save is already at the end of the lane, the mission will immediately enter the patch's new node. Otherwise, it will enter it once the lane gets there.

Would you consider releasing these tools for modding?

Yes. The Creator Tool is already available for testing. The main reason I haven't made it fully public yet is that I'm not 100% sure how much I want to support it in its current state. But if anyone wants a key to check it out, send an email to kevin@wx3.com. You can see the pinned topic in this sub-forum for more information on its current state.

Okay, this turned into a longer post than I expected. Hopefully some found this information interesting, until next week!

- Kevin

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?

Mission Conditions



Conditions inherit from the abstract "MissionCondition" class, shown here with some static utility methods removed for clarity. It is quite simple:


public abstract class MissionCondition
{
[JsonIgnore]
public virtual string Description
{
get
{
return "No description for " + this;
}
}

public abstract bool IsSatisfied(MissionUpdate update);

}


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:

https://clan.akamai.steamstatic.com/images/42185452/ae8d924351f7f73aa43f498192debacab919a2b9.png

The Cargo Spill mission waits for the player to be within 250 units of a particular ship.

This is handled by a PlayerProximityCondition:


///
/// Detect when the player is within a certain range of a persistent object
///

public class PlayerProximityCondition : MissionCondition
{

public string persistentId;


public float atLeast = 0f;

public float atMost = 0f;

public override string Description
get
{
if(atMost <= 0)
{
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;
}
}
[TAG-20]

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:

https://clan.akamai.steamstatic.com/images/42185452/475a999a771d674d9f19d36ba83b77873b6b7ea1.png

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 units of the player?
Is there a ship from faction within the camera frustum?
[TAG-30] Has the mission flag been set?
Does the player have the item ?
Is the player talking to ?

Mission Actions

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:
[TAG-40]
public abstract class MissionAction
{

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);

}


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):



public class FirstOfficerNotificationAction : MissionAction, ILocalizableMissionAction
{

public string message;

public string extra;

public string gamepadExtra;
[TAG-50]
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<(string, string)> GetSymbolPairs(string prefixChain)
{
List<(string, string)> pairs = new List<(string, string)>();
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;
}
}


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.

Mission Logic atop Game Logic atop Engine Logic



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.

Mission Overview



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.

Mission Update Loop



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):

public void UpdateMission(MissionManager missionManager, MissionSave save, List gameEvents, float deltaTime, float unscaledDeltaTime)
{
// If the save has fewer lanes than the mission, then the mission has changed since our
// save (e.g., as part of a content update to the game). Expand the save's lane count
// to accommodate:
if (save.LaneCount < lanes.Count)
{
save.ExpandLanes(lanes.Count);
}
for (int i = 0; i < lanes.Count; i++)
{
// The save contains the current node index for each lane:
int currentNodeIndex = save.GetLaneNodeIndex(i);
MissionLane lane = lanes[i];
MissionNode currentNode;
if (currentNodeIndex < lane.nodes.Count)
{
currentNode = lane.nodes[currentNodeIndex];
MissionUpdate update = new MissionUpdate(missionManager, this, currentNode, save, gameEvents, deltaTime, unscaledDeltaTime);
// Satisfied defaults to true, but if any condition check fails,
// it fails:
bool satisfied = true;
foreach (MissionCondition condition in currentNode.conditions)
{
if (!condition.IsSatisfied(update))
{
blockingConditions[currentNode] = condition;
satisfied = false;
break;
}
}
// If the conditions are satisfied, we still
// need to check if any actions are blocked:
if (satisfied)
{
foreach (MissionAction action in currentNode.actions)
{
if (action.IsBlocked(update))
{
satisfied = false;
break;
}
}
}
if (satisfied)
{
int branchIndex = -1;
foreach (MissionAction action in currentNode.actions)
{
action.Execute(update);
// Branch actions are a special case that conditionally change
// the lane flow, most commonly used to implement lane "loops":
if (action is BranchAction)
{
BranchAction branch = action as BranchAction;
string fullFlag = update.FullId(branch.branchFlag);
if (update.GameVars.GetFlag(fullFlag))
{
if (branch.branchNode >= 0 && branch.branchNode < lane.nodes.Count)
{
branchIndex = branch.branchNode;
}
else
{
Debug.LogWarning("Invalid branch node");
}
}
}
}
ExitNode(update);
if (branchIndex > -1)
{
currentNodeIndex = branchIndex;
}
else
{
++currentNodeIndex;
}
save.SetLaneNodeIndex(i, currentNodeIndex);
}
}
}
}


Hopefully that's mostly self-explanatory for anyone with some coding background. But some require additional explanation for the method's arguments:

The MissionManager provides a reference that lets the actions and conditions call on any of the public methods or properties of other game systems. It's the only way for mission logic to hook into "the rest of the game" without relying on Singletons. The usage of Singletons is a moderately controversial topic: public static objects that can be accessed by any code. I think there's a general distrust of them because of their similarity to one of the stinkiest code smells: global variables. I used them pretty liberally in Starcom: Nexus and I think with careful design they can be a clear and efficient solution to a lot of problems. But in Unknown Space I elected to avoid them for game instance subsystems.

The missionSave is the state the player's current mission. Which as discussed earlier, is mostly just a list of integers representing the current nodes.

The gameEvents is a list of any GameEvents that have transpired since the last mission update. While the Missions can use the MissionManager to check the current state of the game, there may have been transitory events that represented something instantaneous, like a ship taking damage or the player picking up a drop.

The deltaTime and unscaledDeltaTime are the amount of time that has elapsed since the last mission update.

Thanks for reading! Next week we'll take a look at what goes on in the Condition and Action objects, until then,

- Kevin

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

Weekly Update: Mar 7, 2025

Last week I mentioned I was getting over an illness. It doesn't seem to have been Covid or the Flu, just a really bad head cold. Maybe due to not having had one in over five years, my immune system was out of practice? Still not 100%, but definitely feeling much better-- thanks for all the well-wishes.

On Tuesday I updated the default build with a series of patches that had been deployed to the opt-in branch, addressing minor issues since the last major update. I have a fairly robust testing procedure that has mostly avoided major issues hitting the default branch. But mistakes happen and this time some recent change had broken the music system. Game and UI sounds are handled using Unity's own sound system, but music is handled using the 3rd party audio middleware "Wwise". It's a fairly complex tool with a lot of settings and things that can get upset, so I had a panicky spell trying to figure out what broke.

Fortunately, it turned out to be something really simple: I had accidentally set the "enable Wwise" toggle to "off". Within a few hours of players reporting the issue I had a hot fix uploaded. Not exactly game-breaking, but a major issue in my categorization and a stressful few hours.

In other news, the Russian and Ukrainian volunteer translation teams have gotten the game into a solid state for their respective languages-- I've now marked both those languages as supported. A big thanks to those players for having dedicated their time to improving the localizations and also identifying outstanding missing symbols!

The combined update list for recent patches up through Kepler 21519:


  • Shipyard rotate hint not showing correct controls for controller
  • Several minor controller hints
  • Changed navigating module selection with controller: DPAD up/down now cycles category, DPAD left/right now cycles module. Instead of wrapping, moves to next category.
  • Mitigate potential memory leak when on the Operations Panel of the station
  • Fix minor memory leak from saves
  • Volunteer localization updates for Russian
  • Fixed issue with Ukrainian localization causing exceptions in a few dialogues
  • Various (ML) localization update to fill in missing symbols since initial creation
  • Fix to dialogue option not shown for Sepharial Guild Envoy
  • Several minor typo fixes

Until next week!
- Kevin

Kepler 21519 Patch

Hot patch to fix issue with music not playing in latest update.

Kepler 21517 Patch Notes

Kepler 21517 Patch:


  • Mitigate potential memory leak when on the Operations Panel of the station
  • Several minor controller hints
  • Fix minor memory leak from saves
  • Changed navigating module selection with controller: DPAD up/down now cycles category, DPAD left/right now cycles module. Instead of wrapping, moves to next category.
  • Numerous missing localization symbols
  • Volunteer localization updates for Russian
  • Fixed issue with Ukrainian localization causing exceptions in a few dialogues
  • Various (ML) localization update to fill in missing symbols since initial creation
  • Shipyard rotate hint not showing correct controls for controller
  • Fix to dialogue option not shown for Sepharial Guild Envoy
  • Several minor typo fixes