Overview

Unreal Engine 1 and Unreal Engine 2 supported save games by simply saving the entire level from memory to disk. While this method worked well in production, unfortunately any changes content developers made to the levels later on would not reflect over. To solve this problem, another way to represent saved games is to store just enough data, that the saved game is able to be restored by first loading the level and then applying saved game state data to all of the actors and objects in the level.

Developing with saved game states in mind

Due to the way saved game states work, you have to be very careful about what you destroy within the world. Once an actor is destroyed, it can no longer be picked up by the saved game state serializer because the actor is now gone. If the actor is transient, then it is generally not a problem. However, if the actor is level designer placed then when the level is reloaded and the saved game state data is applied, the level designer placed actor will not be affected as there is no data for it!

Player saves the game after playing a level for a while

For example purposes, only a console command has been added to this development kit gem. Obviously, your game will have a graphical user interface attached to it, however you can always call the same console command with the file name parameter anyways. Or the console command function could be made static as it is not dependent on executing within a particular instance of an actor or object (It calls PlayerController::ClientMessage(), but you can always get the local player controller by using Actor::GetALocalPlayerController()).

When the console command is executed, it kick starts the save game state process. First the SaveGameState object is instanced. The SaveGameState object handles iterating and serializing Actors, Kismet and Matinee. We then "scrub" the file name. Scrubbing the file name just ensures that there are no illegal characters added, although in this case only spaces were checked for. For a more robust scrubbing, you may want to consider ensuring that characters such as \, /, ?, ! are not in the file name. The scrubbing function also ensure that the file extension "sav" is also added if it hasn't been already. The SaveGameState is then asked to iterate and serialize Actors, Kismet and Matinee. Finally, if the SaveGameState was successfully saved to disk by BasicSaveObject(), then a message is sent to the player stating that the game was saved.

SaveGameStatePlayerController.uc

