How would you design the script structure / story-system for managing a rich story?

Hey!
I am developing a game with a quite rich story, many things can happen depending on what the player do. Mostly the story is linear but often there could be variations like some things happening in several possible orders, or some path to chose.

I opted for a design where I wrote many coroutines linking to each other when they end. The end of each coroutine could be for instance a timeout or an event (the player did something particular). Here is a simple example:

  • Coroutine 1: make narrator speak and then link to coroutine 2
  • Coroutine 2: reveal two doors to the player and then start both Coroutine 3 and Coroutine 4
  • Coroutine 3: wait for player to open left door, if this happens, stop coroutine 4 and make narrator speak
  • Coroutine 4: wait for player to open right door, if this happens, stop coroutine 3 and make narrator speak

The issues I see with this kind of design are: 1) I am unsure whether it is easy to maintain for a rich story as it is not modular, 2) I am constantly waiting for events with a while(event not triggered) {yield return null;} like when waiting for the doors to open in the example, this looks suboptimal as I could benefit from an event system instead.

Do you have any advice on this?
Many thanks!

I’d avoid using coroutines if I were you. In my experience having multiple coroutines running at the same time will lead to a big mess that’ll be difficult to debug, and in this case, I don’t see what the purpose is anyway because it seems like you just as well perform the same logic without coroutines.

Well, many things are timed, like for instance when the narrator is speaking, there is a WaitForSeconds() in between each sentence. I thought coroutines would be a clear way writing the story. I don’t see how I could write this without coroutines though, could you be more specific?

Timeline is one option.

A delay can go right inside of Update() by using an extra timing variable, for example:

void Update() {
  if player pressed space: delayInSeconds=0;
  if player did event 1: begin branch 1 of this dialogue
  ...

  delayInSeconds-=Time.deltaTime; // no player actions, so count down timer
  if(delayInSeconds<0) {
    displayNextSpeech();
    delayInSeconds=4;
  }

Hopefully you can see how always checks for what the player is doing, before counting down the dialogue timer.

But the big thing I see is writing new code for every area. Eventually it’s easier to write one generic dialog-handler: it knows you’re on dialog tree N, with player actions A, B and C going to trees N1, N2 and N3 … . So eventually you’d make a brass key, give it text “a brass key falls out of the box” and would attach that to the ItemList for a certain action.

And just to be clear, a single coroutine is pretty manageable. It’s when you have multiple coroutines running at the same time across different objects where things get messy.

The part of your description that I’m most perplexed about is Coroutines 3 and 4. Why do you need two coroutines to sit there doing nothing while the player is picking a door to open. Surely, to open a door, the player would need to press a key, or click, or walk close to the door (trigger collider). Then one of these events could trigger the next phase. What’s the point of “waiting” for the player?

If I were to do something like this I’d probably just write my own system that I have more control over. Coroutines are really meant for fire-and-forget use cases. Whereas you might want a sequence to play out, but also allow the player to skip their way through it if needed, or maybe even go back like many visual novel frameworks let you do.

Really this is one of those things where you’d need multiple systems working together. You need the back end to run the sequences, the data structures for said sequences and also the editor tooling to design them. Probably need a separate, lower-level system for the diverging story sequences too (which really boils down to managing data).

Indeed, I have to make sure each coroutine is correctly linked to the next one and I’m afraid I could make mistakes. Though I cannot see a way to avoid having to tie things carefully because the structure of the story itself is demanding (see example below of a full coroutine).

On that point I totally agree, I could use events to just trigger some coroutines like the door. I’ll do this as waiting coroutines seems pointless.

To make things more clear, I will have probably hundreds of sequences. Each sequence is a coroutine and is simply the succession of several sub-coroutines. The sub-coroutines are methods that take some time, like animations or narrotor saying something. Here is a more complete example of what a sequence / coroutine looks like:

private IEnumerator SampleCoroutine()
{
    // Sometimes there could be individual dialogues
    yield return Say(audioObject);

    // Sometimes there could be sequences of dialogues
    foreach (AudioObject a in anAudioObjectContainer)
    {
        yield return Say(a);
    }

    // Sometimes there could be simple pauses
    yield return new WaitForSeconds(3.0f);

    // Sometimes the story-system just takes control of the camera to show something
    yield return camera.DoSomething()

    // Sometimes the story-system enables / disables some things
    player.EnableJump();
    bridge.DisableFence();

    // Sometimes there could be animations
    yield return bridge.AnimateRoad();

    // And eventually each sequence may lead to another sequence
    StartCoroutine(AnotherSequence());
    // Allthough I usually do more than a simple startcoroutine,
    // like saving the current coroutine's name so that I can save the game for instance
}

This sounds appealing! Though the best approach I could think of is coroutines implementing the sequences like the example just above. Do you have general ideas on how to build the backend system?

On that point, I thought coroutines like in my previous post are very clear to me about what happens. But it’s worth mentionning I am on my own coding the game.

A coroutine is just a clever use of an Enumerator state machine that’s processed by the overarching Unity engine player loop. There’s nothing particularly fancy about them. It’d be trivial to implement a similar system and then be able to expand upon it as necessary. Such as pausing, fast forwarding, rewinding, etc, none of which you can do with coroutines.

Though I would probably implement some kind of sequence when I can stack multiple series of actions on top of one another.

That sounds fun to code actually…

I’m also solo dev too, and being one doesn’t preclude you from developing editor tools to greatly speed up or automate the development of certain kinds of content.

Some tooling only requires a small amount of up-front time, but saves you dozens, if not hundreds of hours of time. And there are plenty of set ups you cannot do without some form of editor tooling. It’s why plugins like Odin Inspector are so highly regarded - they make things possible that weren’t before, and save you tons upon tons of time.

Fair point, my approach may produce unnecessary overload then?

There I got lost, do you have a concrete example of how you would do this without coroutines? I think I lack the experience because I don’t see how to implement this :face_with_spiral_eyes:

True, I might be on the lazy side, this side that makes you “not wanting to implement something that would actually save time” :smile: Thanks for the advice!

That would have to be determined by the profiler.

I mean, as I mentioned, it’s simple to set up your own system to start off with something simple, that can then easily be built up.

For example, lets outline some basic data objects first:

public interface ISequenceEntry
{
    void OnEnterEntry();

    float EntryTime { get; }
}

[System.Serializable]
public class SequenceEntry : ISequenceEntry
{
    [SerializeField]
    private float _entryTime = 2f;

    public float EntryTime => _entryTime;
  
    public virtual void OnEnterEntry()
    {
        Debug.Log($"Waiting {_entryTime} seconds...");
    }
}

[System.Serializable]
public class DialogueSequenceEntry : SequenceEntry
{
    [SerializeField, TextArea(2, 5)]
    private string _entryDialogue;

    public string EntryDialogue => _entryDialogue;

    public override void OnEnterEntry()
    {
        Debug.Log(_entryDialogue);
    }
}

Then we create an object to hold a sequence with an internal enumerator class:

[System.Serializable]
public class DialogueSequence : IEnumerable<ISequenceEntry>
{
    #region Inspector Fields

    [SerializeReference]
    private List<ISequenceEntry> _dialogueEntries = new();

    #endregion

    #region Sequence Methods

    // more enumerator methods than you can poke a stick at

    public DialogueSequenceEnumerator GetEnumerator() => new DialogueSequenceEnumerator(this);

    IEnumerator<ISequenceEntry> IEnumerable<ISequenceEntry>.GetEnumerator() => GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    #endregion

    public class DialogueSequenceEnumerator : IEnumerator<ISequenceEntry>
    {
        private int _index = -1;
        private float _currentTime = 0f;

        private readonly DialogueSequence _dialogueSequence;

        public ISequenceEntry Current => _dialogueSequence._dialogueEntries[this._index];

        object IEnumerator.Current => this.Current;

        #region Constructor

        public DialogueSequenceEnumerator(DialogueSequence sequence)
        {
            _dialogueSequence = sequence;
            MoveToNextEntry();
        }

        #endregion

        /// <summary>
        /// Returns false when either at the end of the dialogue, or while
        /// the timer has not yet ticked over until the next entry.
        /// </summary>
        public bool MoveNext()
        {
            if (ReachedEnd() == true)
            {
                return false;
            }

            ISequenceEntry entry = this.Current;
            _currentTime += Time.deltaTime;
            if (_currentTime >= entry.EntryTime)
            {
                bool notAtEnd = MoveToNextEntry();
                return notAtEnd;
            }

            return false;
        }

        public bool ReachedEnd()
        {
            return _index >= _dialogueSequence._dialogueEntries.Count;
        }

        public void Reset()
        {
            _currentTime = 0f;
            _index = 0;
        }

        void IDisposable.Dispose() { }

        private bool MoveToNextEntry()
        {
            _index++;
            _currentTime = 0f;
            bool notAtEnd = ReachedEnd() == false;
            if (notAtEnd)
            {
                var entry = this.Current;
                entry.OnEnterEntry();
            }

            return notAtEnd;
        }
    }
}

Then you just need something to run the sequence:

public sealed class DialogueSequenceRunner : MonoBehaviour
{
    #region Inspector Fields

    [SerializeField]
    private DialogueSequence _dialogueSequence;

    #endregion

    #region Internal Members

    private DialogueSequence.DialogueSequenceEnumerator _dialogueEnumerator;

    #endregion

    #region Unity Callbacks

    private void Awake()
    {
        _dialogueEnumerator = _dialogueSequence.GetEnumerator();
    }

    private void Update()
    {
        if (_dialogueEnumerator.ReachedEnd() == false)
        {
            _dialogueEnumerator.MoveNext();
        }
    }

    #endregion
}

Then we just set some stuff up in the inspector and run it:
9619991--1365677--upload_2024-2-2_23-47-7.png

Hey presto! You have a base that can easily expanded upon as needed.

I used an enumerator object here as that’s basically how co-routines work. But were I actually to spend more than 10 minutes on it, I would take a different foundational approach (so as to support rewinding, pausing, etc).

I feel like you’re too laser focused on coroutines for some reason. Again, they are just enumerator state machine objects. There’s nothing fancy about them. Except you can’t extend how they work at all.

If you build your own, you have full control and can design it precisely to your needs.

And this would pretty much be how Unity’s coroutines could be reimplemented:

public sealed class Coroutine
{
    static readonly List<Coroutine> runningCoroutines = new();

    readonly Stack<object> yielding = new();

    public bool IsFinished => yielding.Count == 0;

    Coroutine(IEnumerator routine) => yielding.Push(routine);

    public static Coroutine Start(IEnumerator enumerator)
    {
        if(runningCoroutines.Count == 0)
        {
            Updater.update += UpdateRunningCoroutines;
        }

        var coroutine = new Coroutine(enumerator);
        runningCoroutines.Add(coroutine);
        return coroutine;
    }

    public static void Stop(Coroutine coroutine)
    {
        if(runningCoroutines.Remove(coroutine) && runningCoroutines.Count == 0)
        {
            Updater.update -= UpdateRunningCoroutines;
        }
    }

    static void UpdateRunningCoroutines()
    {
        for(int i = 0; i < runningCoroutines.Count; i++)
        {
            if(!runningCoroutines[i].MoveNext())
            {
                runningCoroutines.RemoveAt(i);
            }
        }

        if(runningCoroutines.Count == 0)
        {
            Updater.update -= UpdateRunningCoroutines;
        }
    }

    bool MoveNext()
    {
        if(yielding.Count == 0)
        {
            return false;
        }

        var current = yielding.Peek();
        if(current is IEnumerator enumerator)
        {
            if(enumerator.MoveNext())
            {
                yielding.Push(enumerator.Current);
                return true;
            }
        }
        else if(current is YieldInstruction yieldInstruction && yieldInstruction.KeepWaiting)
        {
            return true;
        }

        yielding.Pop();
        return yielding.Count > 0;
    }
}

9620162--1365686--upload_2024-2-2_8-59-50.jpeg
NO! NO! NO!

:wink:

Another thing I would suggest is to look in to state machines, or similar patterns, like decision trees. I understand you want to use coroutines for timing things, but I would avoid any situation where the events of your game unfold through some crazy network of coroutines triggering other coroutines down the line. Rather, I would try to make some kind of single, authoritative data structure to store the game state that determines your story. Separate your game logic from the nitty-gritty implementation details.

There are some free packages you could start from, such as Fungus:

Or Inkle:

Even if you don’t end up using either of those, you could review them for structure because it is almost certain that they have already solved all the same problems you are trying to solve.

Wow, many thanks for the example, it’s a lot clearer and very helpful! :slight_smile: Gives me a lot to think about. A few questions:

  • This means I should define a specific sequence entry class (like DialogueSequenceEntry in your example) that inherits from the interface ISequenceEntry for each kind of entry I’d like to see in the story, right? Like “saying a dialogue”, “moving the camera”, “triggering a VFX”, for instance.

If this is the case, then I guess that it makes things more systematic: it forces me to use a shared interface for each type of entry which is good practice for not forgetting things like what happens on starting the entry or what happens at the end. That’s what we’d like to achieve I guess?
I think it is inline with what you suggested @kdgalla : [quote=“kdgalla, post:14, topic: 939099, username:kdgalla”]
Separate your game logic from the nitty-gritty implementation details.
[/quote]

  • In your example, I see you use a timer for the dialogue entry (which is what I wanted to do, I won’t complain :p), but what if I’d like to wait for the end of something, would you rely on events then? But then since it is called on update it would be just the same as checking whether an event occured or not each frame?

  • [quote=“spiney199, post:12, topic: 939099, username:spiney199”]
    I would take a different foundational approach (so as to support rewinding, pausing, etc)
    [/quote]
    Linked to the previous question maybe. Would you take a different framework that is like not running on update but relying on some sort of event system that could trigger “play” to activate something in update but also “stop” it?

No I’d still have it running in update. Nothing wrong with Update. But it would just proceed the state of the sequence, which itself determines when to move to the next bit of the sequence.

Well, say I create an entry type that just triggers an animation and I want to wait for the end of the animation before moving to the next entry. It could look like this:

[System.Serializable]
public class AnimationSequenceEntry : ISequenceEntry
{
    [SerializeField] private AnimationController animationController;

    public float EntryDuration => animationController.GetAnimationDuration();

    public void OnEnterEntry()
    {
        animationController.TriggerAnimation();
    }
}

But then I would be incrementing a timer on update while waiting for the end of the animation, isn’t it suboptimal?

In what way? You need to substantiate what about the approach is exactly sub-optimal, and not just based upon a feeling or personal preference.

Sorry I wasn’t specific enough. I mean suboptimal in the sense that I would be “counting the time for nothing” in the Update loop, while I could rely on some sort of event (triggered at the end of the animation) to move to the next entry.

Simply relying on the MoveNext() callback in the update forces to check on each update whether the animation timer reached the limit or not. I wonder how one could bypass the check and put it back after the animation.

This is related to this remark where @kdgalla points out that sometimes a check on each update could be avoided: