Lets ignore saving the game state right now.
And instead I ask: How do all the objects in your scene know what state they’re in currently?
When an event occurs in game that causes the narrative to branch, how are all the objects in scene aware of which branch you’re on? Like is there some narrative tree that every object can check to know which point in the narrative it’s in and they behave based on that? Or is it when an event occurs, objects are toggled to a specific state (enabled, disabled, components added/removed, prefabs spawned, etc).
…
Now because I don’t know how your game is structured I’m not sure how much this following is going to help. It all hinges on how you setup your game. But I’ll show you how we did it in one of our games.
In our games there are a lot of different things that go on and the states of objects narratively and gameplay structure wise. But I’ll use some of it to give an example… here is a puzzle we call “the shy bathers”:
The puzzle can be in 1 of 5 states based on the last thing you’ve done to it. You can see the 5 states on the right defined in our ‘I_TriggerSequence’ script.
As you interact with the puzzle and successfully complete a state, the I_TriggerSequence is signaled to proceed forward one step. And it signals to that state that it’s entered it.
So here is State3:
So when it gets triggered the ‘I_Trigger’ script fires off and it performs all of those actions. It destroys some gameobjects, enables some others, putting the whole puzzle into the state it needs to be for it. In this case it’s destroying some unused ‘hijack’ (these are script that hijack an interaction point and modify its behaviour), then enables its own hijack (in this case the matron, so when you click on her you get dialogue relative to that state of the puzzle), enables a combinationlock (the puzzle itself), and enables an interactable called ‘e.Message Unsolved’ which is just an interaction point that prompts you if you try to pick up the goal item that it can’t be yet because it’s locked.
…
You’ll also notice though this ‘Cutscene Event’ script with an trigger/event on it (these are like UnityEvent, but our own custom version, this was all developed before UnityEvent was really a thing. It also does more than just call methods on scripts… it can call method/disable/enable/destroy/trigger all/more). The trigger/event is called “On Skip Index”.
This is called if the Cutscene Manager skips pass this state. This way the state can do any clean up. Effectively it does a quick pass of what should have happened if you went through this state actively.
What happens is that the Cutscene Manager:
This is what saves our game state. It implements an interface called “IPersistentObject”:
public interface IPersistentObject
{
/// <summary>
/// The earliest load event, this occurs immediately after the scene has loaded.
/// Awake has been called on all scene native objects, but Start has not been called yet.
/// </summary>
/// <param name="data"></param>
/// <param name="reason"></param>
void OnSceneLoaded(ScenarioPersistentData data, LoadReason reason);
/// <summary>
/// Signals that the object should load its state. This usually only happens when a scene
/// is loaded. This will occur as part of the Start action if implemented as a Mixin,
/// otherwise it'll occur after all Starts.
/// Use 'OnSceneLoaded' if you need to occur before any Start has been called.
/// </summary>
/// <param name="data">The data object used for loading.</param>
/// <param name="reason">The reason the load occurred.</param>
/// <param name="status">The current LoadStatus of the ScenarioController.</param>
void OnLoad(ScenarioPersistentData data, LoadReason reason, LoadStatus status);
/// <summary>
/// Signals that the object should save its state as some major change is occurring.
/// Either the player requested to save, or the scene is transitioning.
/// </summary>
/// <param name="data"></param>
/// <param name="reason"></param>
void OnSave(ScenarioPersistentData data, SaveReason reason);
}
Basically when a scene is loaded (note that in our game you can back track between scenes… so state doesn’t just have to be saved between playthroughs, but also when traversing the world between different regions of it). So basically when a scene is loaded all objects in the scene that implement this interface have these methods called on them. OnSceneLoaded to signal the scene has loaded and we’re almost ready to start the load of the state process (think of like ‘Awake’, but for save state). The OnLoad occurs just after Start, this is the proper load complete (think of like Start, but for save state). And lastly ‘OnSave’ which signals to everything that it needs to save its data.
Note that each has a “ScenarioPersistentData” object passed in and some enums for status information. This ScenarioPersistentData is a container object that an individual object can stick its state info (it’s what will get serialized and saved to disk).
So in that CutsceneSequenceManager script it had a “UID”, that’s what it uses to identify itself in the ScenarioPersistentData, here you can see its implementation of IPersistentObject:
#region IPersistentObject Interface
void IPersistentObject.OnSceneLoaded(ScenarioPersistentData data, LoadReason reason)
{
if (!_uid.HasValue) return;
Token token;
if (!data.TryGetData<Token>(_uid.ToString(), out token) || token == null) return;
var seq = this.GetComponent<i_TriggerSequence>();
if (seq == null) return;
seq.CurrentIndex = token.SequenceIndex;
}
void IPersistentObject.OnLoad(ScenarioPersistentData data, LoadReason reason, LoadStatus status)
{
if (status == LoadStatus.SceneLoaded) return; //was handled in early load
if (!_uid.HasValue) return;
Token token;
if (!data.TryGetData<Token>(_uid.ToString(), out token) || token == null) return;
var seq = this.GetComponent<i_TriggerSequence>();
if (seq == null) return;
seq.CurrentIndex = token.SequenceIndex;
}
void IPersistentObject.OnSave(ScenarioPersistentData data, SaveReason reason)
{
if (!_uid.HasValue) return;
var seq = this.GetComponent<i_TriggerSequence>();
if (seq == null) return;
var token = new Token();
token.SequenceIndex = seq.CurrentIndex;
data.SetData<Token>(_uid.ToString(), token);
}
#endregion
So really all its doing is storeing an “index” into the data container with a key that is its UID. When the game loads this will set its index in the OnSceneLoaded method (before Start). Then on Start it does this:
protected override void Start()
{
base.Start();
var seq = this.GetComponent<i_TriggerSequence>();
if (seq.CurrentIndex > 0)
{
if (_ignoreSkipIfCompleted && seq.CurrentIndex >= seq.TriggerSequence.Count)
{
//do nothing
_onSequenceLoadedCompleted.ActivateTrigger(this, null);
}
else
{
int c = Mathf.Min(seq.CurrentIndex, seq.TriggerSequence.Targets.Count);
for (int i = 0; i < c; i++)
{
var ev = ObjUtil.GetAsFromSource<CutsceneEvent>(seq.TriggerSequence.Targets[i].Target);
if (ev != null) ev.OnSkipIndex.ActivateTrigger(ev, null);
}
if(seq.CurrentIndex >= seq.TriggerSequence.Count)
{
_onSequenceLoadedCompleted.ActivateTrigger(this, null);
}
}
}
}
Basically it grabs all of those CutsceneEvents and if the index of that CutsceneEvent is BEFORE the current index, it calls “OnSkipIndex” on it. Allowing that state to perform whatever it should have done if it actively was stepped through. Allowing this puzzle to configure itself to the state it needs to be.
…
Of course this specifically is a linear thing. No branching in this example.
But branching can be handled number of ways. Because we don’t actually represent the entire structure of the game as a massive god object. But instead as small little set pieces (like this puzzle is just a single set piece). A set piece can lead into another set piece.
So take this safe here:
It contains a script called “PersistentStateMachine”.
Basically this just has a UID and index again like the CutsceneSequenceManager did. BUT instead of the index representing a sequence, it just represents an arbitrary state index.
All it does in the Start event is trigger the state that matches its current index:
protected override void Start()
{
base.Start();
if (_stateTriggers != null && _currentState >= 0 && _currentState < _stateTriggers.Length)
{
_stateTriggers[_currentState].ActivateTrigger(this, null);
}
}
Anything can tell this thing to change its state, and when it saves it’ll remember which state its in.
So lets say you have a sequence that branches at the end… really what would happen is in the final step of the sequence (say when you solve the bather puzzle you got to pick between 2 choices)… what would happen is there’d be a “PersistentStateMachine” out there that had 3 states:
0 - not used yet
1 - choice 1
2 - choice 2
Once solved the puzzle would switch this persistent state machine to the index it should be in for its state.
When the game loads next time the puzzle just goes through its sequence disabling itself. And then this state machine would load into its state and kick off whatever sequence it needs to do (maybe choice 1 unlocks a door, and choice 2 unlocks a window).
Mechanical branching done.
…
As for a stronger narrative structure. Like we’re talking branching dialogue trees and the sort (like mass effect or something). This would NOT be a good choice for that.
For that… you’d likely have a “script” (as in like a actual narration script). Maybe in xml or something. The structure of that script would break out all the dialogue trees. Each node in the dialogue tree would get a UID associated with it.
Each character you could talk to would have a persistent token that stores which specific node it’s on (by the uid). When you interacted with that person, it’d just look at where it is in its narrative tree by that uid and just start there.
If there is multiple starting places because context effects them. Then you’d just have a context tree which describes all the possible contexts. And you’d have the dialogue tree node associated with the context tree. These relationships would be saved instead. Then when you interacted you’d calculate which context is significant, and then start in the dialogue tree at the node associated with that context.