What would be the most fitting approach for a story system?

I am looking to build a story driven system on top of my current player system.
it’s main goals are to be able to make characters “talk”, “rotate” and “move”, but in an expandable way.

My first though was to make a StoryCharacter.cs script with the .Say(string) and .Move(Vector3) functions, and have a second Story.cs script that holds these characters and calls out their actions.
but here is the deal:
Unity originally calls scripts each frames, so in order to have a script such as:

character.Say("hey");
character.Move(new Vector3(0f, 0f, 0f));
character.Say("yay!");

I need to run this system in the background or a different thread.
the only 2 possible ways to achieve this I know of are:

  1. Coroutines
  2. Jobs (burst)

Using coroutines is pretty easy, as I can simply make the function inside story.cs a IEnumerator and the character will work in the background.
however this blocks a lot of frame dependent tools, like moving with Time.DeltaTime which seems to threaten the extensibility of this.

and jobs seems like an over engineering solution to a system that could look much better.

so,
Are there any better ways to achieve this?
how would you implement such system?
Thanks in advance!

Basic Coroutine+Job System setup for any such system could look something like this code below. It compiles, it has job dependencies in place, it shows to work with jobs and their basic data types etc. but it is not a story system yet.

For this to become some kind of story system you first would need to define
What kind of data structure constitutes a story for you?

Because story, in CS terms, can be anything from a collection of indices to plain text.

using System.Collections;
using UnityEngine;
using Unity.Jobs;
using Unity.Collections;
using Unity.Burst;

public class StorySystem : MonoBehaviour
{
    NativeList<FixedString128Bytes> _characterNames;
    NativeList<Vector3> _characterPosition;
    public JobHandle Dependency;

    void OnEnable ()
    {
        _characterNames = new ( initialCapacity:3 , Allocator.Persistent );
        _characterPosition = new ( initialCapacity:3 , Allocator.Persistent );
        {
            _characterNames.Add( "Winnie the Pooh" );
            _characterPosition.Add( new Vector3(0,0,0) );
        }
        {
            _characterNames.Add( "Piglet" );
            _characterPosition.Add( new Vector3(10,0,0) );
        }
        {
            _characterNames.Add( "Tigger" );
            _characterPosition.Add( new Vector3(-10,0,0) );
        }

        StartCoroutine( UpdateRoutine() );
    }

    void OnDisable ()
    {
        Dependency.Complete();
        if( _characterNames.IsCreated ) _characterNames.Dispose();
        if( _characterPosition.IsCreated ) _characterPosition.Dispose();
    }

    IEnumerator UpdateRoutine ()
    {
        var step = new WaitForFixedUpdate();
        while( true )
        {
            // complete existing job:
            Dependency.Complete();

            // schedule new jobs:
            var moveJob = new CharacterMoveJob{
                GameTime = Time.time ,
                CharacterNames = _characterNames.AsArray() ,
                CharacterPositions = _characterPosition.AsArray() ,
            };
            Dependency = moveJob.Schedule( Dependency );

            // step
            yield return step;
        }
    }

    #if UNITY_EDITOR
    void OnDrawGizmos ()
    {
        if( Application.isPlaying )
        {
            Dependency.Complete();// can't read this data without completing this job first, unfortunately
            
            Gizmos.color = Color.yellow;
            UnityEditor.Handles.color = Color.yellow;
            int length = _characterNames.Length;
            for( int i=0 ; i<length ; i++ )
            {
                Vector3 charPos = _characterPosition[i];
                string charName = _characterNames[i].ToString();

                Gizmos.DrawSphere( charPos , 1f );
                UnityEditor.Handles.Label( charPos , charName );
            }
        }
    }
    #endif

}

[BurstCompile]
public struct CharacterMoveJob : IJob
{
    public float GameTime;
    public NativeArray<FixedString128Bytes> CharacterNames;
    public NativeArray<Vector3> CharacterPositions;
    void IJob.Execute ()
    {
        var random = new Unity.Mathematics.Random( (uint)Mathf.Abs(GameTime.GetHashCode()) );
        int length = CharacterPositions.Length;
        for( int i=0 ; i<length ; i++ )
        {
            var characterName = CharacterNames[i];

            // random wander
            Vector2 dir = random.NextFloat2Direction();
            CharacterPositions[i] += new Vector3( dir.x , 0 , dir.y );
        }
    }
}

Also

This Object-Oriented style of writing is not super compatible with IJob where data is usually layed out either in arrays or hashmaps.

Thank you @andrew-lukasik for the answer!
I appreciate the example :slight_smile:
I ended up thinking about which architecture suits me best,
and I believe object oriented programming flows much better in my blood.

for future reference,
My solution was to create an Action object for any story action (walking, looking, talking)

public interface StoryCommand
{
    public bool Execute();
}

and have an execute function inside it that is responsible for handling the request.

public class say : StoryCommand
{
    string _text;
    public say(string text)
    {
        _text = text;
    }

    public bool Execute()
    {
        Debug.Log(_text);
        return true;
    }
}
public class goTo : StoryCommand
{
    Vector3 _targetPosition;
    Transform _character;
    readonly float _speed;

    public goTo(Transform character, Vector3 position, float speed = 4f)
    {
        
        _character = character;
        _targetPosition = position;
        _speed = speed;
    }

    public bool Execute()
    {
        _character.position = Vector3.MoveTowards(_character.position, _targetPosition, Time.deltaTime * _speed);
        if (Vector3.Distance(_character.position, _targetPosition) < 0.4f)
            return true;
        return false;
    }
}

then I have a global manager (story executer) that when a chapter is started, it calls each function in the queue one after the other has finished.
if the execute() returns true I Dequeue to the next story action, if not I continue in the next frame with the current one.

public StoryCommand _currentAction;
public Queue<StoryCommand> _story = new Queue<StoryCommand>();
void Update()
{
    if (--------- null checks)
        return;

    if (_currentAction.Execute())
    {
        if (_story.Count is 0)
        {
            finishedChapter?.Invoke(_currentChapter);
            _currentChapter = Chapters.empty;
            chapterStarted = false;
        }
        else
            _currentAction = _story.Dequeue();
    }
}

and inside the character script all I need to do is add a new action to the queue:

public void lookAt(Vector3 targetRotation)
{
    _storyExecuter.addAction(new lookAt(transform, targetRotation));
}

and my main story script remains the same:

public void startStory()
{
    Dictionary<Characters, StoryCharacter> storyCharacters = GameObject.FindObjectsOfType<StoryCharacter>().ToDictionary(sc => sc.characterStory.character);
    StoryCharacter bigFoot = storyCharacters[Characters.Bigfoot];
    storyExecuter.setChapter(Chapters.start);

    bigFoot.Say("hey");
    bigFoot.goTo(new Vector3(10, bigFoot.transform.position.y, 1));
    bigFoot.Say("I think it worked!");
    bigFoot.goTo(new Vector3(1, bigFoot.transform.position.y, 1));
    Vector3 forwardTransform = bigFoot.transform.forward;
    bigFoot.lookAt(new Vector3(forwardTransform.x, 20f, forwardTransform.z));

    storyExecuter.startChapter();
}

I may want to add new actions to allow combination with TimeLine, and maybe in the future (if it won’t break too much) also involve data oriented actions if I will need.

would love to hear what you think,
so far this seems to be pretty solid, and hopefully will be flexible when the project grows bigger. :smiley:
Cheers!