Some help with big Dialogues' performance speed

Hello all, @Brian_Trotter I have a new topic I need some help with

When dialogues get too big, let’s say for a massive quest or something, there’s usually a big performance bottleneck attached to it, and the ‘Next’ button, and firing the dialogue up the first time, can be a little expensive at times as a result.

I have identified areas that can be improved but no matter what I try, it doesn’t seem to boost the performance at all

My latest attempt was like the LazyValue approach for the health of the player and NPCs. In simple terms, only cache/process the nodes that come next and ignore everything else as a way to boost performance, and it seems like a great idea for dialogues that can go insanely big, but… implementing that has been very challenging, and that’s where I need help

So, in a nutshell, my current problem is:

a. Firing up a dialogue with 20+ nodes is very slow and expensive performance wise
b. The ‘Next’ button for that dialogue is also usually slow and expensive performance wise

These are the 2 problems I want to solve at any cost

I will attach 3 different scripts below, hopefully they all come to good use

  1. Dialogue.cs:
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;  // to be able to access the 'Undo' class (line 81)

namespace RPG.Dialogue {

    [CreateAssetMenu(fileName = "New Dialogue", menuName = "Dialogue", order = 0)]
    public class Dialogue : ScriptableObject, ISerializationCallbackReceiver    // ISerializationCallbackReceiver Interface avoids errors in Adding object to asset (line 90)
    {

        [SerializeField]
        List<DialogueNode> nodes = new List<DialogueNode>();

        [SerializeField]
        Vector2 newNodeOffset = new Vector2(250, 0);

        // key = string, value = DialogueNode (Dictionaries are used because of their ability to quickly find value of stuff, based on given keys)
        Dictionary<string, DialogueNode> nodeLookup = new Dictionary<string, DialogueNode>();

        private void Awake()
        {
            // Whilst OnValidate() is always called in the '#if UNITY_EDITOR' -> '#endif' range, it never
            // gets called outside of the Unity Editor. The result? Our 'NEXT' button for our game dialogues was never exported 
            // (because to the game, it's NEVER called).
            // To fix that, we call it in Awake(), as shown below (this calls ALL OnValidate() functions, under all scripts
            // with the same namespace as this one ('RPG.Dialogue' in this case)):
            OnValidate();
        }

        private void OnValidate()
        {
            nodeLookup.Clear();

            foreach (DialogueNode node in GetAllNodes())
            {
                nodeLookup[node.name] = node;
            }
        }

        public IEnumerable<DialogueNode> GetRootNodes()
        {
            foreach (DialogueNode node in GetAllNodes())
            {
                bool isChild = false;

                foreach (DialogueNode lookupNode in GetAllNodes())
                {
                    if (lookupNode.GetChildren().Contains(node.name))
                    {
                        isChild = true;
                        break;
                    }
                }

                if (!isChild) yield return node;
            }
        }

        // IEnumerables are an Interface (of type 'Dialogue Nodes' in this case), allowing objects to do 'for' loops over them:
        public IEnumerable<DialogueNode> GetAllNodes()
        {
            return nodes;
        }

        public DialogueNode GetRootNode()
        {
            return nodes[0];
        }

        public IEnumerable<DialogueNode> GetAllChildren(DialogueNode parentNode)
        {
            List<DialogueNode> result = new List<DialogueNode>();

            foreach (string childID in parentNode.GetChildren())
            {
                if (nodeLookup.ContainsKey(childID))
                {
                    yield return nodeLookup[childID];
                }
            }
        }

        // TEST FUNCTION - 19/5/2024:
        public static Dialogue GetByName(string dialogueName) 
        {
            foreach (Dialogue dialogue in Resources.LoadAll<Dialogue>("")) 
            {
                if (dialogue.name == dialogueName) 
                {
                    return dialogue;
                }
            }
            return null;
        }

#if UNITY_EDITOR

        public void CreateNode(DialogueNode parent)
        {

            DialogueNode newNode = MakeNode(parent);

            Undo.RegisterCreatedObjectUndo(newNode, "Created Dialogue Node");
            Undo.RecordObject(this, "Added Dialogue Node");

            AddNode(newNode);

        }

        

        public void DeleteNode(DialogueNode nodeToDelete)
        {
            Undo.RecordObject(this, "Deleted Dialogue Node");
            nodes.Remove(nodeToDelete);
            OnValidate();
            CleanDanglingChildren(nodeToDelete);    // cleans up children (further nodes connected to node to delete down the line) of parent nodes that have been deleted
            Undo.DestroyObjectImmediate(nodeToDelete);  // destroys our node (but also saves a backup, just in case you need to Undo your changes)
        }

        private DialogueNode MakeNode(DialogueNode parent)
        {

            DialogueNode newNode = CreateInstance<DialogueNode>();
            newNode.name = Guid.NewGuid().ToString();

            if (parent != null)
            {

                parent.AddChild(newNode.name);
                newNode.SetPlayerSpeaking(!parent.IsPlayerSpeaking());
                newNode.SetPosition(parent.GetRect().position + newNodeOffset);

            }

            return newNode;

        }

        private void AddNode(DialogueNode newNode)
        {
            nodes.Add(newNode);
            OnValidate();
        }

        private void CleanDanglingChildren(DialogueNode nodeToDelete)
        {
            foreach (DialogueNode node in GetAllNodes())
            {
                node.RemoveChild(nodeToDelete.name);
            }
        }
#endif

        public void OnBeforeSerialize()
        {

#if UNITY_EDITOR

            if (nodes.Count == 0)
            {
                DialogueNode newNode = MakeNode(null);
                AddNode(newNode);
            }
            
            // For saving files on the Hard Drive
            if (AssetDatabase.GetAssetPath(this) != "") {

                foreach(DialogueNode node in GetAllNodes()) {

                    if (AssetDatabase.GetAssetPath(node) == "") {

                        // For Loading a file off the Hard Drive/SSD
                        AssetDatabase.AddObjectToAsset(node, this); // adds the new nodes in the same dialogue under the original Dialogue Node

                    }

                }

            }
#endif

        }

        public void OnAfterDeserialize()
        {
            // Without this function, even if it's empty, the 'ISerializationCallbackReceiver' interface will stop the
            // game from running
        }

        public IEnumerable<DialogueNode> GetPlayerChildren(DialogueNode currentNode)
        {
            
            foreach (DialogueNode node in GetAllChildren(currentNode)) {

                if (node.IsPlayerSpeaking()) {

                    yield return node;

                }

            }

        }

        public IEnumerable<DialogueNode> GetAIChildren(DialogueNode currentNode)
        {

            foreach(DialogueNode node in GetAllChildren(currentNode)) {

                if (!node.IsPlayerSpeaking())
                {

                    yield return node;

                }

            }

        }
    
    }

}
  1. DialogueUI.cs:
using UnityEngine;
using UnityEngine.UI;
using RPG.Dialogue;
using TMPro;
using GameDevTV.UI; // so we can use 'ShowHideUI.cs' to deactivate the Dialogue when the game is paused

namespace RPG.UI 
{
    public class DialogueUI : WindowController
    {
        PlayerConversant playerConversant;
        [SerializeField] TextMeshProUGUI AIText;
        [SerializeField] Button nextButton;
        [SerializeField] GameObject AIResponse;
        [SerializeField] Transform choiceRoot;
        [SerializeField] GameObject choicePrefab;
        [SerializeField] Button quitButton; // quit button, to quit the dialogue midway through
        [SerializeField] TextMeshProUGUI conversantName;    // name of our conversant in a dialogue conversation
        
        // Start is called before the first frame update
        void Start()
        {
            nextButton.onClick.AddListener(() => playerConversant.Next());   // calls the 'Next' function (yes, the function not a variable) Subscriber (to the onClick event) when our player hits 'Next' in the dialogue, using the Lambda "() => {}" function
            quitButton.onClick.AddListener(() => playerConversant.Quit());  // Lambda Function, "() => {}", is used here to add a listener for our quit button. When clicked the script is closed
            // ShowHideUI.OnModalActive += playerConversant.Quit;  // Turn off the Dialogue UI when the game is paused
            UpdateUI();
        }

        // ShowHideUI.OnModalActive is a static event, hence this function is mandatory:
        void OnDestroy()
        {
            // ShowHideUI.OnModalActive -= playerConversant.Quit;
        }

        // Update is called once per frame
        void UpdateUI()
        {
            /* Debug.Log($"UpdateUI playerConversant.IsActive() == {playerConversant.IsActive()}");
            gameObject.SetActive(playerConversant.IsActive());
            if (!playerConversant.IsActive())
            {
                Debug.Log("Exiting DialogueUI as playerConversant is not active");
                return;
            }
            Debug.Log("Continuing with DialogueUI as playeConversant is active");
            //rest of method */

            // The following line ensures the dialogue is invisible until the return time 
            // of the IEnumerator in 'playerConversant.cs' time counter is over
            gameObject.SetActive(playerConversant.IsActive());

            if (!playerConversant.IsActive()) 
            {
                return;
            }

            conversantName.text = playerConversant.GetCurrentConversantName();
            AIResponse.SetActive(!playerConversant.IsChoosing());
            choiceRoot.gameObject.SetActive(playerConversant.IsChoosing());
            
            if (playerConversant.IsChoosing())
            {
                BuildChoiceList();
            }

            else 
            {
                AIText.text = playerConversant.GetText();
                nextButton.gameObject.SetActive(playerConversant.HasNext());
            }
        }

