UDN
Search public documentation:

DevelopmentKitGemsSaveGameStates
日本語訳
中国翻译
한국어

Interested in the Unreal Engine?
Visit the Unreal Technology site.

Looking for jobs and company info?
Check out the Epic games site.

Questions about support via UDN?
Contact the UDN Staff

UE3 Home > Unreal Development Kit Gems > Save Game States
UE3 Home > Input / Output > Save Game States

Save Game States


Last tested against UDK March, 2012

PC compatible
Mac compatible
iOS compatible

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!

General flow of how saved game states work


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.

SaveGameStateInterface.uc
interface SaveGameStateInterface;

/**
 * Serializes the actor's data into JSon
 *
 * @return  JSon data representing the state of this actor
 */
function String Serialize();

/**
 * Deserializes the actor from the data given
 *
 * @param  Data  JSon data representing the differential state of this actor
 */
function Deserialize(JSonObject Data);

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.

ALERT! 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.

SaveGameState.uc
/**
 * Loads the game state by deserializing all of the serialized data and applying the data to the actors that implement the SaveGameStateInterface, Kisment and Matinee.
 */
function LoadGameState()
{
  local WorldInfo WorldInfo;
  local int i;
  local JSonObject JSonObject;
  local String ObjectName;
  local SaveGameStateInterface SaveGameStateInterface;
  local Actor Actor, ActorArchetype;
  
  // No serialized world data to load!
  if (SerializedWorldData.Length <= 0)
  {
    return;
  }

  // Grab the world info, abort if no valid world info
  WorldInfo = class'WorldInfo'.static.GetWorldInfo();
  if (WorldInfo == None)
  {
    return;
  }

  // For each serialized data object
  for (i = 0; i < SerializedWorldData.Length; ++i)
  {
    if (SerializedWorldData[i] != "")
    {
      // Decode the JSonObject from the encoded string
      JSonObject = class'JSonObject'.static.DecodeJson(SerializedWorldData[i]);
      if (JSonObject != None)
      {
        // Get the object name
        ObjectName = JSonObject.GetStringValue("Name");
        // Check if the object name contains SeqAct_Interp, if so deserialize Matinee
        if (InStr(ObjectName, "SeqAct_Interp",, true) != INDEX_NONE)
        {
          LoadMatineeState(ObjectName, JSonObject);
        }
        // Check if the object name contains SeqEvent or SeqVar, if so deserialize Kismet
        else if (InStr(ObjectName, "SeqEvent",, true) != INDEX_NONE || InStr(ObjectName, "SeqVar",, true) != INDEX_NONE)
        {
          LoadKismetState(ObjectName, JSonObject);
        }
        // Otherwise it is some other type of actor
        else
        {
          // Try to find the persistent level actor
          Actor = Actor(FindObject(ObjectName, class'Actor'));

          // If the actor was not in the persistent level, then it must have been transient then attempt to spawn it
          if (Actor == None)
          {
            // Spawn the actor
            ActorArchetype = GetActorArchetypeFromName(JSonObject.GetStringValue("ObjectArchetype"));
            if (ActorArchetype != None)
            {
              Actor = WorldInfo.Spawn(ActorArchetype.Class,,,,, ActorArchetype, true);
            }
          }

          if (Actor != None)
          {
            // Cast to the save game state interface
            SaveGameStateInterface = SaveGameStateInterface(Actor);
            if (SaveGameStateInterface != None)
            {
              // Deserialize the actor
              SaveGameStateInterface.Deserialize(JSonObject);
            }
          }
        }
      }
    }
  }
}

/**
 * Returns an actor archetype from the name
 *
 * @return    Returns an actor archetype from the string representation
 */
function Actor GetActorArchetypeFromName(string ObjectArchetypeName)
{
  local WorldInfo WorldInfo;
  
  WorldInfo = class'WorldInfo'.static.GetWorldInfo();
  if (WorldInfo == None)
  {
    return None;
  }

  // Use static look ups if on the console, for static look ups to work
  //  * Force cook the classes or packaged archetypes to the maps
  //  * Add packaged archetypes to the StartupPackage list
  //  * Reference the packages archetypes somewhere within Unrealscript
  if (WorldInfo.IsConsoleBuild())
  {
    return Actor(FindObject(ObjectArchetypeName, class'Actor'));
  }
  else // Use dynamic look ups if on the PC
  {
    return Actor(DynamicLoadObject(ObjectArchetypeName, class'Actor'));
  }
}

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.

SaveGameState.uc
/**
 * Loads the Kismet Sequence state based on the data provided
 *
 * @param    ObjectName    Name of the Kismet object in the level
 * @param    Data      Data as JSon for the Kismet object
 */
function LoadKismetState(string ObjectName, JSonObject Data)
{
  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 Object SequenceObject;
  local WorldInfo WorldInfo;

  // Attempt to find the sequence object
  SequenceObject = FindObject(ObjectName, class'Object');

  // Could not find sequence object, so abort
  if (SequenceObject == None)
  {
    return;
  }

  // Deserialize Kismet Event
  SequenceEvent = SequenceEvent(SequenceObject);
  if (SequenceEvent != None)
  {
    WorldInfo = class'WorldInfo'.static.GetWorldInfo();
    if (WorldInfo != None)
    {
      SequenceEvent.ActivationTime = WorldInfo.TimeSeconds + Data.GetFloatValue("ActivationTime");
    }

    SequenceEvent.TriggerCount = Data.GetIntValue("TriggerCount");
    return;
  }

  // Deserialize Kismet Variable Bool
  SeqVar_Bool = SeqVar_Bool(SequenceObject);
  if (SeqVar_Bool != None)
  {
    SeqVar_Bool.bValue = Data.GetIntValue("Value");
    return;
  }

  // Deserialize Kismet Variable Float
  SeqVar_Float = SeqVar_Float(SequenceObject);
  if (SeqVar_Float != None)
  {
    SeqVar_Float.FloatValue = Data.GetFloatValue("Value");
    return;
  }

  // Deserialize Kismet Variable Int
  SeqVar_Int = SeqVar_Int(SequenceObject);
  if (SeqVar_Int != None)
  {
    SeqVar_Int.IntValue = Data.GetIntValue("Value");
    return;
  }

  // Deserialize Kismet Variable Object
  SeqVar_Object = SeqVar_Object(SequenceObject);
  if (SeqVar_Object != None)
  {
    SeqVar_Object.SetObjectValue(FindObject(Data.GetStringValue("Value"), class'Object'));
    return;
  }

  // Deserialize Kismet Variable String
  SeqVar_String = SeqVar_String(SequenceObject);
  if (SeqVar_String != None)
  {
    SeqVar_String.StrValue = Data.GetStringValue("Value");
    return;
  }

  // Deserialize Kismet Variable Vector
  SeqVar_Vector = SeqVar_Vector(SequenceObject);
  if (SeqVar_Vector != None)
  {
    SeqVar_Vector.VectValue.X = Data.GetFloatValue("Value_X");
    SeqVar_Vector.VectValue.Y = Data.GetFloatValue("Value_Y");
    SeqVar_Vector.VectValue.Z = Data.GetFloatValue("Value_Z");
    return;
  }
}

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.

SaveGameState.uc
/**
 * Loads up the Matinee state based on the data
 *
 * @param    ObjectName    Name of the Matinee Kismet object
 * @param    Data      Saved Matinee Kismet data 
 */
function LoadMatineeState(string ObjectName, JSonObject Data)
{
  local SeqAct_Interp SeqAct_Interp;
  local float OldForceStartPosition;
  local bool OldbForceStartPos;

  // Find the matinee kismet object
  SeqAct_Interp = SeqAct_Interp(FindObject(ObjectName, class'Object'));
  if (SeqAct_Interp == None)
  {
    return;
  }
  
  if (Data.GetIntValue("IsPlaying") == 1)
  {
    OldForceStartPosition = SeqAct_Interp.ForceStartPosition;
    OldbForceStartPos = SeqAct_Interp.bForceStartPos;

    // Play the matinee at the forced position
    SeqAct_Interp.ForceStartPosition = Data.GetFloatValue("Position");
    SeqAct_Interp.bForceStartPos = true;
    SeqAct_Interp.ForceActivateInput(0);

    // Reset the start position and start pos
    SeqAct_Interp.ForceStartPosition = OldForceStartPosition;
    SeqAct_Interp.bForceStartPos = OldbForceStartPos;
  }
  else
  {
    // Set the position of the matinee
    SeqAct_Interp.SetPosition(Data.GetFloatValue("Position"), true);
  }

  // Set the paused 
  SeqAct_Interp.bPaused = (Data.GetIntValue("Paused") == 1) ? true : false;
}

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.

SaveGameStateKActor.uc
class SaveGameStateKActor extends KActor
  Implements(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.

SaveGameStateKActor.uc
/**
 * 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;

  // Deserialize the location and set it
  SavedLocation.X = Data.GetFloatValue("Location_X");
  SavedLocation.Y = Data.GetFloatValue("Location_Y");
  SavedLocation.Z = Data.GetFloatValue("Location_Z");

  // Deserialize the rotation and set it
  SavedRotation.Pitch = Data.GetIntValue("Rotation_Pitch");
  SavedRotation.Yaw = Data.GetIntValue("Rotation_Yaw");
  SavedRotation.Roll = Data.GetIntValue("Rotation_Roll");

  if (StaticMeshComponent != None)
  {
    StaticMeshComponent.SetRBPosition(SavedLocation);
    StaticMeshComponent.SetRBRotation(SavedRotation);
  }
}

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.

SaveGameStateGameInfo.uc
/**
 * Restarts a controller
 *
 * @param    NewPlayer    Player to restart
 */
function RestartPlayer(Controller NewPlayer)
{
  local LocalPlayer LP; 
  local PlayerController PC; 

  // Ensure that we have a controller
  if (NewPlayer == None)
  {
    return;
  }

  // If we have a pending player pawn, then just possess that one
  if (PendingPlayerPawn != None)
  {
    // Assign the pending player pawn as the new player's pawn
    NewPlayer.Pawn = PendingPlayerPawn;

    // Initialize and start it up
    if (PlayerController(NewPlayer) != None)
    {
      PlayerController(NewPlayer).TimeMargin = -0.1;
    }

    NewPlayer.Pawn.LastStartTime = WorldInfo.TimeSeconds;
    NewPlayer.Possess(NewPlayer.Pawn, false);    
    NewPlayer.ClientSetRotation(NewPlayer.Pawn.Rotation, true);

    if (!WorldInfo.bNoDefaultInventoryForPlayer)
    {
      AddDefaultInventory(NewPlayer.Pawn);
    }

    SetPlayerDefaults(NewPlayer.Pawn);

    // Clear the pending pawn
    PendingPlayerPawn = None;
  }
  else // Otherwise spawn a new pawn for the player to possess
  {
    Super.RestartPlayer(NewPlayer);
  }

  // To fix custom post processing chain when not running in editor or PIE.
  PC = PlayerController(NewPlayer);
  if (PC != none)
  {
    LP = LocalPlayer(PC.Player); 

    if (LP != None) 
    { 
      LP.RemoveAllPostProcessingChains(); 
      LP.InsertPostProcessingChain(LP.Outer.GetWorldPostProcessChain(), INDEX_NONE, true);

      if (PC.myHUD != None)
      {
        PC.myHUD.NotifyBindPostProcessEffects();
      }
    } 
  }
}

This would ensure that the player when loading a saved game state ends up in the same position as before, and not back at a PlayerStart.

Game State Loaded Kismet Event


Sometimes it may be necessary to perform some Kismet Actions to ensure that the game world is fully restored. This is done by making a custom Sequence Event.

SaveGameState_SeqEvent_SavedGameStateLoaded.uc
class SaveGameState_SeqEvent_SavedGameStateLoaded extends SequenceEvent;

defaultproperties
{
  ObjName="Saved Game State Loaded"
  MaxTriggerCount=0
  VariableLinks.Empty
  OutputLinks(0)=(LinkDesc="Loaded")
  bPlayerOnly=false
}

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.

Related Topics


Downloads