Nicholas Perell

Experience Resume Blog Contact
Archer Icon and Moon Icon on the left and right of the name Artemis

A Narrative Tool for Unity

For games where the order of who you talk to or what you do is variable, Artemis accesses rules and world state data to give the most appropriate and important delivery. It’s not about the means of delivery, like Ink or Yarn Spinner, but instead about deciding what should be delivered.

Developer

Team Size: Primarily Solo

Scale: Unity Package/Tool

Development Period: Since May 2022

Tools Used:

C#

C#

Unity

Unity

Git

Git

Ink

Ink

Yarn Spinner

Yarn Spinner

May your aim be true.

Game prototype with a fletcher showing the different dialogue arrows that store their lines, flag changes, and the archers that will require changes to be made to it.

Features

  • Unique scriptable objects responsible for flags, narrative logic, and asset creation.
  • Customizable Archers that will determine which narrative beat it has access to (AKA Arrow) is highest priority and most relevant.
  • Saving & Loading using the Constellation asset.
  • Goddess singleton to track internal symbols and IDs.
  • User documentation.
Inspector for Constellation objects. This one is named Ursus. It has data for different flags that store data like WHO and GAME_STARTED. It has which archers it wants to save the state of. At the bottom there are options to name a save file, save the data to persistent memory, to load it from persistent memory, to save the data as a binary game asset, or to load the data in from a binary game asset.

Contributions

  • Implemented code samples, enumerator script compilation, saving/loading capabilities, custom editors
  • Code samples for getting Artemis to work in tandem with popular narrative tools.
  • Wrote extensive user documentation that also dives into and explains some samples.
  • Built custom editors for each asset.
  • Template classes for Fletcher and Bow to be more versatile and give developers freedom to choose what the narrative data being delivered is and how.
  • Programmed enum compiler for internal symbols & IDs in place of strings.
  • Added an self-indenting debugging system for Archer decision making.

Development of Artemis

Artemis is an ongoing narrative programming project by myself. After working on the narrative implementation on the game Project Nautilus, I wanted to turn the system I made into something more robust that other developers could use. For games where the order of who you talk to or what you do is variable, Artemis accesses rules and world state data to give the most appropriate and important delivery. It’s not about the means of delivery, like Ink or Yarn Spinner, but instead about deciding what should be delivered.

As discussed in this microtalk @ The Loaf, Project Nautilus took heavy inspiration from Hades's priority queues and Firewatch's Delilah brain. However, not every game has nearly as much written content as Hades; Project Nautilus used a priority stack for there to be recency bias (which Artemis allows you to choose between), and Artemis also takes inspiration from Left 4 Dead 2's Dynamic Dialog.

On the main branch of the public repo is version 0.2.5. The way it all interconnects is as follows:

“The fletcher makes and stockpiles the arrows, and the archer decides which arrow to shoot. The archer can get more arrows from (or throw away some in) a bundle, and she uses her bow to fire them.”

Archers will send items with a higher priority closer to the front. It also allows the option for “recency bias,” where instead of putting the newest X-priority item behind the X-priority items that are already there, it can put that item in front. It can also pick the equal priority arrows at random. Depending on the genre of a game, that can be an important distinction, and it can be changed at will by the narrative designers in the custom editors. The priority value of an arrow can also be determined by other means than a flat number, like the number of flags it needs to be met.

Archers also check arrows for criteria saved in flags. If the requirements aren’t met, an Archer will skip it. When compiling narrative items, Artemis tracks what flags are supposed to be set values. Enum IDs for these flags are made as they come up, and deleted when they’re not used by any arrows. That means multiple ways to deliver the narrative (i.e. NPC dialogue or letters) can still reference the same pool of flags. Being in the same pool also means developers can also look through everything and make sure no one made two different flags IDs for what are essentially the same thing.

The compilation of these narrative items and their flags use .CSV files.There are template classes for Fletchers (which turn the files into arrow assets) and Bows (which are game objects that take a fletcher's data and execute the in-scene delivery of a fired arrow). This means the developers have control over how to convert the strings into unique structs for a game’s needs, as well as how to deliver the narrative in-scene using these structs.

Technical information

Debug Console Sample

Spreadsheet that would be parsed by the debug console sample for Artemis. ID, Priority, Values, Flags, How to handle busy, Debug messsage, Log type (Info/Warning/Error), Time in seconds.

Example custom Fletcher & Bow for debug messages in the editor console.

Data Structures

The sorted dictionaries in version 0.2 mean we can simply skip anything that's already been looked at so long as we send back the index the next search should start at.

public bool LinearSearch(K key, ref int startAt, out V foundValue)
{
  foundValue = default(V);
  bool result = false;

  //Validate the startAt value is an index in the list
  if (startAt >= list.Count || startAt < 0)
  {
    startAt = -1;
  }
  else
  {
    int i;
    int tmp;
    for (i = startAt; i < list.Count; i++)
    {
      tmp = key.CompareTo(list[i].Key);
      if (tmp == 0)
      {
        startAt = i + 1;
        foundValue = list[i].Value;
        result = true;
        break;
      }
      else if (tmp < 0)
      {
        startAt = i;
        break;
      }
    }

    if (i == list.Count)
    {
      startAt = -1;
    }
  }

  return result;
}

The reason why some of the structures used (i.e. tuple, sorted list, priority queue, and sorted dictionary) are custom-written is for two reasons:

  1. Their equivalents aren't serializable. Saving/loading the state of a game's narrative may be deeply important to a game. It's something I'd like to implement down the line, or at least make possible for developers using Artemis.
  2. Some things need to be able to easily change how it works. Take the "Narrative Priority Queues" from version 0.1 turning into "archers," allowing for more options. It changed what determined the priority value (number of necessary conditions? flat value? the sum of both?) and if it has a sense of recency bias (stack? queue? random of whichever appropriate arrows have the highest value?). A C# priority queue has nearly none of those variations, let alone the ability to re-sort itself when those changes are made.

Criterion Constructor

All criterion checks on flags can be handled all with the same "a >= x && b >= x" function so long as some math is done to convert a value's requirement into a range. The use of float.Epsilon is for changing the >= into a > check.

public Criterion(FlagID _stateChecked, CriterionComparisonType _comparisonType, float a, float b = 0)
{
    flagIDChecked = _stateChecked;
    comparisonType = _comparisonType;

    lhs = 0;
    rhs = 0;

    switch (comparisonType)
    {
        case CriterionComparisonType.EQUALS:
            lhs = a;
            rhs = a;
            break;
        case CriterionComparisonType.LESS_EQUAL:
            lhs = a;
            rhs = float.NegativeInfinity;
            break;
        case CriterionComparisonType.GREATER_EQUAL:
            lhs = float.PositiveInfinity;
            rhs = a;
            break;
        case CriterionComparisonType.GREATER:
            lhs = float.PositiveInfinity;
            rhs = a + float.Epsilon;
            break;
        case CriterionComparisonType.LESS:
            lhs = a - float.Epsilon;
            rhs = float.NegativeInfinity;
            break;
        case CriterionComparisonType.RANGE_OPEN:
            lhs = a - float.Epsilon;
            rhs = b + float.Epsilon;
            break;
        case CriterionComparisonType.RANGE_CLOSED:
            lhs = a;
            rhs = b;
            break;
        case CriterionComparisonType.RANGE_OPEN_CLOSED:
            lhs = a - float.Epsilon;
            rhs = b;
            break;
        case CriterionComparisonType.RANGE_CLOSED_OPEN:
            lhs = a;
            rhs = b + float.Epsilon;
            break;
    }
}

Internal String Compiler

The flag's IDs are a recompiling enum instead of strings! Internal symbols will save space and process much smoother, and other parts of the system are planned to use internal symbols through similar means. You can see this in the Goddess, which tracks what flags are being used or not. Flags that are created/deleted are added/removed from the newly written FlagID.cs file, and then that enum script is recompiled.

using System;
using System.IO;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;

#if UNITY_EDITOR
using UnityEditor;
#endif

namespace Perell.Artemis
{
    [System.Serializable]
    public class InternalSymbolCompiler
    {
        //For managing internal symbols
        [SerializeField]
        private SortedStrictList<int> idsUsed;
        [SerializeField]
        private SortedStrictDictionary<string, int> toAdd;
        [SerializeField]
        private SortedStrictList<int> intsReadyToConvert;
        [SerializeField]
        private List<int> toRemove;

        [SerializeField]
        private string fileLocation;
        [SerializeField]
        private string enumName;
        [SerializeField]
        private System.Type enumType = null;

        private const int INVALID = -1;

        public InternalSymbolCompiler(string _fileLocation, string _enumPrefix)
        {
            fileLocation = _fileLocation;
            enumName = _enumPrefix.ElementAt(0) + _enumPrefix.Substring(1).ToLower() + "ID";

            idsUsed = new SortedStrictList<int>();
            toAdd = new SortedStrictDictionary<string, int>();
            intsReadyToConvert = new SortedStrictList<int>();
            toRemove = new List<int>();

            CheckForCompiledScript();
        }

        ~InternalSymbolCompiler()
        {
            enumType = null;
        }

        public void SetLocation(string _fileLocation, string _enumPrefix)
        {
            fileLocation = _fileLocation;
            enumName = _enumPrefix.ElementAt(0) + _enumPrefix.Substring(1).ToLower() + "ID";
            CheckForCompiledScript();

        }

        void CheckForCompiledScript()
        {
            enumType = System.Type.GetType("Perell.Artemis.Generated." + enumName + ", Perell.Artemis.Generated");
        }

        public void WriteFlagEnumScript()
        {
#if UNITY_EDITOR
            string elementName;
            int elementInt;

            CheckForCompiledScript();

            if(enumType != null)
            {
                idsUsed.Clear();
                foreach (int e in Enum.GetValues(enumType))
                {
                    if (e != INVALID)
                    {
                        idsUsed.Add(e);
                    }
                }
            }

            //Remove unused enums
            for (int i = 0; i < toRemove.Count; i++)
            {
                idsUsed.Remove(toRemove[i]);
            }

            //Build new enum script
            System.Text.StringBuilder stringBuilder = new System.Text.StringBuilder("");
            stringBuilder.Append("namespace Perell.Artemis.Generated
{
	public enum " + enumName + "
	{
		INVALID = -1");
            if (enumType != null)
            {
                for (int i = 0; i < idsUsed.Count; i++)
                {
                    elementInt = idsUsed[i];
                    stringBuilder.Append(",
		" + System.Enum.GetName(enumType, elementInt) + " = " + elementInt);
                }
            }

            for (int i = 0; i < toAdd.Count; i++)
            {
                elementName = toAdd.GetTupleAtIndex(i).Key; //TODO: Consider a GetKey or GetValue method instead?
                elementInt = toAdd.GetTupleAtIndex(i).Value;

                idsUsed.Add(elementInt);

                stringBuilder.Append(",
		" + elementName + " = " + elementInt);
            }

            stringBuilder.Append("
	}
}");


            //Determine File Path
            string relativePath = fileLocation + enumName + ".cs";
            string path;
            path = Application.dataPath;
            path = path.Substring(0, path.Length - 6); //removes the "Assets"
            path += relativePath;

            if (!Directory.Exists(path.Substring(0, path.LastIndexOf('/'))))
            {
                Directory.CreateDirectory(path.Substring(0, path.LastIndexOf('/')));
            }

            if (!File.Exists(path))
            {
                File.Create(path).Close();
            }

            File.WriteAllText(path, stringBuilder.ToString());


            //Reset toAdd/Remove
            toAdd.Clear();
            toRemove.Clear();
            intsReadyToConvert.Clear();

            AssetDatabase.ImportAsset(relativePath);
            CheckForCompiledScript();
#endif
        }

        public void DeleteFlagEnumScript()
        {
#if UNITY_EDITOR
            //Determine File Path
            string relativePath = fileLocation + enumName + ".cs";
            string path = "";

            path = Application.dataPath;
            path = path.Substring(0, path.Length - 6); //removes the "Assets"
            path += relativePath;

            //Delete unused script
            AssetDatabase.DeleteAsset(relativePath);
            enumType = null;
#endif
        }

        private int FindValidIDNumber()
        {
            int rtn;
            int start;

            if (intsReadyToConvert.Count == 0)
            {
                if (idsUsed.Count != 0)
                {
                    rtn = (int)idsUsed[idsUsed.Count - 1] + 1;
                }
                else
                {
                    rtn = 0;
                }
            }
            else
            {
                rtn = intsReadyToConvert[intsReadyToConvert.Count - 1] + 1;
            }

            if (rtn == int.MaxValue)
            {
                rtn = int.MinValue;
            }

            start = rtn;

            while (rtn == INVALID || idsUsed.Has(rtn) || intsReadyToConvert.Has(rtn))
            {
                rtn++;
                if (rtn == int.MaxValue)
                {
                    rtn = int.MinValue;
                }

                if (rtn == start)
                {
                    //Looped the whole way around and had no luck!
                    UnityEngine.Debug.LogError("You've run out of space for flags to be tracked. That's over (2^32)-1 flags!");
                    rtn = INVALID;
                    break;
                }
            }

            if (rtn != INVALID)
            {
                intsReadyToConvert.Add(rtn);
            }

            return rtn;
        }

        public int FindValueOfString(string id)
        {
            object symbol = null;
            CheckForCompiledScript();
            if (enumType != null && System.Enum.TryParse(enumType, id, true, out symbol))
            {
                return (int)symbol;
            }
            else if (toAdd.HasKey(id))
            {
                return toAdd[id];
            }
            else
            {
                int newIDValue = FindValidIDNumber();

                if (newIDValue != -1)
                {
                    toAdd.Add(id, newIDValue);
                }

                return newIDValue;
            }
        }

        public void SetToRemove(string id)
        {
            object symbol = null;
            CheckForCompiledScript();
            if (enumType != null && System.Enum.TryParse(enumType, id, true, out symbol))
            {
                SetToRemove((int)symbol);
            }
        }

        public void SetToRemove(int id)
        {
            toRemove.Add(id);
        }

        public string FindNameOfValue(int id)
        {
            string result = id + "";

            CheckForCompiledScript();
            if (enumType != null)
            {
                result = Enum.GetName(enumType, id);
            }

            if(result == id + "")
            {
                int index = toAdd.IndexOfValue(id);
                if (index > -1)
                {
                    result = toAdd.GetTupleAtIndex(index).Key;
                }
            }

            return result;
        }

        public System.Type GetEnumType()
        {
            CheckForCompiledScript();
            return enumType;
        }
    }
}