        private void BuildChoiceList()
        {
            foreach (Transform item in choiceRoot)
            {
                // avoids Dangling Nodes in the Hierarchy, which will eventually slow our entire game down
                Destroy(item.gameObject);
            }

            foreach (DialogueNode choice in playerConversant.GetChoices())
            {
                GameObject choiceInstance = Instantiate(choicePrefab, choiceRoot);
                var textComp = choiceInstance.GetComponentInChildren<TextMeshProUGUI>();
                textComp.text = choice.GetText();
                Button button = choiceInstance.GetComponentInChildren<Button>();
                button.onClick.AddListener(() => 
                {          // "() => {}" is a 'lambda' function ("()" is the argument, "{}" is the internals of the function), which only works when a button is clicked (do some research about this)
                    playerConversant.SelectChoice(choice);
                });
            }
        }

        protected override void Subscribe()
        {
            playerConversant = GameObject.FindGameObjectWithTag("Player").GetComponent<PlayerConversant>();
            playerConversant.onConversationUpdated += UpdateUI;
        }

        protected override void Unsubscribe()
        {
            playerConversant.onConversationUpdated -= UpdateUI;
        }

        protected override void OnDisable()
        {
            base.OnDisable();
            playerConversant.Quit();
        }
    }
}
  1. PlayerConversant.cs (for the Next button):
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System;
using RPG.Core; // for the Evaluator of the Dialogues (line 133)
using RPG.Movement;
using GameDevTV.Utils;

namespace RPG.Dialogue {

    public class PlayerConversant : MonoBehaviour, IAction
    {

        [SerializeField] string playerName;
        
        Dialogue currentDialogue;
        DialogueNode currentNode = null;
        AIConversant currentConversant = null;  // the conversation being occured between the Player and the AI (to trigger events during the conversation for instance)
        bool isChoosing = false;

        // OUT OF COURSE CONTENT ------------------------------------------------------------------------------------------------
        private Dialogue targetDialogue;    // the Dialogue our Player is aiming to go to, for conversation purposes
        private AIConversant targetConversant;  // the Target Conversant of our Quest Giver = Player Conversant
        public float acceptanceRadius; // the minimum distance our Player has to be from the NPC before they can talk
        // ----------------------------------------------------------------------------------------------------------------------

        public event Action onConversationUpdated;

        public void StartDialogue(AIConversant newConversant, Dialogue newDialogue) {

            // TEST (Delete if failed):
            if (newConversant == currentConversant) return;

            currentConversant = newConversant;
            currentDialogue = newDialogue;
            currentNode = currentDialogue.GetRootNode();
            TriggerEnterAction();
            onConversationUpdated();    // this line subscribes our Dialogue to the event Action we created above, so that it follows along when something new to that event occurs

        }

        public void Quit() {

            // When the conversation is over, or the 'X' button is clicked, this function is called

            currentDialogue = null; // no node available
            TriggerExitAction();

            // Test
         /* Debug.Log("Quit Dialogue - Before TriggerExitAction");
            TriggerExitAction();
            Debug.Log("Quit Dialogue - After TriggerExitAction"); */

            currentNode = null; // no Nodes available to process through
            isChoosing = false; // no choices of conversations available
            currentConversant = null;   // quitting the AI Conversant Dialogue
            onConversationUpdated();    // makes the dialogue hide itself when the chat is over

        }

        // Current Conversant (lambda, 'on the fly' function), which will deactivate the NPC walking around when our player starts talking to him:
        public AIConversant GetCurrentConversant() => currentConversant;

        public bool IsActive() {

            // This function (a 'getter' function) returns whether we have a current Dialogue to refer to (after the 2 seconds of the IEnumerator)
            return currentDialogue != null;
        
        }

        public bool IsChoosing() {
            // getter
            return isChoosing;
        }

        public string GetText() {

            // getter
            if (currentDialogue == null) {

                return "";

            }

            return currentNode.GetText();

        }

        public IEnumerable<DialogueNode> GetChoices() {

            return FilterOnCondition(currentDialogue.GetPlayerChildren(currentNode));

        }

        public void SelectChoice(DialogueNode chosenNode) {

            currentNode = chosenNode;
            TriggerEnterAction();
            isChoosing = false;

            // OPTIONAL: Implement this line only if you don't want the next conversation to display what your button just had written on it:
            Next();
            // if we didnt call 'Next()', which calls 'onConversationUpdated()' event subscription, we could've called it here instead

        }

        public void Next() {

            int numPlayerResponses = FilterOnCondition(currentDialogue.GetPlayerChildren(currentNode)).Count();

            if (numPlayerResponses > 0) {

                isChoosing = true;
                TriggerExitAction();
                onConversationUpdated();
                return;

            }

            DialogueNode[] children = FilterOnCondition(currentDialogue.GetAIChildren(currentNode)).ToArray();  // filters our Player <-> Quest Giver responses based on the process of our Quests
            int randomIndex = UnityEngine.Random.Range(0, children.Count());    // UnityEngine is mentioned here because Random.Range comes from both UnityEngine and System (which we need for event Action), hence we need to specify which function we are calling
            TriggerExitAction();
            currentNode = children[randomIndex];
            TriggerEnterAction();
            onConversationUpdated();

        }

        public bool HasNext() {

            return FilterOnCondition(currentDialogue.GetAllChildren(currentNode)).Count() > 0;

        }

        private IEnumerable<DialogueNode> FilterOnCondition(IEnumerable<DialogueNode> inputNode) {

            // This function ensures we can play different Nodes on our dialogues, based on the Progress Status of our Quests (Start, Pending, Complete, etc)
            foreach (var node in inputNode) {

                if (node.CheckCondition(GetEvaluators())) {

                    yield return node;  // if a condition (E.g: A quest has been done) is met, we include it in our filter, otherwise it's excluded from the Filter

                }

            }

        }

        private IEnumerable<IPredicateEvaluator> GetEvaluators() {

            return GetComponents<IPredicateEvaluator>();

        }

        private void TriggerEnterAction() {

            if (currentNode != null) {

               TriggerAction(currentNode.GetOnEnterAction());

            }

        }

        private void TriggerExitAction() {

            if (currentNode != null)
            {

                TriggerAction(currentNode.GetOnExitAction());

            }

        }

        private void TriggerAction(string action) {

            if (action == "") return;

            foreach(DialogueTrigger trigger in currentConversant.GetComponents<DialogueTrigger>()) {

                trigger.Trigger(action);

            }

        }

        public string GetCurrentConversantName()
        {
            
            if (isChoosing) {

                return playerName;

            }

            else {

                return currentConversant.GetName();

            }

        }

        // MORE OUT OF COURSE CONTENT (RUNNING TO CLICKED NPC BEFORE INTERACTING IN DIALOGUE WITH THEM) -----------------------------------------------------------------------------

        public void Cancel()
        {
            Quit(); // if you cancel a dialogue, you just Quit it...
            targetConversant = null;    // ... and you also ensure that you're not talking to anyone in-game
        }

        public void StartConversation(AIConversant conversant, Dialogue dialogue) {

            GetComponent<ActionSchedular>().StartAction(this);
            targetConversant = conversant;
            targetDialogue = dialogue;

        }

        void Update() {

            if (!targetConversant) return;
            if (Vector3.Distance(transform.position, targetConversant.transform.position) > acceptanceRadius) {

            // transform.LookAt(targetConversant.transform);
            GetComponent<Mover>().MoveTo(targetConversant.transform.position, 1.0f);

            }

            else {

                GetComponent<Mover>().Cancel();
                StartDialogue(targetConversant, targetDialogue);
                targetConversant = null;    // stops our player from being a creepy stalker that creepily follows the NPCs around

            }

        }

        // -----------------------------------------------------------------------------------------------------------

    }

}

Have you tried profiling to see what the exact bottleneck is in presenting the dialogues?

In my own tests, with large dialogues I really didn’t see enough of a bottleneck to try crazy optimizations.

That being said, one of the reasons we recommend small scenes and transitions rather than larger scenes is for performance, and that principle is likely well applied to dialogues as well. What you could consider is some sort of mechanism for a DialogueNode to automatically change to a new dialogue entirely, keeping dialogues themselves smaller and only moving to the new dialogue when needed…

This would entail a new field in DialogueNode

[SerializeField] private Dialogue nextDialogue;

public bool HasNextDialogue => NextDialogue!=null;
public Dialogue NextDialogue => nextDialogue;

Now, on a Player DialogueNode, when clicked, in Next, check to see if the node has a NextDialogue, and if it does, switch to that dialogue.

OK so I never worked with a profiler before, so… what am I looking at here?

If it helps, here’s the spike I see when I open a large dialogue:

As for the dialogues system, I’ll get to that shortly :slight_smile:

This might be an issue with caching the Quests and Inventory. Both of these caches are created when we first GetFromID on either the Quest or the InventoryItems.
Reason: When you call up a Dialogue, if it has any conditions, they will be evaluated so that we know what replies to include in the Next. If HasQuest, HasInventoryItem, or CompletedQuest are in the conditions the first time a specific dialogue loads, then it will have to perform a Resources.LoadAll().

That Resources LoadAll is only called once for the Inventory and the Quests, so one possible optimization is to attempt a load in Inventory.Awake() and QuestList.Awake().

We don’t actually need to retrieve a specific item, as long as there is a string to check, as we’re not looking for the result of GetFromID(), we’re just trying to force the load to happen when the game starts instead of the middle.

Make the last line of Awake() in Inventory.cs

InventoryItem item = InventoryItem.GetFromID("");

Are we talking about Inventory.cs? Because it does sound a little suspicious to be honest. This is currently what Inventory.Awake() currently looks like:

        private void Awake()
        {
            slots = new InventorySlot[inventorySize];
        }

and as I learned yesterday, this is what object pooling is all about. I am currently in the middle of my own object pooling mess. Wish I knew about this earlier, lol

But I saw the performance improvements of forcing loads at the start and yes, I want that lol

Apparently all it took was to cache some patrol path finding algorithms and a bit of object pooling and suddenly the game has zero hiccups when it comes to dynamically spawning NPCs, lol

And along the way I made my own FPS and Memory Management counters, xD. This game, outside of Unity, is extremely light… like 500MB RAM kinda light right now, xD. Runs at 95FPS on a GTX1650 M laptop

@Brian_Trotter I am back to deep research. So far I discovered that triggering onConversationUpdated in PlayerConversant.cs is the root cause of the major delay

After some further debugging, it lead to DialogueUI.BuildChoiceList() from DialogueUI.UpdateUI(), which is subscribed to ‘playerConversant.onConversationUpdated()’. This destroys every part of the conversation and rebuilds only what we need from scratch, so I need to pool it instead of destroying and rebuilding it each time

I am guessing the destroying of every part of the conversation is what gets expensive? How do we fix this?

It should only be destroying the player choices, which (unlike large inventories) should be a pretty short list of objects to destroy.

    private void BuildChoiceList()
    {
        foreach (Transform item in choiceRoot)
        {
            Destroy(item.gameObject);
        }

All that should be in choiceRoot are the player choices, and this should only happen after the user has made a choice (or getting started). Now… if you have a very large amount of player choices, say 100 or so that are functioning at once, this could be a problem.

That being said, HAVING a large amount of choices that need Conditions checked can also be a problem. Every Condition will require gathering the Evaluators (that step could be optimized by gathering all IPredicateEvaluators in Awake()), and then iterating over those evaluators in Condition.cs. This means the more children a particular node has, the more condition checks need to happen. A definite potential bottleneck situation. The TLDR of that is to keep your dialogues tight.

Sorry for the extremely late response, I was finishing off my own games’ witness system (I had to save and restore dynamically spawned NPCs and let’s just say it was one big and hard challenge for me, but yeah now I got dynamically spawned NPCs that can save and restore not only themselves, but also the witness information to report you to the police even if you think closing and re-opening the game again would solve it, xD. Best part is? They also queue reports up against you. Trust me, the mechanic to tempt them to get into this mess will be worthwhile :))

You did mention earlier that one solution is to just load the next dialogue information and conditions at the start instead of everything. Can you please demonstrate to me how to do so? It would absolutely make a big difference for me! - My only problem with big dialogues is the time it takes for firing them up the first time, that’s all I am trying to solve

Something like how we significantly improved the inventory speed by not having to redraw everything every time we interact, but we only update the slot the player is using

I don’t remember that as a suggestion. I can’t even think of how that would work because conditions are likely to change as the game progresses.

What you can do is cache the Evaluators

        List<IPredicateEvaluator> evaluators = new List<IPredicateEvaluator>();

        private void Awake()
        {
            evaluators = GetComponents<IPredicateEvaluator>().ToList();
        }

and instead of using GetEvaluators() (which runs that GetComponent on every tested node), use evaluators

        private IEnumerable<DialogueNode> FilterOnCondition(IEnumerable<DialogueNode> inputNode)
        {
            foreach (var node in inputNode)
            {
                if (node.CheckCondition(evaluators))
                {
                    yield return node;
                }
            }
        }

There is another optimization I can see, was we’re technically checking player choices TWICE, once in Next(), and then if there are player choices in
DialogueUI.BuildChoices

        foreach (DialogueNode choice in playerConversant.GetChoices())

which leads to

        public IEnumerable<DialogueNode> GetChoices()
        {
            return FilterOnCondition(currentDialogue.GetPlayerChildren(currentNode));
        }

What if instead, we cached those choices in Next()

        public DialogueNode[] PlayerChildren { get; private set; }
        
        public void Next()
        {
            PlayerChildren = FilterOnCondition(currentDialogue.GetPlayerChildren(currentNode)).ToArray();
            
            if (PlayerChildren.Length > 0)
            {
                isChoosing = true;
                TriggerExitAction();
                onConversationUpdated();
                return;
            }

And then the UI can retrieve the PlayerChildren (which are already cached) reducing the IEnumerable/Filter done.

I’m supposed to be working on this but I bumped into another bug in one of my systems… will be back whenever I can, sorry for the delay :slight_smile:

Hello again @Brian_Trotter - I made the modifications as per your suggestion. The initial fire up of a heavy dialogue is, unfortunately, still slow (it always gets faster post the first time). Here’s what I tried doing so far:

using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System;
using RPG.Core; // for the Evaluator of the Dialogues (line 133)
using RPG.Movement;
using GameDevTV.Utils;

namespace RPG.Dialogue 
{
    public class PlayerConversant : MonoBehaviour, IAction, IPredicateEvaluator
    {
        [SerializeField] string playerName;

        Dialogue currentDialogue;
        DialogueNode currentNode = null;
        AIConversant currentConversant = null;  // the conversation being occured between the Player and the AI (to trigger events during the conversation for instance)
        bool isChoosing = false;

        // OUT OF COURSE CONTENT ------------------------------------------------------------------------------------------------
        private Dialogue targetDialogue;    // the Dialogue our Player is aiming to go to, for conversation purposes
        private AIConversant targetConversant;  // the Target Conversant of our Quest Giver = Player Conversant
        public float acceptanceRadius; // the minimum distance our Player has to be from the NPC before they can talk
        // ----------------------------------------------------------------------------------------------------------------------

        // ------------------------- TEST ZONE - 19/9/2025 ------------------------------

        private List<IPredicateEvaluator> evaluators = new List<IPredicateEvaluator>();

        // OPTIMIZATION: Cache player choices to avoid duplicate filtering
        public DialogueNode[] PlayerChildren { get; private set; } = new DialogueNode[0];

        private void Awake()
        {
            evaluators = GetEvaluators().ToList();
        }

        // ------------------------------------------------------------------------------

        public event Action onConversationUpdated;

        public void StartDialogue(AIConversant newConversant, Dialogue newDialogue)
        {
            // TEST (Delete if failed):
            if (newConversant == currentConversant) return;

            currentConversant = newConversant;
            currentDialogue = newDialogue;
            currentNode = currentDialogue.GetRootNode();
            PlayerChildren = new DialogueNode[0]; // Clear cached choices for new dialogue
            TriggerEnterAction();
            onConversationUpdated();    // this line subscribes our Dialogue to the event Action we created above, so that it follows along when something new to that event occurs
        }

        public void Quit()
        {
            // When the conversation is over, or the 'X' button is clicked, this function is called

            currentDialogue = null; // no node available
            PlayerChildren = new DialogueNode[0]; // Clear cached choices when quitting
            TriggerExitAction();

            // Test
         /* Debug.Log("Quit Dialogue - Before TriggerExitAction");
            TriggerExitAction();
            Debug.Log("Quit Dialogue - After TriggerExitAction"); */

            currentNode = null; // no Nodes available to process through
            isChoosing = false; // no choices of conversations available
            currentConversant = null;   // quitting the AI Conversant Dialogue
            onConversationUpdated();    // makes the dialogue hide itself when the chat is over
        }

        // Current Conversant (lambda, 'on the fly' function), which will deactivate the NPC walking around when our player starts talking to him:
        public AIConversant GetCurrentConversant() => currentConversant;

        public DialogueNode GetCurrentNode() => currentNode;

        public bool IsActive()
        {
            // This function (a 'getter' function) returns whether we have a current Dialogue to refer to (after the 2 seconds of the IEnumerator)
            return currentDialogue != null;
        }

        public bool IsChoosing()
        {
            // getter
            return isChoosing;
        }

        public string GetText()
        {
            // getter
            if (currentDialogue == null)
            {
                return "";
            }

            return currentNode.GetText();
        }

        public IEnumerable<DialogueNode> GetChoices()
        {
            // Return cached player choices instead of filtering again
            return PlayerChildren;
        }

        public void SelectChoice(DialogueNode chosenNode)
        {
            currentNode = chosenNode;
            TriggerEnterAction();
            isChoosing = false;

            // OPTIONAL: Implement this line only if you don't want the next conversation to display what your button just had written on it:
            Next();
            // if we didnt call 'Next()', which calls 'onConversationUpdated()' event subscription, we could've called it here instead
        }

        public void Next()
        {
            // OPTIMIZATION: Cache player choices to avoid duplicate filtering
            PlayerChildren = FilterOnCondition(currentDialogue.GetPlayerChildren(currentNode)).ToArray();

            if (PlayerChildren.Length > 0)
            {
                isChoosing = true;
                TriggerExitAction();
                onConversationUpdated();
                return;
            }

            DialogueNode[] children = FilterOnCondition(currentDialogue.GetAIChildren(currentNode)).ToArray();  // filters our Player <-> Quest Giver responses based on the process of our Quests
            int randomIndex = UnityEngine.Random.Range(0, children.Count());    // UnityEngine is mentioned here because Random.Range comes from both UnityEngine and System (which we need for event Action), hence we need to specify which function we are calling
            TriggerExitAction();
            currentNode = children[randomIndex];
            TriggerEnterAction();
            onConversationUpdated();
        }

        public bool HasNext()
        {
            return FilterOnCondition(currentDialogue.GetAllChildren(currentNode)).Count() > 0;
        }

        private IEnumerable<DialogueNode> FilterOnCondition(IEnumerable<DialogueNode> inputNode)
        {
            // This function ensures we can play different Nodes on our dialogues, based on the Progress Status of our Quests (Start, Pending, Complete, etc)
            foreach (var node in inputNode)
            {
                // if (node.CheckCondition(GetEvaluators()))
                // ----- TEST ZONE - 19/9/2025 -------
                if (node.CheckCondition(evaluators))
                // -----------------------------------
                {
                    yield return node;  // if a condition (E.g: A quest has been done) is met, we include it in our filter, otherwise it's excluded from the Filter
                }
            }
        }

        private IEnumerable<IPredicateEvaluator> GetEvaluators()
        {
            // Get all IPredicateEvaluator components on this GameObject (player)
            List<IPredicateEvaluator> evaluators = new List<IPredicateEvaluator>(GetComponents<IPredicateEvaluator>());

            Debug.Log($"[PlayerConversant] Found {evaluators.Count} predicate evaluators on player");

            // Also include singleton instances that implement IPredicateEvaluator
            if (WantedLevelManager.Instance != null)
            {
                evaluators.Add(WantedLevelManager.Instance);
                Debug.Log("[PlayerConversant] Added WantedLevelManager singleton to predicate evaluators");
            }
            else
            {
                Debug.LogWarning("[PlayerConversant] WantedLevelManager.Instance is null!");
            }

            Debug.Log($"[PlayerConversant] Total predicate evaluators: {evaluators.Count}");
            return evaluators;
        }

        private void TriggerEnterAction()
        {
            if (currentNode != null)
            {
               TriggerActions(currentNode.GetOnEnterActions());
            }
        }

        private void TriggerExitAction()
        {
            if (currentNode != null)
            {
                TriggerActions(currentNode.GetOnExitActions());
            }
        }

        private void TriggerActions(List<string> actions)
        {
            if (actions == null || actions.Count == 0) return;

            foreach (string action in actions)
            {
                if (string.IsNullOrEmpty(action)) continue;

                // Trigger on NPC DialogueTriggers (original behavior)
                foreach (DialogueTrigger trigger in currentConversant.GetComponents<DialogueTrigger>())
                {
                    trigger.Trigger(action);
                }

                // Also trigger on Player DialogueTriggers (for player-initiated actions like bribes)
                foreach (DialogueTrigger trigger in GetComponents<DialogueTrigger>())
                {
                    trigger.Trigger(action);
                }
            }
        }

        public string GetCurrentConversantName()
        {
            // Return the name of the conversant currently speaking,
            // i.e: either the player, or who he is talking to
            if (isChoosing)
            {
                return playerName;
            }
            else
            {
                return currentConversant.GetName();
            }
        }

        public string GetTargetConversantName()
        {
            // Return the name of the NPC we're currently talking to
            if (currentConversant != null)
            {
                return currentConversant.GetName();
            }

            // Fallback to target conversant if no current conversation
            if (targetConversant != null)
            {
                return targetConversant.GetName();
            }

            return "";
        }

        // MORE OUT OF COURSE CONTENT (RUNNING TO CLICKED NPC BEFORE INTERACTING IN DIALOGUE WITH THEM) -----------------------------------------------------------------------------

        public void Cancel()
        {
            Quit(); // if you cancel a dialogue, you just Quit it...
            targetConversant = null;    // ... and you also ensure that you're not talking to anyone in-game
        }

        public void StartConversation(AIConversant conversant, Dialogue dialogue)
        {
            GetComponent<ActionSchedular>().StartAction(this);
            targetConversant = conversant;
            targetDialogue = dialogue;
        }

        void Update()
        {
            if (!targetConversant) return;
            if (Vector3.Distance(transform.position, targetConversant.transform.position) > acceptanceRadius)
            {
                // transform.LookAt(targetConversant.transform);
                GetComponent<Mover>().MoveTo(targetConversant.transform.position, 1.0f);
            }

            else
            {
                GetComponent<Mover>().Cancel();
                StartDialogue(targetConversant, targetDialogue);
                targetConversant = null;    // stops our player from being a creepy stalker that creepily follows the NPCs around
            }
        }

        // -----------------------------------------------------------------------------------------------------------

        // IPredicateEvaluator implementation
        public bool? Evaluate(EPredicate predicate, string[] parameters)
        {
            if (predicate == EPredicate.NPCConversantRejectedBribe)
            {
                bool result = false;

                if (currentConversant.GetComponent<RPG.States.Enemies.EnemyStateMachine>())
                {
                    result = !currentConversant.GetComponent<RPG.States.Enemies.EnemyStateMachine>().GetCanAcceptBribery();
                }

                // Animals too. Retarded I know but just get the flow running (since they can snitch too)
                if (currentConversant.GetComponent<AnimalStateMachine>())
                {
                    result = !currentConversant.GetComponent<AnimalStateMachine>().GetCanAcceptBribery();
                }

                return result;
            }

            if (predicate == EPredicate.PlayerHasDoubleCombatLevelOfSnitch)
            {
                // Get player's combat level from SkillStore
                RPG.Skills.SkillStore playerSkillStore = GetComponent<RPG.Skills.SkillStore>();
                if (playerSkillStore == null)
                {
                    Debug.LogWarning("[PlayerConversant] Player doesn't have SkillStore component for intimidation check");
                    return false;
                }

                int playerCombatLevel = playerSkillStore.GetCombatLevel();

                // Get NPC's starting level from BaseStats
                int npcLevel = 1; // Default fallback
                
                if (currentConversant.GetComponent<RPG.States.Enemies.EnemyStateMachine>())
                {
                    RPG.Stats.BaseStats npcBaseStats = currentConversant.GetComponent<RPG.States.Enemies.EnemyStateMachine>().GetComponent<RPG.Stats.BaseStats>();
                    if (npcBaseStats != null)
                    {
                        npcLevel = npcBaseStats.GetStartingLevel();
                    }
                }
                else if (currentConversant.GetComponent<AnimalStateMachine>())
                {
                    RPG.Stats.BaseStats npcBaseStats = currentConversant.GetComponent<AnimalStateMachine>().GetComponent<RPG.Stats.BaseStats>();
                    if (npcBaseStats != null)
                    {
                        npcLevel = npcBaseStats.GetStartingLevel();
                    }
                }

                // Check if player has at least double the combat level for intimidation
                bool canIntimidate = playerCombatLevel >= (npcLevel * 2);
                
                Debug.Log($"[PlayerConversant] Intimidation check: Player combat level {playerCombatLevel} vs NPC level {npcLevel} (x2 = {npcLevel * 2}) - Can intimidate: {canIntimidate}");
                
                return canIntimidate;
            }

            return null;
        }
    }
}

Any other ideas? At this point in time I am not sure what else can be done. It’s fast in the editor, but extremely slow in the game build

@Brian_Trotter I noticed something in my game build: the dialogue startup is slow for massive dialogues when they are called the first time but are fast the 2nd time onwards. HOW ABOUT… we fire up the dialogues whilst the game is still loading the scene and then shut them down during the loading screen? How can I do this one?