Building a Text Renderer

Hello! In this post I will go through how I built my text renderer in Unity. I will be using my systems I talked about in a previous post and show how I build upon them.

This is all for my game pixel based game ‘Traitors on the Train’. I wanted to make my own fonts instead of scouring dafont.com or something to find a font that vaguely resembled my specific style. I drew all my other sprites so why would text be any different.

Please keep in mind, this is not a step by step tutorial, rather it is a retrospective of my process creating my own text renderer and my understanding of how text renderers work. Ok lets continue…

Being a pixel style game, I can already get away from a few challenges most text renderers need to face. First instead of reading a TTF file and handling vector based rendering (which is something I have not covered in my current rendering system), I can use a regular bitmap instead. I don’t need to scale any of my text nor do I want to because I have made it a hard rule that everything is true to size and avoid any filtering. This means any pixel I draw is exactly where it will be placed in the game.

You have noticed the atlas above is set in a familiar order, that is the ASCII table.

The first 33 symbols are non printable. Between index 32 to 126 are ASCII’s printable characters and the only ones I really care about.

Putting them through my atlas factory, the exclamation mark is index 0 going all the way to tilde at index 93. Each character is treated like a separate sprite and the whole text will be one mesh.

Features Built

Basics

As always I’ll start with a data structure:

public struct TextBoxData
{
    public Vector2 size;
    public float[] lineWidthArray;
    public string[] lineTextArray;

    public float maxLineWidth;
    public int lineCount;
    public int charCount;
}
const int MAX_CHARS = 256;
const int MAX_LINES = 16;

I also define some memory constraints too. I’m not playing on having too much text for one renderer so I’ve made the maximum character length 256 and the maximum lines 16. Considering I’m using C# with Unity, I’d rather the memory to be occupied with a persistent array rather than a dynamic list to avoid garbage collection.

Now over to the function that actually sets the text:

public void SetTextWorld(string inputText, float alpha)
{
    if (inputText == null) return;

    text = inputText;

    textBoxData.lineCount = 0;
    textBoxData.charCount = 0;

    string curLineText = "";
    float curLineWidth = -kerning;
    float maxLineWidth = 0f;
        
    string[] words = text.Split(' ');
...

First I reset the line and character count to 0, ready to sum up that data. the curLineText will be the string of the current line. curLineWidth starts at the negative kerning value. This is because every word will always have one more character than space between characters which is important when calculating when there should be a new line. I also want to keep track of the max line width because that will determine the bounds width of the entire text. I then split the text into words using the Split() function so I can keep each word together without splitting a word between lines.

...
for (int i = 0; i < words.Length; i++)
{
    string word = words[i];

    float wordWidth = 0f;

    for (int j = 0; j < word.Length; j++)
    {
        char c = word[j];

        int letterIndex = c - 33;

        if (letterIndex < 0) continue;

        SimpleSprite sprite = textAtlas.atlas.simpleSprites[letterIndex];

        wordWidth += sprite.worldSize.x + kerning;
    }

    float spacingWidth = curLineText.Length > 0 ? spacing : 0f;
    float newLineWidth = curLineWidth + spacingWidth + wordWidth;

    if (curLineText.Length > 0 && newLineWidth > textBoxData.size.x)
    {
        textBoxData.lineTextArray[textBoxData.lineCount] = curLineText;
        textBoxData.lineWidthArray[textBoxData.lineCount] = curLineWidth;
        textBoxData.lineCount++;
        curLineText = word;
        curLineWidth = wordWidth - kerning;
        if (curLineWidth > maxLineWidth) maxLineWidth = curLineWidth;
    }
    else
    {
        if (curLineText.Length > 0) curLineText += " ";

        curLineText += word;
        curLineWidth = newLineWidth;
    }

    if (curLineWidth > maxLineWidth) maxLineWidth = curLineWidth;
    textBoxData.charCount += word.Length;
}
...

Here come the nested loops. The first nested loop is to calculate the textbox data for the second loop to read. For the outer loop, I iterate through each word of the text and for the inner loop, I iterate through each character. The letterIndex you’ll notice is subtracted by 33. This is because of the ASCII indexing I mentioned above where the printable characters start at index 33. I can look up the character sprite from my atlas with that index. All I need from the sprite at the moment is the world width to then add on to the wordWidth. The kerning value is then added on top for each character. After iterating through the current word, I add the curLineWidth to the spacingWidth between each word as well as the wordWidth that was just calculated. I test to see if the newLineWidth is larger than the text box width. If it is, I know a new line should start. I initially thought the curLineWidth should reset to 0, because it’s a new line.

But I quickly found out that was wrong shown here where the word “Old is outside the text box. How do I handle the word that triggers the new line to occur? Well this word would be the first of the new line so whatever that word is needs to snap there, meaning the curLineWidth resets to the wordWidth minus the kerning value because there’s no kerning spacing before the first word of each line. If the newLineWidth is not greater than the textbox width, it’s a little easier, we just continue that line adding back a space for the next word and updating the curLineWidth. In the end of the outer loop I update the maxLineWidth if needed and accumulate the character count.

...
float curPosY = -textAtlas.typeWorldHeight;
...

So we now know the length of each line, but what about the height? This is determined but the tallest letter of the font where I cache the height value in the variable typeWorldHeight. This is stored in the atlas and calculated in edit time.

Another thing to note is where the transform lies which at the top and the x axis is determined by the alignment type which I’ll explain later. For now, I want to write left to right, top to bottom so having the transform here means no matter how long or tall the text is, the world position won’t change. Relating back to the code, the curPosY starts as the negative of typeWorldHeight because my character sprites pivots are on the bottom left. I need to offset the first line by that height downwards. I can now start my second nested loop.

...
for (int i = 0; i < textBoxData.lineCount; i++)
{
    string line = textBoxData.lineTextArray[i];
    float lineWidth = textBoxData.lineWidthArray[i];

    float curPosX = 0;

    if (alignmentType == AtlasTextAlignmentType.Center)
    {
        curPosX = -(lineWidth * 0.5f);
    }
    else if (alignmentType == AtlasTextAlignmentType.Right)
    {
        curPosX = -lineWidth;
    }

    for (int  j = 0; j < line.Length; j++)
    {
        char c = line[j];

        if (c == ' ')
        {
            curPosX += spacing;
            continue;
        }

        int letterIndex = c - 33;

        if (letterIndex < 0) continue;

        SimpleSprite sprite = textAtlas.atlas.simpleSprites[letterIndex];

        Vector4 worldPivSize = new Vector4();

        worldPivSize.x = curPosX;
        worldPivSize.y = curPosY;
        worldPivSize.z = sprite.worldSize.x;
        worldPivSize.w = sprite.worldSize.y;
        worldPivotsAndSizes[spriteIndex] = worldPivSize;

        uvSizesAndPositions[spriteIndex] = sprite.uvSizeAndPos;
        scalesAndFlips[spriteIndex] = Vector4.one;

        spriteIndex++;

        curPosX += sprite.worldSize.x + kerning;
    }
    curPosY -= textAtlas.typeWorldHeight;
}

textBoxData.size.y = Mathf.Max(Mathf.Abs(curPosY + textAtlas.typeWorldHeight), textAtlas.typeWorldHeight); 
bounds.size = new Vector2(maxLineWidth, textBoxData.size.y);
...

The outer loop iterates through each line I calculated earlier. This is where I handle my alignments to bare with me as I will include that too. The curPosX starts at 0 if the alignment type is on the left which is the default case. Otherwise the curPosX starts in the middle of the lineWidth if the alignment type is Center or at the end of the lineWidth if the alignment type is Right. The inner loop iterates through the line characters and look up the character sprites again. This time I store the data for my Renderer Feature to hand off to the GPU to render in my shader. The sprite information is already cached in edit time so I just need to fill the arrays of worldPivotsAndSizes and uvSizesAndPositions and the scalesAndFlips with the sprite data and then add onto the curPosX after for the next character sprite. When the line ends curPosY offsets downwards by the typeWorldHeight. This all could’ve been done in one nested loop but the problem became apparent when I added alignments. Going left to right or right to left is easy because I can just append each character to the following position without any other characters moving. The center alignment is a different story.

Each character’s position depends on the characters in front of it, so I saw no other way other than to pre compute the line widths first. Just another thing I didn’t see coming as I was making this.

Text Box Editor

This is just a quick walkthrough here, but I think it good to show some Unity Editor API.

[CustomEditor(typeof(AtlasTextRenderer))]
public class AtlasTextRendererEditor : Editor
{
    BoxBoundsHandle boundsHandle = new BoxBoundsHandle();

    Vector4 custom;
    private void OnSceneGUI()
    {
        AtlasTextRenderer textRend = (AtlasTextRenderer)target;
        EditorGUI.BeginChangeCheck();

        switch (textRend.rendererType)
        {
            case AtlasTextRenderer.AtlasTextRendererType.Simple:
            case AtlasTextRenderer.AtlasTextRendererType.Border:
            {
                boundsHandle.size = textRend.textBoxData.size;

                float centerX = textRend.transform.position.x;

                switch (textRend.alignmentType)
                {
                    case AtlasTextRenderer.AtlasTextAlignmentType.Left:
                    {
                        centerX += boundsHandle.size.x * 0.5f;
                    }
                    break;

                    case AtlasTextRenderer.AtlasTextAlignmentType.Center:
                    {

                    }
                    break;

                    case AtlasTextRenderer.AtlasTextAlignmentType.Right:
                    {

                        centerX -= boundsHandle.size.x * 0.5f;
                    }
                    break;
                }


                boundsHandle.center = new Vector3(centerX, textRend.transform.position.y - boundsHandle.size.y * 0.5f, textRend.transform.position.z);
                boundsHandle.SetColor(Color.green);
                boundsHandle.DrawHandle();

                if (EditorGUI.EndChangeCheck())
                {
                    Undo.RecordObject(textRend, "Resize Textbox");
                    textRend.textBoxData.size = boundsHandle.size;
                    textRend.SetText(textRend.text);
                }
            }
            break;
...

Unity already has a bunch of editor tools built into every game object so it’s a different mindset here. I inherit from Editor where I have access to the OnSceneGUI() function. To refer to the instance of my text renderer you cast the `target` (which is a Unity Object inherited from Editor), to the type to access all the functions and variables I assigned. Using the type BoxBoundsHandle from the UnityEditor.IMGUI.Controls class, I get access to the green box editor tool. This is the same tool 2D box colliders use. If you don’t want a 2D box collider but need this draw tool, doing it this way is a nice alternative and you have more control over it’s behaviour. Plus you don’t get any of the other bloated unused data colliders have that you may not need. To tell Unity when to start looking for editor changes, you call EditorGUI.BeginChangeCheck(). The text scroll type uses a different scene GUI system, but the simple and border types are the same. the BoxBoundsHandle is mostly the same as a regular Bounds so to define it’s shader you need a Vector3 center and a Vector3 size. The center on the x axis is dependant on the alignment type. I start at the world position and either offset left or right by half the boundsHandle width. The user gets to control the size or at least the width which is all I need. To get the frame the boundsHandle changes, you call EditorGUI.EndChangeCheck() inside an if statement. From there you can override the textbox size but also you need to record that change for the undo stack. Unity has Undo.RecordObject() for that.

Background Borders

The background is a separate game object using my regular sprite renderer under the nine slice setting. I have not made a post explicitly about 9 nine slicing however I do have a LinkedIn post here .

private void SetBorderText()
{
    switch (alignmentType)
    {
        case AtlasTextAlignmentType.Left:
        {
            borderLocalPos.x = bounds.extents.x;
        }
        break;

        case AtlasTextAlignmentType.Center:
        {
            borderLocalPos.x = 0;
        }
        break;

        case AtlasTextAlignmentType.Right:
        {
            borderLocalPos.x = -bounds.extents.x;
        }
        break;
    }

    borderLocalPos.y = -textBoxData.size.y - backgroundRenderer.worldPivotsAndSizes[0].w - margin.y;
    borderLocalPos.z = backgroundRenderer.transform.localPosition.z;

    backgroundRenderer.enabled = true;

    Vector2 worldSize = new Vector2();
    worldSize.x = bounds.size.x + (margin.x * 2);  
    worldSize.y = bounds.size.y + (margin.y * 2);

    backgroundRenderer.transform.localPosition = borderLocalPos;

    backgroundRenderer.SetNineSliceSizeFromWorldSpace(worldSize);
}

I treat the background sprite like a bounding box so the code here is sort of similar to my text box editor tool. The background sprite is parented to the text and I use the localPosition to offset it depending on the alignment type. It offsets by half the bounds yet again. I also added a Vector2 margin so I can control the width and height of the background.

Scrolling Text

This was a fun one and such an extreme edge case, but I wanted it for my carriage signs. So unlike the other two renderer types, this type requires a specific shader to go along with it. I’ll show the shader first.

Varyings vert(Attributes v)
{
    Varyings o;

    AtlasSprite spriteData = _SpriteData[v.instanceID];

    float3 position = spriteData.position.xyz;
    float2 pivot = spriteData.pivotAndSize.xy;
    float2 size = spriteData.pivotAndSize.zw;

    float textBoundWidth = spriteData.custom.x;
    float scrollBoundWidth = spriteData.custom.y; 
    float scrollSpeed = spriteData.custom.z;
    float scrollingBackwards = spriteData.custom.w;

    float buffer = 0.1;
    float repeatWidth = max(scrollBoundWidth, textBoundWidth);
                
    float time = _Time.y * scrollSpeed;

    float2 objPos = v.positionOS.xy;
    objPos *= size;

    float bufferSign =  buffer * (scrollingBackwards * 2 - 1);
    objPos.x += fmod(time + pivot.x, repeatWidth + (buffer * 2)) + bufferSign;
    objPos.x += repeatWidth * scrollingBackwards;
                
    objPos.y += pivot.y;
    float3 worldPos = float3(position.xy + objPos, position.z);

    o.objPos = objPos;
    o.positionHCS = TransformWorldToHClip(worldPos);
    o.uv = v.uv;
    o.uvSizeAndPos = spriteData.uvSizeAndPos;
    o.scrollBoundWidth = scrollBoundWidth;

    return o;
}

The vertex pass is the only bit really worth mentioning here as the fragment pass just samples and renders the sprites. To optimise sprite batching, I pass in an extra vector for each of my renderers called custom. This vector4 can store whatever else I specifically want from the CPU. In the x channel i store the text bounds width which is the width of the raw text represented as the purple box. In the y channel I store the width of the scroll bounds which is represented as the red box. The speed in the z channel and which way it scrolls in the w channel. repeatWidth is the max between the two widths. Which ever is greater is where the characters will reset too. To offset the x position I use the modulo or fmod in HLSL which returns the remainder of a division. The dividend is time the pivot.x (which is the left side of the character sprite). and the divisor is the repeatWidth with a buffer. The reason for a buffer here is because the bounds represent the width of the text, but I need the width plus a little more so the position doesn’t repeat until the whole character sprite is outside the scrolling bounds. Considering every letter has a different width, I give it a generic buffer value that does a good enough job. Not great because it’s sort of a guess, but so far I can get away with it. If it is scrolling backwards the buffer needs to be on the other side too which is where objPos.x += repeatWidth * scrollingBackwards; comes from. This is what the vertex pass is doing close up where you can see that buffer.

The rest is regular vertex instructions for sprites.

private void SetScrollingText()
{
    for (int i = 0; i < customs.Length; i++)
    {
        Vector4 custom = customs[i];
        custom.x = bounds.size.x;
        custom.y = scrollBoundSize;
        custom.z = scrollSpeed;
        custom.w = scrollSpeed < 0 ? 1 : 0;
        customs[i] = custom; 
    }
}

Over to the CPU I just pass in the custom data from the renderer. Not much else.

That’s All For Now

Thanks for reading this far and hopefully it made some sense. I’ll get a post about 9 slicing done sometime soon. See Ya!

Devlog: How My NPC System Works

Hello. In this devlog I want to talk about my NPC system. Before I jump into the code I want to establish some context. In my game “Traitors on the Train”, NPCs are integral to the gameplay loop. The player who acts as a ticket inspector but is secretly a spy must check tickets of each NPC.

Some NPCs are traitors that must be caught. Based on the profiles that describe the behaviours and appearances of each traitor that are given to the player on a notepad, the player must check the suspected traitor’s ticket and allocate the station they’ll get off at on the given profile. I hope that makes sense.

Currently I have 5 NPC types. Tourist, Glasses Lady, Tradie, Businessman and Necklace Lady. There’s quite a few things established about how NPCs behave and the sort of data that I need to generate for the player.

Behaviour
    [Flags] public enum Behaviours
    {
        Nothing = 0,
        Frequent_smoker = 1 << 0,
        Takes_naps = 1 << 2,
        Always_hungry = 1 << 3,
        Listens_to_music = 1 << 4,
        Lots_of_phone_calls = 1 << 5,
        Enjoys_reading = 1 << 6,
    }

First is behaviour. Each NPC type acts out a mix of unique and generic behaviours. As an example, almost all NPC types sleep, however only the businessman and tradie takes phone calls. I made a firm rule that each NPC instance only acts out two of the however many behaviours that are designated to the NPC type. This is to prevent the situation where an NPC who isn’t a traitor which I dub “bystanders” don’t have the same behaviours and appearances as the traitors. I can see how this would be unfair for the player. The two selected behaviours are randomised upon instantiation of the NPC. I also ensure the behaviour combinations are unique to each NPC by selecting from a pool or structs that I dub “NPCProfile”.

    [Serializable] public struct NPCProfile
    {
        public string fullName;

        public int startStationIndex;
        public int exitStationIndex;

        public int npcPrefabIndex;

        public Behaviours behaviours;
        public Appearence appearence;
    }
Appearance
    [Flags] public enum Appearence
    {
        Nothing = 0,
        White_hair = 1 << 0,
        Blue_collar_worker = 1 << 1,
        Has_a_cain = 1 << 2,
        Near_sighted = 1 << 3,
        Suit_and_tie = 1 << 4,
        Is_bald = 1 << 5,
        Big_boned = 1 << 6,
        Wearing_a_dress = 1 << 7,
        Wears_shorts = 1 << 8,
        Carries_a_bag = 1 << 9,
        Wears_a_hat = 1 << 10,
        Wears_a_necklace = 1 << 11,
    }

Second is Appearance. This one is quite simple. Essentially, I just need to designate a combination of appearance descriptions of each NPC prefab as they are already have predetermined appearances based on how they are drawn. Meaning, if I draw an NPC with glasses, one of the appearances selected in the inspector would be “Wears glasses”. If NPC is a traitor, that one of those appearance descriptions would appear in the notepad.

Name
    [Serializable] public class NameData
    {
        public FirstName[] firstNames;
        public LastName[] lastNames;
    }
    [Serializable] public struct FirstName
    {
        public string gender;
        public string ethnicity;
        public string name;
    }
    [Serializable] public struct LastName
    {
        public string ethnicity;
        public string name;
    }

Third is name generation which could be a topic on it’s own. The world I’m building is based around the public transport of Melbourne. Gender and ethnicity are heavy influencers of what sort of name I generate for each NPC. The challenge is avoiding stereotypes and respecting ethnic backgrounds. For the system however, I just need to make sure I can pair these generated names to the appropriate NPC instance. I do so with the function below where I match names from a JSON file to the gender and ethnicity inputs.

private void Awake()
{
    nameData = JsonUtility.FromJson<NameData>(namesJSON.text);
}

public string GenerateName(Gender gender, Ethnicity ethnicity)
{
    string genderString = gender.ToString();
    string ethnicityString = ethnicity.ToString();
    List<FirstName> firstNamesList = new List<FirstName>();

    for(int i = 0; i < nameData.firstNames.Length; i++)
    {
        FirstName fn = nameData.firstNames[i];
        if (fn.gender.Equals(genderString, StringComparison.OrdinalIgnoreCase) &&
            fn.ethnicity.Equals(ethnicityString, StringComparison.OrdinalIgnoreCase))
        {
            firstNamesList.Add(fn);
        }
    }

    if (firstNamesList.Count == 0) return "NoFirstName";

    int firstNameIndex = UnityEngine.Random.Range(0, firstNamesList.Count);
    string firstName = firstNamesList[firstNameIndex].name;
        
    List<LastName> lastNameList = new List<LastName>();
    for(int i = 0; i < nameData.lastNames.Length; i++)
    {
        LastName ln = nameData.lastNames[i];
        if (ln.ethnicity.Equals(ethnicityString, StringComparison.OrdinalIgnoreCase))
        {
            lastNameList.Add(ln);
        }
    }
    if (lastNameList.Count == 0) return firstName;

    int lastNameIndex = UnityEngine.Random.Range(0, lastNameList.Count);
    string lastName = lastNameList[lastNameIndex].name;

    return firstName + " " + lastName;
}
Stations
public class StationSO : ScriptableObject
{
    public Station station_prefab;
    public int targetTrainSpeed = 100;
    public int metersPosition = 0;

    [Range(0, 1)]public float busynessFactor = 0.2f;
    public int traitorSpawnAmount = 2;

    public bool isFrontOfTrain;
    [Header("Generated")]
    public bool hadSpawned;
    public  List<NPCProfile> bystanderProfiles;
    public  List<NPCProfile> traitorProfiles;
}

Finally, the stations the NPCs start and end at are huge influencers of difficulty. If a traitor is only on the train for 1 or 2 stations, the likelihood of the player catching the traitor would be quite low. So like most procedural generation systems, fairness and variety need to be balanced. I treat each station as an NPC spawner so I can influence the distribution of NPCs. Some stations can be more busy than others, which reflects real world patterns.

public class Station : MonoBehaviour
{
    public StationSO station;
    ...
    private void SpawnNPCs()
    {
        for (int i = 0; i < station.bystanderProfiles.Count; i++)
        {
            NPCProfile bystanderProfile = station.bystanderProfiles[i];

            float randXPos = Random.Range(
            platformRenderer.renderInput.bounds.min.x + SPAWN_BUFFER,
            platformRenderer.renderInput.bounds.max.x - SPAWN_BUFFER);

            Vector3 spawnPos = new Vector3(
            randXPos,
            transform.position.y + 0.1f,
            platformRenderer.transform.position.z);

            NPCBrain bystander = Instantiate(
            trip.npc_prefabsArray[bystanderProfile.npcPrefabIndex],
            spawnPos,
            Quaternion.identity,
            platformRenderer.transform);

            bystander.profile = bystanderProfile;
            bystander.role = Role.Bystander;
        }

        for (int i = 0; i < station.traitorProfiles.Count; i++)
        {
            NPCProfile traitorProfile = station.traitorProfiles[i];
            float randXPos = Random.Range(
            platformRenderer.renderInput.bounds.min.x + SPAWN_BUFFER,
            platformRenderer.renderInput.bounds.max.x - SPAWN_BUFFER);

            Vector3 spawnPos = new Vector3(randXPos,
            transform.position.y + 0.1f,
            platformRenderer.transform.position.z);

            NPCBrain traitor = Instantiate(
            trip.npc_prefabsArray[traitorProfile.npcPrefabIndex],
            spawnPos,
            Quaternion.identity,
            platformRenderer.transform);

            traitor.profile = traitorProfile;
            traitor.role = Role.Traitor;
        }
    }
    ...
}

How I like to code is very data oriented based. I rely serialized data, static APIs and limited classes to avoid dependencies and cross references. I have a video explaining a bit more in depth as to why. Here I want to talk more about the how.

NPCBrain Class

I start off with the NPC component I call “NPCBrain”. Just for context I call my state machines “Brains” hence the name. The goal I have here as a programmer is to keep the NPC object script in one script. Following my video I linked above, I create a basic switch case state machine. Despite the NPCBrain having many states, the logic to get to each state is very simple. Considering the “Behaviours” enum is a bitmask, I just need to match the current behaviour to one of the behaviour enum flags using an AND operator.

 ... else if ((curBehaviour & Behaviours.Frequent_smoker) != 0)
 {
     SetState(NPCState.Smoking);
 }
 else if ((curBehaviour & Behaviours.Takes_naps) != 0)
 {
     SetState(NPCState.Sleeping);
 }...

When the bitwise result isn’t zero, I set the state. An advantage I found, that is different to how state machines are regularly taught, is decoupling the animations from the states. I can see, that overtime, the states and animations will grow. To reduce the amount states needed, I can couple animations like “sitting eating” and “standing eating” into one “Eating” state. Each state already has access to the first frame, every frame, every fixed frame and the last frame of the being the state so handling the further logic to decide which animation to play can be stored in those scopes. I’ll also take advantage of the simplification that comes with being on a train. NPCs are either standing or sitting for most behaviours so a simple check on whether the NPC is designated a seat will determine which of the animations to play.

        switch (curState)
        {
            ...
            case NPCState.Eating:
            {
                stateDuration = UnityEngine.Random.Range(
                npc.pickBehaviourDurationRange.x, 
                npc.pickBehaviourDurationRange.y);

                if (chairPosIndex != int.MaxValue)
                {
                    curClip = atlas.clipDict[(int)NPCMotion.SittingEating];
                }
                else
                {
                    curClip = atlas.clipDict[(int)NPCMotion.StandingEating];
                }
            }
            break; ...
        }

Onto to the behaviour selection logic, I want the NPCs to randomly choose the next behaviour on a timer. The timer value is also randomly selected from a range each time the NPC enters into a new random behaviour state. I have to consider that this logic influences two paradigms, difficulty and naturalness. If the NPC takes a long to change behaviours, the information is more limited to the player. If the range of the timer value is too small, the more robotic the NPC will appear to be. It is too soon to tell where the sweet spots are for these values, but I can tell I will need control here when level designing.

private Behaviours[] behaviourFlags;    
private Behaviour curBehaviour
private void UpdateStates()
    {
        switch (curState)
        {
            ...
            case NPCState.Eating:
            {
                if (behaviourClock  > stateDuration)
                {
                    curBehaviour = PickBehaviour();
                }
            }
            break;...
        }
    }

    private Behaviours PickBehaviour()
    {
        return behaviourFlags[UnityEngine.Random.Range(0, behaviourFlags.Length)];
    }
   

There is also logic outside of behaviours every NPC needs. They all need to be able to find a slide door, board the train, find a seat, find a standing position and exit the train. The challenge here is CPU optimisation. I want a lot of NPCs existing at once. A pattern here is each function calculates a position for the NPC to move towards. Considering the train doesn’t move in world space, these world positions can be cached beforehand to minimise spikes in performance. Lets take for example the FindSlideDoor() method.

    private void FindSlideDoor()
    {
        float shortestDist = float.MaxValue;
        float selectedSlideDoorPos = float.MaxValue;
        for (int i = 0; i < trainStats.slideDoorPositions.Length; i++)
        {
            float dist = Mathf.Abs(trainStats.slideDoorPositions[i] - transform.position.x);

            if (dist < shortestDist)
            {
                shortestDist = dist;
                selectedSlideDoorPos = trainStats.slideDoorPositions[i];
            }
        }
        targetXPos = selectedSlideDoorPos;
        curPath = Path.ToSlideDoor;
    }

The goal here is to find the closest SlideDoor object. Instead of querying each slide door position individually, I cache every slide door’s x position into a float array stored in a scriptable object trainStats.slideDoorPositions and I can loop through those values instead.

    private void SetSlideDoorPositions()
    {
        int slideDoorsPerCarriage = carriages[0].exteriorRenderers.Length;
        int totalSlideDoors = carriages.Length * slideDoorsPerCarriage;

        stats.slideDoorPositions = new float[totalSlideDoors];

        for (int i = 0; i < carriages.Length; i++)
        {
            Carriage carriage = carriages[i];

            for (int j = 0; j < carriage.exteriorSlideDoors.Length; j++)
            {
                int curIndex = i * slideDoorsPerCarriage + j;
                stats.slideDoorPositions[curIndex] = carriage.exteriorSlideDoors[j].transform.position.x;
            }
        }
    }

This is cheaper because it avoids the overhead of interacting with scene objects at runtime which are scattered in memory. Float arrays are contiguous, making iterations to find the closest position much faster. I use the same technique for the other methods too. The con is obviously there’s more arrays to create, paying the upfront cost either in edit time or in the first frame. During runtime, I’m set to maximise my NPC amount with a less performance cost in comparison.

The final framework allows me to scale in a specific way that serves the overall game loop. NPCs hold a duality of purpose, giving hints to the player and world building. Later on, the plan is to create specific NPCs that hold more of a narrative purpose. I plan to have a sports event happen and so the train would be full of sports fans. The framework should support this idea. Sports fans would have sport fan behaviours such as chanting, drinking etc. NPCs interacting with each other is another avenue I want to explore as well which I hope to help further build a convincing world. The idea would be to set the target position to another NPC, once close enough, enter the conversation state and talk until the behaviour timer stops. The framework should hold… I hope. We’ll see how we go.

Thank you for reading.