As I prepare my next update for Fair Weather, I thought it might be nice to dive into one of the features of the game and explain how I brought it together technically. The feature I’ll discuss today is my Player Decision Manager.
What is it?
Well simply put, it’s a multiple choice question presented to the player which will affect the player’s stats. It allows me to represent events aboard the player’s ship without having to build them out mechanically, (because I simply don’t have the resources or time to do so) but also it allows me an opportunity to introduce small snippets of world building. I also use these decisions to inject humor and set the mood of the game. While I aim to build many (hundreds, ideally) of decisions in, I also expect that the player will see these questions multiple times and begin to use them strategically to keep their ship afloat.
For my next update I decided to add 50 new decisions (a number I selected quite arbitrarily) into the game. It has always been my hope that my game would excel in writing and while I built systems for notes, and quests, and decisions, I haven’t taken a lot of time to sit down and make content, favoring work on features instead. So that’s what I’m doing now. I am inspired by games like Reigns which seem so simple on the surface and still manage to be totally engaging.
Wrangling Data
While on its face this is a relatively straightforward task, a lot of time is lost in the wrangling of data. So what I’m really sharing today is a couple of tools that I’ve made to make the process faster and less error prone. My game expects a json file with decisions laid out. Here’s a sample.
Initially I used Google Sheets to hold my thoughts for questions and responses and then manually copied the text into the json format that I needed by hand. Of course this lead to a ton of errors due to mistyping. (Shout out to JSONLint. You tha real MVP!)
So this was my first problem to solve: How to convert the data in my spreadsheet into the format that I needed. I looked into writing a script directly in Google’s scripting ecosystem, but the process of dealing with Google services and scary security messages just to run my own script turned out to be pretty convoluted and not one I wanted to spend more time on. I decided to just export my sheet as a csv and use an online csv to json converter to get my decisions.json. This worked….partially. As I continued to expand the complexity of my decision model I found that the online tool could work for some of the columns, but didn’t map some of the relationships correctly. So I could get part way there and have to manually insert the rest of the data. (Did I mention, I heart JSONLint?)
My next optimization involved writing a python script to read in the csv and spit out a json. It’s been quite a while since I wrote python but after a healthy amount of googling I managed to put together a script that does the job. This is the system currently in use for generating data files for decisions and quests. I feasibly could use this for the ftue (first time user experience) data as well but since that file changes a lot less, I haven’t spent the time to set up the script. There are improvements that I’ll need to address in the near future. The largest being an encoding problem which causes the script to stumble when it encounters smart quotes. Smart quotes are the curly style quotes that some programs like word convert straight quotes to automatically. The default text encoding (ascii) doesn’t know what to do with these characters. Text typed directly into the spreadsheet doesn’t have this problem, but if I copy and paste text from other programs, as I often do, I could inadvertently introduce them. The solution is to change the encoding to UTF-8, but doing so has proved difficult. Using a different library such as pandas to read in my csv could potentially solve this problem, but since it’s a rare issue I’ve delegated it to the backlog for now.
String Replacement
If you’ve scrutinized the screenshots above you’ll notice I developed a simple markup syntax for string replacement. It generally takes the form:
<KEYWORD>
I process all text before it’s displayed through a function that will replace the various keywords with the desired text. This helps with strings that are variable, like your captain’s name which changes each time you die, and also for items which I may change during development of the game, such as your ship name and the name of the company you work for. This is my way of reserving the right to change these items easily before I take the game of this beta-mode that it’s in currently. [Fun fact: The ship name Fair Weather used to be editable much like your crew and captain’s names, but I changed it to be static instead because I think it tells a better story that the Fair Weather is your ship, instead of me telling the story of some arbitrary ship.] Additionally, some of my keywords will automatically generate sector or ship names to vary the text slightly each time you see it.
So I can write statements like:
Singing is against <COMPANY_NAME> policy.
Vacation Request for <CREW_NAME>.
and
Captain, the <RAND_SHIP_NAME> wants to know if we’ll participate in their crew exchange program.
Reward Types
To add consequences, or “rewards” as I call them, for each option I added a few base reward types to specify to the code how handle dishing out rewards. Each option under the decision can have zero to N rewards listed along with associated counts for each reward. These rewards are things like reputation, fuel, and food, but also can be negative stat hits like ship damage and injuries.
A type of ITEM means that the player will get all of the items listed when the choice is selected.
The type RANDOM_SELECT means to choose between the rewards listed. There is an equal chance of each item being selected. In the future I hope to expand this so that the choice can be weighted on an individual basis. This has been a tricky type to navigate, and because of that I use it sparingly. I expect players will encounter a decision more than once as they play, but not knowing that an outcome was random, they could make the incorrect conclusion that their selected outcome is the only option and avoid selecting it if they didn’t get the “good” outcome the first time. To combat this I try to use language like “might” or “could” to signal that there are multiple results.
The final reward type ACTION signals to the code that an in-game event should trigger on selection like a sector jump or an attack. No doubt I’ll add new reward types as I extend the game functionality, but for now these types provide a decent amount of variety.
Selecting Decisions and their Targets
Decisions are triggered as one of the outcomes when the player hits the scan button. The PlayerDecisionManager then attempts to retrieve a decision from the full set of options. Some decisions have requirements, such as the average ship morale being above or below a certain level, which could cause a decision to be rejected. In cases such as these, to attempt up to four times to retrieve another decision. In the case that one isn’t found (which is pretty rare unless only a small subset of the decisions are loaded) it closes down and proceeds without showing the player anything.
Decisions also might have targets. Targets are one or more Non Player Characters (NPCs). So an outcome in a decision might affect a single crew member, an entire “department”, such as all Biologists, or the entire crew. In the case where everyone is effected, instead of specifying a target the reward type is used to specify this distinction. Instead of rewarding mood I would reward mood_all, for example.
Let’s take a look at some code snippets from the PlayerDecisionManager:
private const string TARGET_SINGLE_CREW = "SINGLE_CREW";
private const string TARGET_RAND_CREW = "RAND_CREW_";
private const string TARGET_DEPARTMENT = "DEPT_";
private const string TARGET_SINGLE_DEPARTMENT = "SINGLE_DEPT_";
public DecisionData? GetDecision()
{
int reselectAttempts = 0;
if(String.IsNullOrEmpty(selectedDecision.id))
{
bool success = false;
while(!success && reselectAttempts < 4)
{
selectedDecision = GetDecisionData();
success = !HasSeenRecently(selectedDecision.id) &&
SelectTargets(selectedDecision.target) &&
MeetsRequirements(selectedDecision.require);
reselectAttempts++;
}
if(success)
{
MarkDecisionSeen(selectedDecision.id);
return selectedDecision;
}
else
{
Invoke("CloseDecision", 0.2f);
return null;
}
}
return selectedDecision;
}
public bool SelectTargets(string decisionTarget)
{
if(!String.IsNullOrEmpty(decisionTarget))
{
selectedTargets = new List<NPCModel>();
if(decisionTarget == TARGET_SINGLE_CREW)
{
selectedTargets.Add(Main.Instance.crewCtrl.GetRandomCrew()[0]);
}
else if(decisionTarget.Contains(TARGET_RAND_CREW))
{
int count = 0;
Int32.TryParse(decisionTarget.Substring(TARGET_RAND_CREW.Length), out count);
if(count < 0)
{
NLog.LogError("INCORRECT COUNT FOR SELECTED TARGET RAND");
count = 1; //fallback in case of error
}
count = Math.Min(count, Main.Instance.crewCtrl.CrewCount);
selectedTargets = Main.Instance.crewCtrl.GetRandomCrew(count);
}
else if(decisionTarget.StartsWith(TARGET_DEPARTMENT))
{
string departmentType =
decisionTarget.Substring(TARGET_DEPARTMENT.Length);
selectedTargets = Main.Instance.crewCtrl.GetCrewOfType(departmentType);
if(selectedTargets.Count == 0)
{
return false;
}
}
else if(decisionTarget.StartsWith(TARGET_SINGLE_DEPARTMENT))
{
string departmentType =
decisionTarget.Substring(TARGET_SINGLE_DEPARTMENT.Length);
selectedTargets = Main.Instance.crewCtrl.GetCrewOfType(departmentType, 1);
if(selectedTargets.Count == 0)
{
return false;
}
}
selectedDecision.npcs = selectedTargets;
}
return true;
}
HasSeenRecently simply checks to see if the selected decision id is present a list recounting the last five decisions seen. While the PlayerDecisionManager selects a decision and processes the outcome, a separate popup view displays the decision and its options to the player. The selectedDecision object also stores selected NPC targets so that outcomes can be applied to them, and also so that string replacements can be done as needed with their names.
This system allows for a variable number of crew to be specified as targets for a decision in the spreadsheet. Take the target type TARGET_RAND_CREW in the code above. In the spreadsheet, I can enter a target like RAND_CREW_2 or RAND_CREW_3. That number is then is parsed out and used to select crew members that meet the desired type via helper functions in my crew controller.
Wrapping Up
That’s essentially it! When one of the multiple options is selected in the view, it calls back to the PlayerDecisionManager to dish out the rewards and complete the process. Like all things code, this will undoubtedly change in the future and if it’s interesting enough I’ll let you know about it. I hope you check out Fair Weather if you haven’t already. That’s all for now.