/**
* This exec function will save the game state to the file name provided.
*
* @param FileName File name to save the SaveGameState to
*/
exec function SaveGameState(string FileName)
{
local SaveGameState SaveGameState;
// Instance the save game state
SaveGameState = new () class'SaveGameState';
if (SaveGameState == None)
{
return;
}
// Scrub the file name
FileName = ScrubFileName(FileName);
// Ask the save game state to save the game
SaveGameState.SaveGameState();
// Serialize the save game state object onto disk
if (class'Engine'.static.BasicSaveObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
{
// If successful then send a message
ClientMessage("Saved game state to "$FileName$".", 'System');
}
}

Serialize the level name

The saved game state serializes the level name (or map file name) so that the saved game state knows which map it is to load when it, itself is loaded. Rather than storing this in another file such as the configuration file, it makes more sense to simply store it within the saved game state. The saved game state only needs to set the variables it wants saved, as BasicSaveObject() will perform the actual saving to disk for you. If any streaming levels are visible or have a load request pending, then they are saved into an array so that when the save game state is reloaded the streaming levels will be loaded straight away. This step also saves the current GameInfo class.

SaveGameState.uc

/**
* Saves the game state by serializing all of the actors that implement the SaveGameStateInterface, Kismet and Matinee.
*/
function SaveGameState()
{
local WorldInfo WorldInfo;
// Get the world info, abort if the world info could not be found
WorldInfo = class'WorldInfo'.static.GetWorldInfo();
if (WorldInfo == None)
{
return;
}
// Save the map file name
PersistentMapFileName= String(WorldInfo.GetPackageName());
// Save the currently streamed in map file names
if (WorldInfo.StreamingLevels.Length > 0)
{
// Iterate through the streaming levels
for (i = 0; i < WorldInfo.StreamingLevels.Length; ++i)
{
// Levels that are visible and has a load request pending should be included in the streaming levels list
if (WorldInfo.StreamingLevels[i] != None && (WorldInfo.StreamingLevels[i].bIsVisible || WorldInfo.StreamingLevels[i].bHasLoadRequestPending))
{
StreamingMapFileNames.AddItem(String(WorldInfo.StreamingLevels[i].PackageName));
}
}
}
// Save the game info class
GameInfoClassName = PathName(WorldInfo.Game.Class);
}

Serialize all actors that implement the SaveGameStateInterface as JSon

Only dynamic actors need to be serialized, so the iterator of choice here was DynamicActors. A filter for SaveGameStateInterface was also added, as that allows you to decide which dynamic actors need to be serialized and which do not. An interface is used here as it is easier to extend the save game state later on, since it is the actor which will serialize and deserialize the JSon data later on. When the Actor implementing SaveGameStateInterface is asked to serialize itself, it returns the encoded JSon string. The string is added the SerializedActorData array, which is then saved by BasicSaveObject().

SaveGameState.uc

/**
* Saves the game state by serializing all of the actors that implement the SaveGameStateInterface, Kismet and Matinee.
*/
function SaveGameState()
{
local WorldInfo WorldInfo;
local Actor Actor;
local String SerializedActorData;
local SaveGameStateInterface SaveGameStateInterface;
local int i;
// Get the world info, abort if the world info could not be found
WorldInfo = class'WorldInfo'.static.GetWorldInfo();
if (WorldInfo == None)
{
return;
}
// Save the persistent map file name
PersistentMapFileName = String(WorldInfo.GetPackageName());
// Save the currently streamed in map file names
if (WorldInfo.StreamingLevels.Length > 0)
{
// Iterate through the streaming levels
for (i = 0; i < WorldInfo.StreamingLevels.Length; ++i)
{
// Levels that are visible and has a load request pending should be included in the streaming levels list
if (WorldInfo.StreamingLevels[i] != None && (WorldInfo.StreamingLevels[i].bIsVisible || WorldInfo.StreamingLevels[i].bHasLoadRequestPending))
{
StreamingMapFileNames.AddItem(String(WorldInfo.StreamingLevels[i].PackageName));
}
}
}
// Save the game info class
GameInfoClassName = PathName(WorldInfo.Game.Class);
// Iterate through all of the actors that implement SaveGameStateInterface and ask them to serialize themselves
ForEach WorldInfo.DynamicActors(class'Actor', Actor, class'SaveGameStateInterface')
{
// Type cast to the SaveGameStateInterface
SaveGameStateInterface = SaveGameStateInterface(Actor);
if (SaveGameStateInterface != None)
{
// Serialize the actor
SerializedActorData = SaveGameStateInterface.Serialize();
// If the serialzed actor data is valid, then add it to the serialized world data array
if (SerializedActorData != "")
{
SerializedWorldData.AddItem(SerializedActorData);
}
}
}
}

The SaveGameStateInterface is very simple. It has two functions that every Actor implementing it, must implement. Serialize(), which handles serializing all of the data required by the Actor at loading time. And Deserialize() handles reading the JSon data saved at an earlier point in time, and restoring the appropriate values.

Serialize Kismet and Matinee as JSon

The save game state is also able to serialize Kismet Events and Kismet Variables. This allows game designers to implement a portion of the game using Kismet. This is done by iterating though the level's Kismet Events and Kismet variables and serializing each one.

Kismet Events have their ActivationTime calculated as offsets. When the saved game state is reloaded, the WorldInfo.TimeSeconds is usually at zero or a very small number. This is unlikely to be the time when the game was saved previously. ActivationTime is mostly important if the Kismet Event has set its ReTriggerDelay variable. Thus to prevent the bug where a Kismet Event is retriggered too quickly by saving and loading, it is required to calculate the time remaining from ActivationTime with ReTriggerDelay in consideration. This way, when the Kismet Event is reloaded the ActivationTime is usually set in the future, if it had been triggered. The other value that is saved is the TriggerCount. This is usually required for triggers that have their MaxTriggerCount values set to something other than zero.

Kismet Variables are detected using a typecasting trial and error method. Another option would have been to iterate over the Kismet Sequence Objects looking for each type of Kismet Variable. Either approach is fine. Once a Kismet Variable has been detected, its value is then serialized.

SaveGameState.uc

/**
* Saves the Kismet game state
*/
protected function SaveKismetState()
{
local WorldInfo WorldInfo;
local array<Sequence> RootSequences;
local array<SequenceObject> SequenceObjects;
local SequenceEvent SequenceEvent;
local SeqVar_Bool SeqVar_Bool;
local SeqVar_Float SeqVar_Float;
local SeqVar_Int SeqVar_Int;
local SeqVar_Object SeqVar_Object;
local SeqVar_String SeqVar_String;
local SeqVar_Vector SeqVar_Vector;
local int i, j;
local JSonObject JSonObject;
// Get the world info, abort if it does not exist
WorldInfo = class'WorldInfo'.static.GetWorldInfo();
if (WorldInfo == None)
{
return;
}
// Get all of the root sequences within the world, abort if there are no root sequences
RootSequences = WorldInfo.GetAllRootSequences();
if (RootSequences.Length <= 0)
{
return;
}
// Serialize all SequenceEvents and SequenceVariables
for (i = 0; i < RootSequences.Length; ++i)
{
if (RootSequences[i] != None)
{
// Serialize Kismet Events
RootSequences[i].FindSeqObjectsByClass(class'SequenceEvent', true, SequenceObjects);
if (SequenceObjects.Length > 0)
{
for (j = 0; j < SequenceObjects.Length; ++j)
{
SequenceEvent = SequenceEvent(SequenceObjects[j]);
if (SequenceEvent != None)
{
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SequenceEvent so it can found later
JSonObject.SetStringValue("Name", PathName(SequenceEvent));
// Calculate the activation time of what it should be when the saved game state is loaded. This is done as the retrigger delay minus the difference between the current world time
// and the last activation time. If the result is negative, then it means this was never triggered before, so always make sure it is larger or equal to zero.
JsonObject.SetFloatValue("ActivationTime", FMax(SequenceEvent.ReTriggerDelay - (WorldInfo.TimeSeconds - SequenceEvent.ActivationTime), 0.f));
// Save the current trigger count
JSonObject.SetIntValue("TriggerCount", SequenceEvent.TriggerCount);
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
}
}
}
// Serialize Kismet Variables
RootSequences[i].FindSeqObjectsByClass(class'SequenceVariable', true, SequenceObjects);
if (SequenceObjects.Length > 0)
{
for (j = 0; j < SequenceObjects.Length; ++j)
{
// Attempt to serialize as a boolean variable
SeqVar_Bool = SeqVar_Bool(SequenceObjects[j]);
if (SeqVar_Bool != None)
{
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SeqVar_Bool so it can found later
JSonObject.SetStringValue("Name", PathName(SeqVar_Bool));
// Save the boolean value
JSonObject.SetIntValue("Value", SeqVar_Bool.bValue);
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
// Continue to the next one within the array as we're done with this array index
continue;
}
// Attempt to serialize as a float variable
SeqVar_Float = SeqVar_Float(SequenceObjects[j]);
if (SeqVar_Float != None)
{
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SeqVar_Float so it can found later
JSonObject.SetStringValue("Name", PathName(SeqVar_Float));
// Save the float value
JSonObject.SetFloatValue("Value", SeqVar_Float.FloatValue);
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
// Continue to the next one within the array as we're done with this array index
continue;
}
// Attempt to serialize as an int variable
SeqVar_Int = SeqVar_Int(SequenceObjects[j]);
if (SeqVar_Int != None)
{
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SeqVar_Int so it can found later
JSonObject.SetStringValue("Name", PathName(SeqVar_Int));
// Save the int value
JSonObject.SetIntValue("Value", SeqVar_Int.IntValue);
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
// Continue to the next one within the array as we're done with this array index
continue;
}
// Attempt to serialize as an object variable
SeqVar_Object = SeqVar_Object(SequenceObjects[j]);
if (SeqVar_Object != None)
{
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SeqVar_Object so it can found later
JSonObject.SetStringValue("Name", PathName(SeqVar_Object));
// Save the object value
JSonObject.SetStringValue("Value", PathName(SeqVar_Object.GetObjectValue()));
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
// Continue to the next one within the array as we're done with this array index
continue;
}
// Attempt to serialize as a string variable
SeqVar_String = SeqVar_String(SequenceObjects[j]);
if (SeqVar_String != None)
{
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SeqVar_String so it can found later
JSonObject.SetStringValue("Name", PathName(SeqVar_String));
// Save the string value
JSonObject.SetStringValue("Value", SeqVar_String.StrValue);
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
// Continue to the next one within the array as we're done with this array index
continue;
}
// Attempt to serialize as a vector variable
SeqVar_Vector = SeqVar_Vector(SequenceObjects[j]);
if (SeqVar_Vector != None)
{
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SeqVar_Vector so it can found later
JSonObject.SetStringValue("Name", PathName(SeqVar_Vector));
// Save the vector value
JSonObject.SetFloatValue("Value_X", SeqVar_Vector.VectValue.X);
JSonObject.SetFloatValue("Value_Y", SeqVar_Vector.VectValue.Y);
JSonObject.SetFloatValue("Value_Z", SeqVar_Vector.VectValue.Z);
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
// Continue to the next one within the array as we're done with this array index
continue;
}
}
}
}
}
}

Saving Matinee is done in the same way as saving Kismet, as what it saved is the Matinee Kismet Sequence Action. That is, all of the Kismet Sequence Objects are iterated over, and filtering is done for the SeqAct_Interp class. Then the variables relevant are serialized and added to the SerializedWorldData array.

SaveGameState.uc

/**
* Saves the Matinee game state
*/
protected function SaveMatineeState()
{
local WorldInfo WorldInfo;
local array<Sequence> RootSequences;
local array<SequenceObject> SequenceObjects;
local SeqAct_Interp SeqAct_Interp;
local int i, j;
local JSonObject JSonObject;
// Get the world info, abort if it does not exist
WorldInfo = class'WorldInfo'.static.GetWorldInfo();
if (WorldInfo == None)
{
return;
}
// Get all of the root sequences within the world, abort if there are no root sequences
RootSequences = WorldInfo.GetAllRootSequences();
if (RootSequences.Length <= 0)
{
return;
}
// Serialize all SequenceEvents and SequenceVariables
for (i = 0; i < RootSequences.Length; ++i)
{
if (RootSequences[i] != None)
{
// Serialize Matinee Kismet Sequence Actions
RootSequences[i].FindSeqObjectsByClass(class'SeqAct_Interp', true, SequenceObjects);
if (SequenceObjects.Length > 0)
{
for (j = 0; j < SequenceObjects.Length; ++j)
{
SeqAct_Interp = SeqAct_Interp(SequenceObjects[j]);
if (SeqAct_Interp != None)
{
// Attempt to serialize the data
JSonObject = new () class'JSonObject';
if (JSonObject != None)
{
// Save the path name of the SeqAct_Interp so it can found later
JSonObject.SetStringValue("Name", PathName(SeqAct_Interp));
// Save the current position of the SeqAct_Interp
JSonObject.SetFloatValue("Position", SeqAct_Interp.Position);
// Save if the SeqAct_Interp is playing or not
JSonObject.SetIntValue("IsPlaying", (SeqAct_Interp.bIsPlaying) ? 1 : 0);
// Save if the SeqAct_Interp is paused or not
JSonObject.SetIntValue("Paused", (SeqAct_Interp.bPaused) ? 1 : 0);
// Encode this and append it to the save game data array
SerializedWorldData.AddItem(class'JSonObject'.static.EncodeJson(JSonObject));
}
}
}
}
}
}
}

Save the data using BasicSaveObject

As shown earlier, the save game state data is saved by BasicSaveObject(). BasicSaveObject() returns true or false depending if the file was written successfully or not. This allows you to display a message if the saved game was saved successfully or not.

SaveGameStatePlayerController.uc

/**
* This exec function will save the game state to the file name provided.
*
* @param FileName File name to save the SaveGameState to
*/
exec function SaveGameState(string FileName)
{
local SaveGameState SaveGameState;
// Instance the save game state
SaveGameState = new () class'SaveGameState';
if (SaveGameState == None)
{
return;
}
// Scrub the file name
FileName = ScrubFileName(FileName);
// Ask the save game state to save the game
SaveGameState.SaveGameState();
// Serialize the save game state object onto disk
if (class'Engine'.static.BasicSaveObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
{
// If successful then send a message
ClientMessage("Saved game state to "$FileName$".", 'System');
}
}

Player loads a game from a saved game state

LoadGameState() is the entry point from where saved game states are loaded. Again, this function may be made a static function as it is not really dependent on any class instances.

SaveGameStatePlayerController.uc

/**
* This exec function will load the game state from the file name provided
*
* @param FileName File name of load the SaveGameState from
*/
exec function LoadGameState(string FileName);

Load the saved game state object

The saved game state object is first loaded from disk using BasicLoadObject().

SaveGameStatePlayerController.uc

/**
* This exec function will load the game state from the file name provided
*
* @param FileName File name of load the SaveGameState from
*/
exec function LoadGameState(string FileName)
{
local SaveGameState SaveGameState;
// Instance the save game state
SaveGameState = new () class'SaveGameState';
if (SaveGameState == None)
{
return;
}
// Scrub the file name
FileName = ScrubFileName(FileName);
// Attempt to deserialize the save game state object from disk
if (class'Engine'.static.BasicLoadObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
{
}
}

Load the map appending a command line to store saved game state file name

If the saved game state object was loaded successfully, then the serialized map is loaded with command line parameters stating that when the map has finished loading, it should continue loading up the saved game state defined. If you decide to make this function a static function, you can call ConsoleCommand() from other global referenceable Actors.

Note: The console command 'start' is used here instead of 'open' because 'start' always resets the command line parameters; where as 'open' appends command line parameters. This is very important, otherwise the command line parameter "SaveGameState" will be appended multiple times which will lead to incorrect loading of the save game state!

SaveGameStatePlayerController.uc

/**
* This exec function will load the game state from the file name provided
*
* @param FileName File name of load the SaveGameState from
*/
exec function LoadGameState(string FileName)
{
local SaveGameState SaveGameState;
// Instance the save game state
SaveGameState = new () class'SaveGameState';
if (SaveGameState == None)
{
return;
}
// Scrub the file name
FileName = ScrubFileName(FileName);
// Attempt to deserialize the save game state object from disk
if (class'Engine'.static.BasicLoadObject(SaveGameState, FileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
{
// Start the map with the command line parameters required to then load the save game state
ConsoleCommand("start "$SaveGameState.PersistentMapFileName$"?Game="$SaveGameState.GameInfoClassName$"?SaveGameState="$FileName);
}
}

When the map has finished loading, reload the saved game state object

When the map has loaded, SaveStateGameInfo::InitGame() picks out whether or not a save game state command line parameter exists or not. If it does then it saves the value within PendingSaveGameFileName. Then when the match is started, the save game state object is loaded from disk again and is asked to load the game state. When the saved game state is loaded, a message is sent to the player to inform him / her that the saved game has loaded. If there are any streaming levels, then SaveStateGameInfo::StartMatch() will ask all player controllers streaming in the other maps. However, because streaming in the other maps will not be finished in the same tick, a looping timer called SaveStateGameInfo::WaitingForStreamingLevelsTimer() is setup to watch for when all streaming levels have finished loading. When the streaming maps have finished loading, then the match is started by calling Super.StartMatch() [UTGame::StartMatch()].

SaveGameStateGameInfo.uc

class SaveGameStateGameInfo extends UTGame;
// Pending save game state file name
var private string PendingSaveGameFileName;
/*
* Initialize the game. The GameInfo's InitGame() function is called before any other scripts (including PreBeginPlay()), and is used by the GameInfo to initialize parameters and spawn its helper classes.
*
* @param Options Passed options from the command line
* @param ErrorMessage Out going error messages
*/
event InitGame(string Options, out string ErrorMessage)
{
Super.InitGame(Options, ErrorMessage);
// Set the pending save game file name if required
if (HasOption(Options, "SaveGameState"))
{
PendingSaveGameFileName = ParseOption(Options, "SaveGameState");
}
else
{
PendingSaveGameFileName = "";
}
}
/**
* Start the match - inform all actors that the match is starting, and spawn player pawns
*/
function StartMatch()
{
local SaveGameState SaveGameState;
local PlayerController PlayerController;
local int i;
// Check if we need to load the game or not
if (PendingSaveGameFileName != "")
{
// Instance the save game state
SaveGameState = new () class'SaveGameState';
if (SaveGameState == None)
{
return;
}
// Attempt to deserialize the save game state object from disk
if (class'Engine'.static.BasicLoadObject(SaveGameState, PendingSaveGameFileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
{
// Synchrously load in any streaming levels
if (SaveGameState.StreamingMapFileNames.Length > 0)
{
// Ask every player controller to load up the streaming map
ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
{
// Stream map files now
for (i = 0; i < SaveGameState.StreamingMapFileNames.Length; ++i)
{
PlayerController.ClientUpdateLevelStreamingStatus(Name(SaveGameState.StreamingMapFileNames[i]), true, true, true);
}
// Block everything until pending loading is done
PlayerController.ClientFlushLevelStreaming();
}
// Store the save game state in StreamingSaveGameState
StreamingSaveGameState = SaveGameState;
// Start the looping timer which waits for all streaming levels to finish loading
SetTimer(0.05f, true, NameOf(WaitingForStreamingLevelsTimer));
return;
}
// Load the game state
SaveGameState.LoadGameState();
}
// Send a message to all player controllers that we've loaded the save game state
ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
{
PlayerController.ClientMessage("Loaded save game state from "$PendingSaveGameFileName$".", 'System');
}
}
Super.StartMatch();
}
function WaitingForStreamingLevelsTimer()
{
local int i;
local PlayerController PlayerController;
for (i = 0; i < WorldInfo.StreamingLevels.Length; ++i)
{
// If any levels still have the load request pending, then return
if (WorldInfo.StreamingLevels[i].bHasLoadRequestPending)
{
return;
}
}
// Clear the looping timer
ClearTimer(NameOf(WaitingForStreamingLevelsTimer));
// Load the save game state
StreamingSaveGameState.LoadGameState();
// Clear it for garbage collection
StreamingSaveGameState = None;
// Send a message to all player controllers that we've loaded the save game state
ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
{
PlayerController.ClientMessage("Loaded save game state from "$PendingSaveGameFileName$".", 'System');
}
// Start the match
Super.StartMatch();
}

Iterate over the JSon data and deserialize the data on the actors and objects within the level

Now that the saved game state object has been loaded, it is now possible to iterate over the Actors that implement SaveGameStateInterface, Kismet and Matinee and restore them based on the data stored in SerializedWorldData array (which is now encoded as JSon).

As SerializedWorldData is iterated over, each entry is decoded as a JSonObject. Retrieving the Name will provide some insight as to what the JSonObject data is relevant to. Testing for SeqAct_Interp will reveal that the data is relevant for a Matinee Object, SeqEvent or SeqVar for either Kismet Event or a Kismet Variables. If those three fail, then it must be for an Actor in the world.

If the JSonObject data is for an Actor in the world, then the actor is retrieved by using FindObject(). As the full path name of the Actor is stored, FindObject() should be able to find any Actor that was placed by the level designer. If FindObject() fails, then it must be for an Actor that was instanced during play. This is why it is often useful to store the ObjectArchetype too, so that it can be reinstanced by the saved game state if required. Once the Actor is found or instanced, the Actor is then casted to SaveGameStateInterface and is then asked to deserialize itself based on the data stored within the JSonObject.

Deserializing Kismet is done in much the same way as deserializing Actors, with the exception that if a Kismet Sequence Object cannot be found, Unrealscript will not attempt to instance it. Once the Kismet Sequence Object is found using FindObject(), it is then type casted to find out what it is exactly. From there the saved values from the JSonObject is restored.

Deserializing Matinee is similar to deserializing Kismet. However, if the Matinee Sequence was playing at the time the saved game state was saved, then IsPlaying will be stored as 1 within the JSonObject. Thus, the ForceStartPosition is set and Matinee is asked to play. Otherwise Matinee will have its position set according to the Position value stored within the JSonObject.

KActor example

This example shows how you would setup a KActor to serialize and deserialize itself using the Save Game State System. Remember that for any Actor class that you want the Save Game System to automatically pick up upon loading or saving, you need to implement the SaveGameStateInterface.

Serializing the KActor

Only the location and rotation values are saved here. The path name and object archetype are required data; otherwise the Save Game State System will not know what Actor or Object to apply the data to and or if the Actor or Object is required to be instanced the Save Game State System will not know what Actor or Object archetype to instance.

So the location is saved as three floats and the rotation is saved as three integers. You can of course save more variables as required. One reason why JSon was chosen, was that you can create parent - child structures using the JSonObject::SetObject() function. Thus you can also have child Actors or Objects serialize themselves within this step (ensure that these Actors or Objects have a way of keeping track if they have been serialized or not; as you do not want these Actors or Objects being serialized and deserialized more than once) and saved together with the parent data set. This naturally creates a very easy method to handle attached Actors or Objects, without having to tweak the base Save Game State System code base.

SaveGameStateKActor.uc

/**
* Serializes the actor's data into JSon
*
* @return JSon data representing the state of this actor
*/
function String Serialize()
{
local JSonObject JSonObject;
// Instance the JSonObject, abort if one could not be created
JSonObject = new () class'JSonObject';
if (JSonObject == None)
{
`Warn(Self$" could not be serialized for saving the game state.");
return "";
}
// Serialize the path name so that it can be looked up later
JSonObject.SetStringValue("Name", PathName(Self));
// Serialize the object archetype, in case this needs to be spawned
JSonObject.SetStringValue("ObjectArchetype", PathName(ObjectArchetype));
// Save the location
JSonObject.SetFloatValue("Location_X", Location.X);
JSonObject.SetFloatValue("Location_Y", Location.Y);
JSonObject.SetFloatValue("Location_Z", Location.Z);
// Save the rotation
JSonObject.SetIntValue("Rotation_Pitch", Rotation.Pitch);
JSonObject.SetIntValue("Rotation_Yaw", Rotation.Yaw);
JSonObject.SetIntValue("Rotation_Roll", Rotation.Roll);
// Send the encoded JSonObject
return class'JSonObject'.static.EncodeJson(JSonObject);
}

Deserializing the KActor

When the KActor is asked to deserialize itself, it is given the JSon data that it had serialized itself. Thus simply performing the opposite should restore the KActor to its state that it was when the game state was saved. As mentioned above, if you required child Actors or Objects to be serialized; then here would be the appropriate place to deserialize that data.

Player controlled pawn example

The player controlled pawn is an interesting example where none of the Actors involved are placed by the level designers; that is neither the PlayerController or the Pawn classes were placed in the map. However, Pawns may be placed by the level designer for different purposes such as place enemy monsters in the map for a single player game. Thus the method that was done here was to save an extra flag called IsPlayerControlled. Thus when the pawn is instanced and deserialized by the Save Game System, if IsPlayerControlled is set to 1 then the deserializing code will tell the GameInfo about that.

SaveGameStatePlayerController.uc

/**
* Serializes the actor's data into JSon
*
* @return JSon data representing the state of this actor
*/
function String Serialize()
{
local JSonObject JSonObject;
// Instance the JSonObject, abort if one could not be created
JSonObject = new () class'JSonObject';
if (JSonObject == None)
{
`Warn(Self$" could not be serialized for saving the game state.");
return "";
}
// Serialize the path name so that it can be looked up later
JSonObject.SetStringValue("Name", PathName(Self));
// Serialize the object archetype, in case this needs to be spawned
JSonObject.SetStringValue("ObjectArchetype", PathName(ObjectArchetype));
// Save the location
JSonObject.SetFloatValue("Location_X", Location.X);
JSonObject.SetFloatValue("Location_Y", Location.Y);
JSonObject.SetFloatValue("Location_Z", Location.Z);
// Save the rotation
JSonObject.SetIntValue("Rotation_Pitch", Rotation.Pitch);
JSonObject.SetIntValue("Rotation_Yaw", Rotation.Yaw);
JSonObject.SetIntValue("Rotation_Roll", Rotation.Roll);
// If the controller is the player controller, then saved a flag to say that it should be repossessed by the player when we reload the game state
JSonObject.SetIntValue("IsPlayerControlled", (PlayerController(Controller) != None) ? 1 : 0);
// Send the encoded JSonObject
return class'JSonObject'.static.EncodeJson(JSonObject);
}
/**
* Deserializes the actor from the data given
*
* @param Data JSon data representing the differential state of this actor
*/
function Deserialize(JSonObject Data)
{
local Vector SavedLocation;
local Rotator SavedRotation;
local SaveGameStateGameInfo SaveGameStateGameInfo;
// Deserialize the location and set it
SavedLocation.X = Data.GetFloatValue("Location_X");
SavedLocation.Y = Data.GetFloatValue("Location_Y");
SavedLocation.Z = Data.GetFloatValue("Location_Z");
SetLocation(SavedLocation);
// Deserialize the rotation and set it
SavedRotation.Pitch = Data.GetIntValue("Rotation_Pitch");
SavedRotation.Yaw = Data.GetIntValue("Rotation_Yaw");
SavedRotation.Roll = Data.GetIntValue("Rotation_Roll");
SetRotation(SavedRotation);
// Deserialize if this was a player controlled pawn, if it was then tell the game info about it
if (Data.GetIntValue("IsPlayerControlled") == 1)
{
SaveGameStateGameInfo = SaveGameStateGameInfo(WorldInfo.Game);
if (SaveGameStateGameInfo != None)
{
SaveGameStateGameInfo.PendingPlayerPawn = Self;
}
}
}

When GameInfo::RestartPlayer() is called, it first checks if there is a pending player pawn waiting for the player controller. If there is, then the player controller is given that instead.

The custom Sequence Event is then triggered when the save game state is loaded in GameInfo::StartMatch().

SaveGameStateGameInfo.uc

/**
* Start the match - inform all actors that the match is starting, and spawn player pawns
*/
function StartMatch()
{
local SaveGameState SaveGameState;
local PlayerController PlayerController;
local int Idx;
local array<SequenceObject> Events;
local SaveGameState_SeqEvent_SavedGameStateLoaded SavedGameStateLoaded;
// Check if we need to load the game or not
if (PendingSaveGameFileName != "")
{
// Instance the save game state
SaveGameState = new () class'SaveGameState';
if (SaveGameState == None)
{
return;
}
// Attempt to deserialize the save game state object from disk
if (class'Engine'.static.BasicLoadObject(SaveGameState, PendingSaveGameFileName, true, class'SaveGameState'.const.SAVEGAMESTATE_REVISION))
{
// Load the game state
SaveGameState.LoadGameState();
}
// Send a message to all player controllers that we've loaded the save game state
ForEach WorldInfo.AllControllers(class'PlayerController', PlayerController)
{
PlayerController.ClientMessage("Loaded save game state from "$PendingSaveGameFileName$".", 'System');
// Activate saved game state loaded events
if (WorldInfo.GetGameSequence() != None)
{
WorldInfo.GetGameSequence().FindSeqObjectsByClass(class'SaveGameState_SeqEvent_SavedGameStateLoaded', true, Events);
for (Idx = 0; Idx < Events.Length; Idx++)
{
SavedGameStateLoaded = SaveGameState_SeqEvent_SavedGameStateLoaded(Events[Idx]);
if (SavedGameStateLoaded != None)
{
SavedGameStateLoaded.CheckActivate(PlayerController, PlayerController);
}
}
}
}
}
Super.StartMatch();
}

Questions

How do a handle child Actors or Objects?

One reason why JSon was chosen, was that you can create parent - child structures using the JSonObject::SetObject() function. Thus you can also have child Actors or Objects serialize themselves within this step (ensure that these Actors or Objects have a way of keeping track if they have been serialized or not; as you do not want these Actors or Objects being serialized and deserialized more than once) and saved together with the parent data set. This naturally creates a very easy method to handle attached Actors or Objects, without having to tweak the base Save Game State System code base. When the Actor or Object is asked to be deserialized, then you can iterate through the inner JSonObjects and perform the same kind of deserialization.

The saved game state is stored as plain text! How would I prevent players from cheating?

Another reason why JSon was chosen, was that it would be very easy to debug the saved game state files by simply opening them up in Notepad or some other kind of text editing software. However, it is understandable that not storing it as binary may lead to some fears about cheating.

There are a few trains of thought on this. You could obfuscate the data by passing the encoded JSon through a text mangler function. However, even that would eventually get decoded by people who really want to hack your saved games. Even binary would not be immune to this.

Therefore, at the end of the day; there is very little you can do to prevent cheating; unless you can verify the source of the information and verify where the save data is being stored (online saves).

Is it possible to store the JSon data online?

Yes. The nice thing about using JSon for this, is that it is a plain text interchangable data format that can be sent to a server via TCPLink. Thus save games can be stored online some where and the client could retrieve them on a different machine... or even on a different device. Or you could even have a website which reads the JSon data and displays the player's progress to them. The possibilities are practically endless.

How do I integrate this Development Kit Gem!?

You can either subclass from SaveGameState classes (easiest) or you can shift the code within SaveGameState classes into your own game. Remember, you must be running the correct game type so that the correct PlayerController is being used by the game; otherwise none of the code will work because the incorrect classes are being used. To check which GameInfo and which PlayerController is currently being used, used the "showdebug" console command. This will print on screen in the top left corner which GameInfo and which PlayerController are currently being used.

I've integrated, but when I load a map nothing happens!

Remember that by default, the example code uses SaveGameStateGameInfo::StartMatch() and a delayed called to Super.StartMatch() [UTGame::StartMatch()] when the Save Game State has streaming levels. GameInfo::StartMatch() is automatically called when bDelayedStart is false and bWaitingToStartMatch is true by default. However, if this does not fit with your game; then remember to call SaveGameStateGameInfo::StartMatch(). You can also move the contents of the SaveGameStateGameInfo::StartMatch(), as the main reason why it is in there is because save game state requires the PlayerController to be instanced before the save game state is loaded.