A Way To Save/Load Between Versions on Desktop or Online: SaveLoadCodes

Hello everyone!

Been a big fan of text adventure games for a while, but just recently decided to try my hand at making one of my own! One of the things I wanted to be able to do with my game was give the player the ability to save their game and resume playing even on a future version of the game.

I considered using the SaveLoad library created by Pix (https://github.com/ThePix/quest/wiki/Library:-Save-and-Load), but unfortunately some of its limitations didn't quite work for what I had in mind. In particular, I wanted the player to be able to save their game to an actual file that could then be loaded on either the online player or desktop player. The solution I came up with was a copy/pastable code that could then be saved locally, but you may prefer Pix's cleaner in-game save/load menu solution. Additionally, I wanted to be able to change the player object (game.pov) mid-game, which is unfortunately a limitation of Pix's library.

Besides that, one of the primary differences between Pix's SaveLoad library and mine is that their library saves objects explicitly, rather than procedurally, which allows the author to ensure they're ONLY saving the attributes they want to in exchange for some extra legwork. My library, on the other hand, grabs everything it can, then excludes the attributes you don't want. This means that although my library is (theoretically) easier to integrate into existing games, extra care may be required to ensure you're saving all the attributes you truly want to (especially when it comes to ensuring compatibility between saves). I highly recommend checking out Pix's library as well as mine, as both have their merits!

This may be something that others have already encountered and come up with solutions for, but I wasn't able to find any concrete examples in my online or forum searches, so I figured I would share my solution here in case others would like to use it as well!

What I came up with is a system to save and load the player's game by creating a SaveGameCode function that collects nearly every attribute in the player's game, as well as a LoadGameCode function that parses out the saved code to update any changed attributes. Paired with some javascript to add a popup UI for the functions as well as base64 encoding (to limit the player's ability to directly edit savegames), I tried to keep everything as modular as possible, with self-explanatory comments, so others could use it too.

What do these functions do?

These functions allow you to save your game in the form of a base64-encoded code. This code can be saved locally, then copied into the load function to allow the player to load their game.

What this means is that players can play the game online, save their progress to a save code, then load and resume that progress on a desktop version of the same game, or vice-versa. This also means that saves can be stored SEPARATELY from the rest of the Quest game, potentially allowing a player to continue their old save even on a new version of the game (provided you, as the author, account for that, but more on that in the wiki). Furthermore, loading a save code is typically MUCH faster than loading a traditional Quest 5.8 save, as traditional Quest saves save the ENTIRE game each time (and thus require you to load the entire game each time).

The SaveGameCode function

I encourage you to look at the source code to learn more, but at a high level the SaveGameCode function works by parsing through all objects, exits, and turnscripts in the game to save as many changeable attributes as possible in the form of a long 'SaveString'. There are some limitations to this (see the 'Limitations' section in the wiki), but for most applications the SaveString that is created is enough to completely recreate the player's game state when fed back in to the LoadGameCode function. And all of this happens more or less automatically after installing the functions and javascript, so it can potentially be added on to nearly any existing game (barring limitations)!

The LoadGameCode function

You probably can guess, but the LoadGameCode function is responsible for parsing a given SaveString created by the SaveGameCode function, converting it back into usable object and attribute data, and updating the corresponding attributes in the game to recreate the player's saved state. There's a bit more to it, but that's the high-level explanation. To learn more, I again highly recommend checking out the wiki and/or source code!

The SaveLoadJavaCode.js javascript

In addition to those two functions, I also included some additional javascript functions for converting the savecode to/from Base64 encoding (to make it a little harder to 'cheat' by editing your save state), as well as provide a nice UI for presenting the savecode and a nice UI for the player to paste existing savecodes into.

New v3.0 Functions:

The SaveCheckpoints function

SaveCheckpoint works very similarly to SaveGameCode, except instead of saving the SaveString to present it to the player, it instead will save a SaveString to a game.checkpoints stringdictionary. If game.checkpoints does not exist when SaveCheckpoints is called, then it will create it.

This is useful for saving checkpoints internally that can then be loaded internally using the LoadCheckpoint function.

See the wiki for more details.

The LoadCheckpoint function

LoadCheckpoint works very similarly to LoadGameCode, except it will not print any messages and it will not overwrite existing checkpoint data. It will also only load existing checkpoints. If it cannot find a matching checkpoint in game.checkpoints, it will throw an error.

Useful for loading internally-created checkpoints (i.e. after a "you have died! Would you like to reload before you died?" type screen, for instance).

The GetSaveGameCodeDelims function

GetSaveGameCodeDelims() returns a stringlist of the delimiters used by the SaveGameCode function.

Useful for checking whether a user-entered input contains a banned delimiter before letting them change an attribute, (i.e. like in a "enter your name" screen taking user input to change the player.alias attribute). As a reminder, if any attributes saved by SaveGameCode contain a banned delimiter, the game will not save/load properly.

The GetSaveCheckpointDelims function

GetSaveCheckpointDelims() returns a stringlist of the delimiters used by the SaveCheckpoint function.

Useful for checking whether a user-entered input contains a banned delimiter before letting them change an attribute, (i.e. like in a "enter your name" screen taking user input to change the player.alias attribute). As a reminder, if any attributes saved by SaveCheckpoint contain a banned delimiter, the game will not save/load checkpoints properly.


I've now completed the wiki page for these functions. Check it out if you're interested, as it goes into great detail regarding how to use these functions in your game, their limitations, workarounds, etc: https://github.com/Leviathon44/SaveLoadCode/wiki

I also created a small test game to show off the functionality here: http://textadventures.co.uk/games/view/gsxdvwowaumaamc28spsga/

I've also set up a git repository with these functions and commands drawn up in a library, 'SaveLoadCodeFunctionsLibrary.aslx', and the required javascript, 'SaveLoadJavaCode.js', here: https://github.com/Leviathon44/SaveLoadCode.

The code is also posted farther down in this post, if you'd rather scroll than click the link.

Special thanks to KV, mrangel, and Pix! I never spoke to any of you directly, but your forum posts and functions over the years were a valuable resource in getting this made! I hope others find this useful!


The SaveGameCode and LoadGameCode functions require the following javascript, 'SaveLoadJavaCode.js':

(Disclaimer: I am not super familiar to javascript, so I apologize if it's not as clean as it could be)

//Converts Strings to Base64 for the SaveGame code functionality
function showPopupSave(text) {
    try {
	//Original Popup Function Courtesy of KV. Altered slightly for our specific save code popup.
	$('#msgboxCaption').html(text);

    var msgboxOptions = {
        modal: true,
        autoOpen: false,
        title: "Save Game Code",
        width: "650px",
        height: "auto",
        buttons: [
			{
			    text: 'Copy To Clipboard',
			    //click: function () { $(this).dialog('close'); }
				click: function () {  
					// Select the SaveCode text  
					var SaveCode = document.querySelector('#msgboxCaption');  
					var range = document.createRange();  
					range.selectNode(SaveCode);  
					window.getSelection().addRange(range);  

					try {  
						// Execute the copy command on selected text
						var successful = document.execCommand('copy');  
						//var msg = successful ? 'successful' : 'unsuccessful';  
						//console.log('Copy email command was ' + msg);  
					} 					
					catch(err) {  
					//console.log('Oops, unable to copy');  
					}  

					// Remove the selections 
					window.getSelection().removeAllRanges();  
}
			},
        ],
        closeOnEscape: false,
    };

    $('#msgbox').dialog(msgboxOptions);
    $('#msgbox').dialog('open');
	}
	catch(err) {
		ASLEvent("Log", err);
	}
};

function ConvertToBase64(string) {
  if (string != null && string != "") {
	  try {
	    conversion = btoa(string)
	  }
	  catch(err) {
		conversion = "Error: "+err
	  }
	  finally {
        return conversion;
		//ASLEvent("Log", conversion);	//For Debugging
	  }
  }
}

function ConvertFromBase64(string) {
	if (string != null && string != "") {
      try {
	    conversion = atob(string)
	  }
	  catch(err) {
		conversion = "Error: "+err
	  }
	  finally {
        return conversion;
		//ASLEvent("Log", conversion);	//For Debugging
	  }
    }
}

function CreateSaveCode(string) {
	//Convert the saved attribute data to Base64 to make it harder for the player to manually alter
	//converted = ConvertToBase64(string);
	converted = compressToBase64(string)
	//Post the created Save Code to the player for them to copy and save
	showPopupSave(converted);
}

function LoadSaveCode(LoadCode) {
	//Check if loaded code is encoded in Base64. The SaveGame() code has game.gameid as it's first parameter, so if first 4 characters are "game", assume it hasn't been encoded.
	first4char = LoadCode.substring(0, 4);
	if (first4char == "game") {
		var P = LoadCode
	}
	else {
		//var P = ConvertFromBase64(LoadCode)
		var P = decompressFromBase64(LoadCode)
	}
	//Send the loadcode back to the LoadGame() function in Quest
	ASLEvent("LoadGameCode", P);
}

function LoadGamePrompt() {
	$('#msgboxCaption').html("<form id='LoadCodeForm'><textarea id='LoadCodeBox' cols='55' rows='11' style='overflow:auto;max-width:100%'></textarea></form>");

    var msgboxOptions = {
        modal: true,
        autoOpen: false,
        title: "Paste SaveCode to Load",
		width: "650px",
        buttons: [
			{
			    text: 'Load Saved Game',
				click: function () {  
					// On click, retrieve the pasted load code from the textbox   
					var LoadCode = $("#LoadCodeBox").val();
					if (LoadCode != null && LoadCode != "") {
					  $(this).dialog('close');
					  //document.getElementById("#LoadCodeForm").submit();
					  //var LoadCode = document.getElementById('#LoadCodeBox').value;
					  LoadSaveCode(LoadCode);
					}
}
			},
        ],
        closeOnEscape: false,
    };

    $('#msgbox').dialog(msgboxOptions);
    $('#msgbox').dialog('open');
};


// The below is adapted from Pieroxy's LZ-string javascript library. An excerpt from LZ-string was pulled in and adapted to allow for string compression to cut down on the length of the savecode (especially when the savecode contains multiple checkpoints)...

// Copyright (c) 2013 Pieroxy <[email protected]>
// This work is free. You can redistribute it and/or modify it
// under the terms of the WTFPL, Version 2
// For more information see LICENSE.txt or http://www.wtfpl.net/
//
// For more information, the home page:
// http://pieroxy.net/blog/pages/lz-string/testing.html
//
// LZ-based compression algorithm, version 1.4.4

function getBaseValue(alphabet, character) {
  var f = String.fromCharCode;
  var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
  var keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
  var baseReverseDic = {};
  if (!baseReverseDic[alphabet]) {
    baseReverseDic[alphabet] = {};
    for (var i=0 ; i<alphabet.length ; i++) {
      baseReverseDic[alphabet][alphabet.charAt(i)] = i;
    }
  }
  return baseReverseDic[alphabet][character];
}

function compressToBase64(input) {
    try {
	  var f = String.fromCharCode;
      var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
      var keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
      var baseReverseDic = {};
      if (input == null) return "";
      var res = _compress(input, 6, function(a){return keyStrBase64.charAt(a);});
      switch (res.length % 4) { // To produce valid Base64
      default: // When could this happen ?
      case 0 : return res;
      case 1 : return res+"===";
      case 2 : return res+"==";
      case 3 : return res+"=";
      }
    }
	catch(err) {
	return "Error: "+err;
	}
}

function decompressFromBase64(input) {
    try {
	  var f = String.fromCharCode;
      var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
      var keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
      var baseReverseDic = {};
      if (input == null) return "";
      if (input == "") return null;
      return _decompress(input.length, 32, function(index) { return getBaseValue(keyStrBase64, input.charAt(index)); });
    }
	catch(err) {
	  return "Error: "+err;
	}
}

function compress(uncompressed) {
    return _compress(uncompressed, 16, function(a){return f(a);});
}
   
function _compress(uncompressed, bitsPerChar, getCharFromInt) {
  try {
	var f = String.fromCharCode;
    var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
    var keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
    var baseReverseDic = {};
    if (uncompressed == null) return "";
    var i, value,
        context_dictionary= {},
        context_dictionaryToCreate= {},
        context_c="",
        context_wc="",
        context_w="",
        context_enlargeIn= 2, // Compensate for the first entry which should not count
        context_dictSize= 3,
        context_numBits= 2,
        context_data=[],
        context_data_val=0,
        context_data_position=0,
        ii;

    for (ii = 0; ii < uncompressed.length; ii += 1) {
      context_c = uncompressed.charAt(ii);
      if (!Object.prototype.hasOwnProperty.call(context_dictionary,context_c)) {
        context_dictionary[context_c] = context_dictSize++;
        context_dictionaryToCreate[context_c] = true;
      }

      context_wc = context_w + context_c;
      if (Object.prototype.hasOwnProperty.call(context_dictionary,context_wc)) {
        context_w = context_wc;
      } else {
        if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate,context_w)) {
          if (context_w.charCodeAt(0)<256) {
            for (i=0 ; i<context_numBits ; i++) {
              context_data_val = (context_data_val << 1);
              if (context_data_position == bitsPerChar-1) {
                context_data_position = 0;
                context_data.push(getCharFromInt(context_data_val));
                context_data_val = 0;
              } else {
                context_data_position++;
              }
            }
            value = context_w.charCodeAt(0);
            for (i=0 ; i<8 ; i++) {
              context_data_val = (context_data_val << 1) | (value&1);
              if (context_data_position == bitsPerChar-1) {
                context_data_position = 0;
                context_data.push(getCharFromInt(context_data_val));
                context_data_val = 0;
              } else {
                context_data_position++;
              }
              value = value >> 1;
            }
          } else {
            value = 1;
            for (i=0 ; i<context_numBits ; i++) {
              context_data_val = (context_data_val << 1) | value;
              if (context_data_position ==bitsPerChar-1) {
                context_data_position = 0;
                context_data.push(getCharFromInt(context_data_val));
                context_data_val = 0;
              } else {
                context_data_position++;
              }
              value = 0;
            }
            value = context_w.charCodeAt(0);
            for (i=0 ; i<16 ; i++) {
              context_data_val = (context_data_val << 1) | (value&1);
              if (context_data_position == bitsPerChar-1) {
                context_data_position = 0;
                context_data.push(getCharFromInt(context_data_val));
                context_data_val = 0;
              } else {
                context_data_position++;
              }
              value = value >> 1;
            }
          }
          context_enlargeIn--;
          if (context_enlargeIn == 0) {
            context_enlargeIn = Math.pow(2, context_numBits);
            context_numBits++;
          }
          delete context_dictionaryToCreate[context_w];
        } else {
          value = context_dictionary[context_w];
          for (i=0 ; i<context_numBits ; i++) {
            context_data_val = (context_data_val << 1) | (value&1);
            if (context_data_position == bitsPerChar-1) {
              context_data_position = 0;
              context_data.push(getCharFromInt(context_data_val));
              context_data_val = 0;
            } else {
              context_data_position++;
            }
            value = value >> 1;
          }


        }
        context_enlargeIn--;
        if (context_enlargeIn == 0) {
          context_enlargeIn = Math.pow(2, context_numBits);
          context_numBits++;
        }
        // Add wc to the dictionary.
        context_dictionary[context_wc] = context_dictSize++;
        context_w = String(context_c);
      }
    }

    // Output the code for w.
    if (context_w !== "") {
      if (Object.prototype.hasOwnProperty.call(context_dictionaryToCreate,context_w)) {
        if (context_w.charCodeAt(0)<256) {
          for (i=0 ; i<context_numBits ; i++) {
            context_data_val = (context_data_val << 1);
            if (context_data_position == bitsPerChar-1) {
              context_data_position = 0;
              context_data.push(getCharFromInt(context_data_val));
              context_data_val = 0;
            } else {
              context_data_position++;
            }
          }
          value = context_w.charCodeAt(0);
          for (i=0 ; i<8 ; i++) {
            context_data_val = (context_data_val << 1) | (value&1);
            if (context_data_position == bitsPerChar-1) {
              context_data_position = 0;
              context_data.push(getCharFromInt(context_data_val));
              context_data_val = 0;
            } else {
              context_data_position++;
            }
            value = value >> 1;
          }
        } else {
          value = 1;
          for (i=0 ; i<context_numBits ; i++) {
            context_data_val = (context_data_val << 1) | value;
            if (context_data_position == bitsPerChar-1) {
              context_data_position = 0;
              context_data.push(getCharFromInt(context_data_val));
              context_data_val = 0;
            } else {
              context_data_position++;
            }
            value = 0;
          }
          value = context_w.charCodeAt(0);
          for (i=0 ; i<16 ; i++) {
            context_data_val = (context_data_val << 1) | (value&1);
            if (context_data_position == bitsPerChar-1) {
              context_data_position = 0;
              context_data.push(getCharFromInt(context_data_val));
              context_data_val = 0;
            } else {
              context_data_position++;
            }
            value = value >> 1;
          }
        }
        context_enlargeIn--;
        if (context_enlargeIn == 0) {
          context_enlargeIn = Math.pow(2, context_numBits);
          context_numBits++;
        }
        delete context_dictionaryToCreate[context_w];
      } else {
        value = context_dictionary[context_w];
        for (i=0 ; i<context_numBits ; i++) {
          context_data_val = (context_data_val << 1) | (value&1);
          if (context_data_position == bitsPerChar-1) {
            context_data_position = 0;
            context_data.push(getCharFromInt(context_data_val));
            context_data_val = 0;
          } else {
            context_data_position++;
          }
          value = value >> 1;
        }


      }
      context_enlargeIn--;
      if (context_enlargeIn == 0) {
        context_enlargeIn = Math.pow(2, context_numBits);
        context_numBits++;
      }
    }

    // Mark the end of the stream
    value = 2;
    for (i=0 ; i<context_numBits ; i++) {
      context_data_val = (context_data_val << 1) | (value&1);
      if (context_data_position == bitsPerChar-1) {
        context_data_position = 0;
        context_data.push(getCharFromInt(context_data_val));
        context_data_val = 0;
      } else {
        context_data_position++;
      }
      value = value >> 1;
    }

    // Flush the last char
    while (true) {
      context_data_val = (context_data_val << 1);
      if (context_data_position == bitsPerChar-1) {
        context_data.push(getCharFromInt(context_data_val));
        break;
      }
      else context_data_position++;
    }
    return context_data.join('');
  }
  catch(err) {
    return "Error: "+err;
  }
}

function decompress(compressed) {
    if (compressed == null) return "";
    if (compressed == "") return null;
    return _decompress(compressed.length, 32768, function(index) { return compressed.charCodeAt(index); });
}

function _decompress(length, resetValue, getNextValue) {
  try {
	var f = String.fromCharCode;
    var keyStrBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
    var keyStrUriSafe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$";
    var baseReverseDic = {};
    var dictionary = [],
        next,
        enlargeIn = 4,
        dictSize = 4,
        numBits = 3,
        entry = "",
        result = [],
        i,
        w,
        bits, resb, maxpower, power,
        c,
        data = {val:getNextValue(0), position:resetValue, index:1};

    for (i = 0; i < 3; i += 1) {
      dictionary[i] = i;
    }

    bits = 0;
    maxpower = Math.pow(2,2);
    power=1;
    while (power!=maxpower) {
      resb = data.val & data.position;
      data.position >>= 1;
      if (data.position == 0) {
        data.position = resetValue;
        data.val = getNextValue(data.index++);
      }
      bits |= (resb>0 ? 1 : 0) * power;
      power <<= 1;
    }

    switch (next = bits) {
      case 0:
          bits = 0;
          maxpower = Math.pow(2,8);
          power=1;
          while (power!=maxpower) {
            resb = data.val & data.position;
            data.position >>= 1;
            if (data.position == 0) {
              data.position = resetValue;
              data.val = getNextValue(data.index++);
            }
            bits |= (resb>0 ? 1 : 0) * power;
            power <<= 1;
          }
        c = f(bits);
        break;
      case 1:
          bits = 0;
          maxpower = Math.pow(2,16);
          power=1;
          while (power!=maxpower) {
            resb = data.val & data.position;
            data.position >>= 1;
            if (data.position == 0) {
              data.position = resetValue;
              data.val = getNextValue(data.index++);
            }
            bits |= (resb>0 ? 1 : 0) * power;
            power <<= 1;
          }
        c = f(bits);
        break;
      case 2:
        return "";
    }
    dictionary[3] = c;
    w = c;
    result.push(c);
    while (true) {
      if (data.index > length) {
        return "";
      }

      bits = 0;
      maxpower = Math.pow(2,numBits);
      power=1;
      while (power!=maxpower) {
        resb = data.val & data.position;
        data.position >>= 1;
        if (data.position == 0) {
          data.position = resetValue;
          data.val = getNextValue(data.index++);
        }
        bits |= (resb>0 ? 1 : 0) * power;
        power <<= 1;
      }

      switch (c = bits) {
        case 0:
          bits = 0;
          maxpower = Math.pow(2,8);
          power=1;
          while (power!=maxpower) {
            resb = data.val & data.position;
            data.position >>= 1;
            if (data.position == 0) {
              data.position = resetValue;
              data.val = getNextValue(data.index++);
            }
            bits |= (resb>0 ? 1 : 0) * power;
            power <<= 1;
          }

          dictionary[dictSize++] = f(bits);
          c = dictSize-1;
          enlargeIn--;
          break;
        case 1:
          bits = 0;
          maxpower = Math.pow(2,16);
          power=1;
          while (power!=maxpower) {
            resb = data.val & data.position;
            data.position >>= 1;
            if (data.position == 0) {
              data.position = resetValue;
              data.val = getNextValue(data.index++);
            }
            bits |= (resb>0 ? 1 : 0) * power;
            power <<= 1;
          }
          dictionary[dictSize++] = f(bits);
          c = dictSize-1;
          enlargeIn--;
          break;
        case 2:
          return result.join('');
      }

      if (enlargeIn == 0) {
        enlargeIn = Math.pow(2, numBits);
        numBits++;
      }

      if (dictionary[c]) {
        entry = dictionary[c];
      } else {
        if (c === dictSize) {
          entry = w + w.charAt(0);
        } else {
          return null;
        }
      }
      result.push(entry);

      // Add w+entry[0] to the dictionary.
      dictionary[dictSize++] = w + entry.charAt(0);
      enlargeIn--;

      w = entry;

      if (enlargeIn == 0) {
        enlargeIn = Math.pow(2, numBits);
        numBits++;
      }

    }
  }
  catch(err) {
	return "Error: "+err;
  }
}

The SaveGameCode function code:

(Function below has been updated to v3.0)

// SaveGameCode(ShowCodePopupFlag) Function to collect changeable attributes into a string in order to generate a SaveCode for LoadGameCode to load.
// The ShowCodePopupFlag input parameter is a boolean value. If TRUE, then the function will present the player with a popup window containing their encoded save code. If FALSE, the function will instead RETURN the SaveString (so if X=SaveGameCode(False), then X will equal the generated SaveString).
// Will not save non-string/non-object lists/dictionaries (with grid_coordinates as an exception), will not save script attributes, will not save script dictionaries, will not save delegate attributes, will not save command patterns, will not save the "look" attribute (as it should not change), and will not save the "description" attribute (as it should not change).
// Will not grab any other attributes attached to the "game" object except for game.gameid, game.version, game.pov, and game.timeelapsed. IF YOU WOULD LIKE TO SAVE ANY ADDITIONAL ATTRIBUTES ATTACHED TO "game", you will need to add the attribute names to a game.SaveAtts STRINGLIST attribute OR EDIT THIS FUNCTION TO CALL THEM OUT SPECIFICALLY. If you'd like to go the latter route I've noted the section below where I would recommend adding custom "game" attributes with a ***
// Will not save timer status UNLESS the names of the timers are added to a game.SaveTimers STRINGLIST attribute OR YOU EDIT THIS FUNCTION DIRECTLY! If you want to go the latter route, I would recommend adding these to the section below marked with ***
SuppressTurnscripts
// Make sure ShowCodePopupFlag is of type 'boolean'
if (not TypeOf(ShowCodePopupFlag)="boolean") {
  X = TypeOf(ShowCodePopupFlag)
  error ("ERROR: SaveGameCode function expected input 'ShowCodePopupFlag' to be of type 'boolean', but instead recieved an input of type '"+X+"'!")
}
SaveString = ""
CreatedObj = NewStringList()
DestroyedObj = NewStringList()
// Set delimiters.
// WARNING: D1 or D2 CANNOT be present in any object names, attribute names, or attribute values that you intend to save. Otherwise, the save will not load properly.
// WARNING: D3 CANNOT be present in any object names or attribute names, but CAN be present in an attribute's value. This is because D3 MUST be the delimiter used to separate List entries in order to load lists properly
// D1 delimiter will separate full object attributes, D2 delimiter will separate the data that comprises an attribute.
// D3 is not set by this function, but instead is what the LoadGame() function will assume separates all list entries. As a reminder, by-default Quest will use ; as the List delimiter.
D1 = "|"
D2 = "$"
D3 = ";"
D4 = "@"
// Save the player's current map before saving
// Make sure first two entries are gameid and version (for load function)
SaveString = SaveString+"game"+D2+"gameid"+D2+"string"+D2+game.gameid+D1
SaveString = SaveString+"game"+D2+"version"+D2+"string"+D2+game.version+D1
// Grab current active player (game.pov). This way the LoadGame knows who the player object is and to update its parent last
SaveString = SaveString+"game"+D2+"pov"+D2+"object"+D2+game.pov+D1
// Record all changable object attributes
foreach (o, AllObjects()) {
  objectname = o.name
  // Check to see if object was created by player mid-game by comparing to the objectlist at start of game
  if (not ListContains(game.StartingObjStrList, objectname)) {
    // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
    if (not ListContains(CreatedObj, objectname)) {
      list add (CreatedObj, objectname)
    }
    // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
    IncludeTypeFlag = True
  }
  else {
    IncludeTypeFlag = False
  }
  foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
    fullname = objectname+"."+attributename
    att_datatype = ToString(TypeOf(o, attributename))
    if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
      if (att_datatype="object") {
        v = GetAttribute (o, attributename)
        att_value = v.name
      }
      else if (att_datatype="stringlist" or att_datatype="objectlist") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "List: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("List: "))
        // Confirm there are no banned delimiters in the list entries
        v = GetAttribute (o, attributename)
        if (ListCount(v)>0) {
          if (att_datatype="stringlist") {
            foreach (listcheck, v) {
              // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
              if (Instr(listcheck,D1)>0 or Instr(listcheck,D2)>0 or Instr(listcheck,D3)>0 or Instr(listcheck,D4)>0) {
                error ("ERROR: Banned delimiter detected in \""+fullname+"\" list entry '"+listcheck+"'! Consider editting SaveGameCode function to change delimiters, or renaming list entry. Current banned list entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
              }
            }
          }
        }
      }
      else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
        // Confirm there are no banned delimiters in the dictionary entries
        v = GetAttribute (o, attributename)
        if (DictionaryCount(v)>0) {
          foreach (dictkey, v) {
            if (Instr(dictkey,D1)>0 or Instr(dictkey,D2)>0 or Instr(dictkey,D3)>0 or Instr(dictkey,D4)>0) {
              error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key '"+dictkey+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary key. Current banned dictionary key delimiters: "+D1+" "+D2+" "+D3+" "+D4)
            }
            if (att_datatype="stringdictionary") {
              dictitm = DictionaryItem (v, dictkey)
              // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
              if (Instr(dictitm,D1)>0 or Instr(dictitm,D2)>0 or Instr(dictitm,D3)>0 or Instr(dictitm,D4)>0) {
                error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key.value '"+dictkey+"."+dictitm+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary value. Current banned dictionary entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
              }
            }
          }
        }
      }
      else {
        att_value = ToString(GetAttribute (o, attributename))
      }
      // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
      if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object.Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
      }
      SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
    }
  }
}
foreach (o, AllExits()) {
  objectname = o.name
  // Check to see if exit was created by player mid-game by comparing to the objectlist at start of game
  if (not ListContains(game.StartingObjStrList, objectname)) {
    // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
    if (not ListContains(CreatedObj, objectname)) {
      list add (CreatedObj, objectname)
    }
    // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
    IncludeTypeFlag = True
  }
  else {
    IncludeTypeFlag = False
  }
  foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
    fullname = objectname+"."+attributename
    att_datatype = ToString(TypeOf(o, attributename))
    if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate" and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
      if (att_datatype="object") {
        v = GetAttribute (o, attributename)
        att_value = v.name
      }
      else if (att_datatype="stringlist" or att_datatype="objectlist") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "List: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("List: "))
      }
      else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
      }
      else {
        att_value = ToString(GetAttribute (o, attributename))
      }
      // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
      if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object. Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
      }
      SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
    }
  }
}
foreach (turnscript, AllTurnScripts()) {
  // Check for which turnscripts are enabled/disabled
  if (GetBoolean(turnscript, "enabled")) {
    SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"True"+D1
  }
  else {
    SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"False"+D1
  }
}
// Determine if any objects were destroyed by the player since game start...
foreach (objectname, game.StartingObjStrList) {
  IsThere = GetObject(objectname)
  if (Equal(IsThere,null)) {
    list add (DestroyedObj, objectname)
  }
}
// Save the game.timeelapsed attribute
SaveString = SaveString+"game"+D2+"timeelapsed"+D2+"int"+D2+ToString(game.timeelapsed)+D1
// Check if game.SaveAtts and/or game.SaveTimers exists.
// game.SaveAtts is expected to be a stringlist containing a list of game attributes to save.
if (HasAttribute (game, "SaveAtts")) {
  SaveAttType = TypeOf(game.SaveAtts)
  if (SaveAttType="stringlist") {
    if (ListCount(game.SaveAtts)>0) {
      foreach (x, game.SaveAtts) {
        AttValue = GetAttribute (game, x)
        att_datatype = TypeOf(AttValue)
        if (not Equal(AttValue,null) and not Equal(x,"checkpoints") and not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern") {
          SaveString = SaveString+"game"+D2+x+D2+att_datatype+D2+ToString(AttValue)+D1
        }
        else if (Equal(x,"checkpoints")) {
          error ("ERROR: game.SaveAtts - Banned attribute 'checkpoints' found in game.SaveAtts. game.checkpoints cannot be saved using game.SaveAtts!")
        }
        else if (Equal(AttValue,null)) {
          error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not found attached to game object!")
        }
        else {
          error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not allowed. SaveGameCode cannot save attributes of type: "+att_datatype)
        }
      }
    }
  }
  else {
    error ("ERROR: game.SaveAtts expected to be a stringlist containing a list of game attributes to save. Instead, game.SaveAtts found is of datatype: "+SaveAttType)
  }
}
// game.SaveTimers is expected to be an stringlist containing a list of the names of all timers in the game that the author wants to save (ideally, all timers in the game).
if (HasAttribute (game, "SaveTimers")) {
  SaveAttType = TypeOf(game.SaveTimers)
  if (SaveAttType="stringlist") {
    if (ListCount(game.SaveTimers)>0) {
      foreach (x, game.SaveTimers) {
        T = GetObject(x)
        if (not Equal(T,null)) {
          TimerName = x.name
          TimerValue1 = x.trigger
          TimerValue2 = x.interval
          TimerValue3 = x.enabled
          TimerValue1Type = TypeOf(TimerValue1)
          TimerValue2Type = TypeOf(TimerValue2)
          TimerValue3Type = TypeOf(TimerValue3)
          SaveString = SaveString+TimerName+D2+"trigger"+D2+TimerValue1Type+D2+ToString(TimerValue1)+D1
          SaveString = SaveString+TimerName+D2+"interval"+D2+TimerValue2Type+D2+ToString(TimerValue2)+D1
          SaveString = SaveString+TimerName+D2+"enabled"+D2+TimerValue3Type+D2+ToString(TimerValue3)+D1
        }
        else {
          error ("ERROR: game.SaveTimers - Timer named '"+x+"' not found!")
        }
      }
    }
  }
  else {
    error ("ERROR: game.SaveTimers expected to be a stringlist containing a list of the names of timers. Instead, game.SaveTimers found is of datatype: "+SaveAttType)
  }
}
// If neither of those attributes exist, then the developer can also add their own custom attributes to save using the template below...
// *** TO DEVELOPER: Recommend putting timer status and other "game" attributes that can change based on user action during the game in this function below:
// The template to save additional attributes is SaveString=SaveString+{string objectname}+D2+{string attributename}+D2+{datatype string}+D2+ToString({attribute value})+D1
// For example, to save the "timer.enabled" attribute for a timer named BeeTimer: SaveString=SaveString+"BeeTimer"+D2+"enabled"+D2+"boolean"+D2+ToString(BeeTimer.enabled)+D1
// ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
// DO NOT APPEND THE SAVESTRING WITH ANY ADDITIONAL ATTRIBUTES BELOW THIS POINT. The game.pov.grid_coordinates, Created/Destroyed objectlist, and delimiters MUST be added on last in order for the LoadGame() Function to load properly.
// Save the player.grid_coordinates so the player's map is saved. Because it is a dictionary of dictionaries, it must be saved in a special way...
// grid_coordinates will be saved in form: "StuffBefore|@ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|"
foreach (o, AllObjects()) {
  foreach (attributename, GetAttributeNames(o,false)) {
    objectname = o.name
    fullname = objectname+"."+attributename
    att_datatype = ToString(TypeOf(o, attributename))
    if (att_datatype="dictionary" and StartsWith(attributename,"saved_map_for_")) {
      // ASSUMES THAT ANY SAVED MAP DATA (for teleporting and keeping your map) STARTS WITH 'saved_map_for'. This follows the naming convention recommended by https://docs.textadventures.co.uk/quest/showing_a_map.html
      SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
      foreach (UKey, GetAttribute(o, attributename)) {
        SaveString = SaveString+UKey+D2
        UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
        foreach (Lkey, UVal) {
          Lval = DictionaryItem(UVal, Lkey)
          Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
          SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
        }
        SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
      }
    }
    else if (att_datatype="dictionary" and attributename="grid_coordinates") {
      // Save the current map. Typically this is game.pov.grid_coordinates, but if player character can change, there may be multiple occurences of 'grid_coordinates'. Save them all.
      SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
      foreach (UKey, GetAttribute(o, attributename)) {
        SaveString = SaveString+UKey+D2
        UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
        foreach (Lkey, UVal) {
          Lval = DictionaryItem(UVal, Lkey)
          Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
          SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
        }
        SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
      }
    }
  }
}
// Add on the list of created/destroyed objects...
X = ToString(CreatedObj)
// Cut off the "List: " string that preceeds its values when you use the ToString() command
CreatedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
X = ToString(DestroyedObj)
// Cut off the "List: " string that preceeds its values when you use the ToString() command
DestroyedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
SaveString = SaveString+D4+CreatedObjStr+D1+DestroyedObjStr
// Special logic needed in order to save the game.checkpoints attribute
D4toD1 = D4+D3+D2+D1
if (HasAttribute(game,"checkpoints")) {
  if (DictionaryCount(game.checkpoints)>0) {
    KeyList = ""
    CheckValList = ""
    foreach (k, game.checkpoints) {
      KeyList = KeyList+k+D3
      val = DictionaryItem(game.checkpoints, k)
      CheckValList = CheckValList+val+D4toD1
    }
    // Remove final D3 from KeyList string and final D4toD1 from CheckValList
    KeyList = Left(KeyList, LengthOf(KeyList)-LengthOf(D3))
    CheckValList = Left(CheckValList, LengthOf(CheckValList)-LengthOf(D4toD1))
    // Add game.checkpoints data to SaveString
    SaveString = SaveString+D4toD1+KeyList+D4toD1+CheckValList
  }
  else {
    // If game.checkpoints empty, just add D4toD1 to SaveString.
    SaveString = SaveString+D4toD1
  }
}
else {
  // If game.checkpoints non-existant, just add D4toD1 to SaveString.
  SaveString = SaveString+D4toD1
}
// Append the end of the SaveString with the delimiters used, so LoadGame() knows what delimiter maps to what...
SaveString = SaveString+D1+D2+D3+D4
// msg for Debugging:
// msg (SaveString+"<br><br>")
if (ShowCodePopupFlag=True) {
  // Create save code and present to player in textbox
  JS.CreateSaveCode (SaveString)
  JS.setCss ("#msgbox", "word-wrap:break-word;max-height:250px;")
}
return (SaveString)

The LoadGameCode function code:

(Function below has been updated to v3.0.)

// LoadGameCode(SaveGameCodeDecoded) function to load a SaveGameCode save-state. Takes a decoded (not in base64) SaveGameCode created by SaveGameCode and decoded from base64 by java functions. Requires SaveLoadJavaCode.js in order to function!
SuppressTurnscripts
// NOTE: Requires either JS.LoadSaveCode to be called with a SaveGameCode as an input parameter, or JS.LoadGamePrompt to be called elsewhere (i.e. by a custom "LoadGame" command), which will make a popup appear for the player to enter their SaveCode, eventually piping it to this function.
// TO DEVELOPER: Set the OldestAllowedVersion to the oldest compatible game version that a player can load saved game data from. Setting OldestAllowedVersion=0 will essentially allow the player to load saves from any old version. Setting OldestAllowedVersion=game.version will make it so the player can ONLY load saves from the current game version.
OldestAllowedVersion = 0.0
// TO DEVELOPER: Setting DebugMode to 'True' will enable the printing of debug messages to the screen when running. Very useful for testing out the function if you've made any custom edits for compatibility or the like.
DebugMode = False
// Msg for Debugging:
if (DebugMode) {
  msg ("<br>Full decoded SaveCode:<br>"+SaveGameCodeDecoded)
}
// Set up other variables for later
bla => {
}
upgradesave = False
Proceed = False
SkippedAttList = ""
CreatedObjDebugList = ""
DestroyedObjDebugList = ""
// Check for a "✓✓" at the end of the SaveGameCodeDecoded string. If it's there, then the function knows this savecode is for an older game version. "But how do we know that ✓✓ isn't being used as a custom delimiter?" Because custom delimiters can only be one character long and two delimiters cannot be the same. Also ✓ (theoretically) cannot be converted to base64, so the user would get an error trying to make a SaveGameCode with ✓ as a delimiter.
CheckForCheck = Right(SaveGameCodeDecoded,2)
if (CheckForCheck="✓✓") {
  upgradesave = True
  SaveGameCodeDecoded = Left(SaveGameCodeDecoded, LengthOf(SaveGameCodeDecoded)-2)
}
// Retrieve delimiters from end of SaveGameCodeDecoded
Dls = Right(SaveGameCodeDecoded,4)
D1 = Mid (Dls, 1, 1)
D2 = Mid (Dls, 2, 1)
D3 = Mid (Dls, 3, 1)
D4 = Mid (Dls, 4, 1)
// Remove delimiters from end of SaveGameCode
SaveCode = Left(SaveGameCodeDecoded, LengthOf(SaveGameCodeDecoded)-(LengthOf(Dls)))
// Special logic to extract saved game.checkpoints attribute
D4toD1 = D4+D3+D2+D1
CheckpointSplit = Split(SaveCode, D4toD1)
SaveCode = ListItem(CheckpointSplit,0)
CheckpointKeys = ListItem(CheckpointSplit,1)
// If CheckpointKeys not an empty string, then that means there was game.checkpoints data saved. Extract it.
if (not CheckpointKeys="") {
  CheckpointKeyList = Split(CheckpointKeys, D3)
  game.checkpoints = NewStringDictionary()
  for (xx, 0, ListCount(CheckpointKeyList)-1) {
    key = ListItem(CheckpointKeyList,xx)
    val = ListItem(CheckpointSplit,xx+1)
    DictionaryAdd (game.checkpoints, key, val)
  }
}
// Extract the Created/Destroyed object lists. The START of the created/destroyed section should be after the LAST D4 delimiter...
CreatedDestroyedInfo = Right(SaveCode, LengthOf(SaveCode)-InstrRev(SaveCode, D4))
if (DebugMode) {
  msg ("<br>CreatedDestroyedInfo: "+CreatedDestroyedInfo)
}
CDList = Split(CreatedDestroyedInfo,D1)
CSection = ListItem (CDList, 0)
DSection = ListItem (CDList, 1)
if (CSection="") {
  CreatedList = NewStringList()
}
else {
  CreatedList = Split (CSection, D3)
  // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
  list remove (CreatedList, " ")
}
if (DSection="") {
  DestroyedList = NewStringList()
}
else {
  DestroyedList = Split (DSection, D3)
  // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
  list remove (DestroyedList, " ")
}
// Remove Created/Destroyed list from end of SaveCode, also removing the final D1 and D4 delimiter...
SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(CreatedDestroyedInfo)+2))
// Extract the player.grid_coordinates info separately from the rest of the savecode. It has special rules for decoding it since it is a dictionary of dictionaries.
GridGInfo = Right(SaveCode, LengthOf(SaveCode)-Instr(SaveCode, D4))
if (DebugMode) {
  msg ("<br>GridGInfo: "+GridGInfo)
}
// Remove player.grid_coordinates info from end of SaveCode also remove the final D1 & D4 delimiter separating the grid_coordinates from the rest of the attributes
SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(GridGInfo)+2))
if (DebugMode) {
  msg ("<br>SaveCode w/o player.grid_coordinate or create/destroy info:<br>"+SaveCode)
}
// Note: if the "SaveCode" begins with the word "Error:", then an error was encountered when trying to convert from Base64.
// Extract SaveCode game info. This includes the gameid, game version, and current player (game.pov)...
GameIdDelim = Instr (SaveCode, D1)
GameVersionDelim = Instr(GameIdDelim+1,SaveCode,D1)
GamePOVDelim = Instr(GameVersionDelim+1,SaveCode,D1)
GameInfo = Split(Left(SaveCode,GamePOVDelim-1),D1)
GameIdObjectEl = ListItem (GameInfo,0)
GameIdElements = Split(GameIdObjectEl,D2)
Loaded_GameId = ListItem (GameIdElements, 3)
GameVerObjectEl = ListItem (GameInfo,1)
GameVerElements = Split(GameVerObjectEl,D2)
VersionString = ListItem (GameVerElements, 3)
Loaded_GameVersion = ToDouble(VersionString)
GamePOVObjectEl = ListItem (GameInfo,2)
GamePOVElements = Split(GamePOVObjectEl,D2)
GamePOVName = ListItem (GamePOVElements, 3)
if (StartsWith(GamePOVName,"Object: ")) {
  GamePOVName = Right(GamePOVName,LengthOf(GamePOVName)-LengthOf("Object: "))
}
GamePOVObject = GetObject (GamePOVName)
GamePOVParent = GetAttribute (GamePOVObject, "parent")
// Check that the save belongs to this game by comparing gameIds
if (not Loaded_GameId=game.gameid) {
  error ("Load Aborted: SaveCode not identified by this game. GameID mismatch.")
}
else {
  // Compare version of game in SaveCode to version of game loading it
  ThisGame_GameVersion = ToDouble(game.version)
  if (not TypeOf(OldestAllowedVersion)="double") {
    OldestAllowedVersion_Double = ToDouble(OldestAllowedVersion)
  }
  else {
    OldestAllowedVersion_Double = OldestAllowedVersion
  }
  // If upgrading from an old game version, then arbitrarily set Loaded_GameVersion to ThisGameVersion to proceed.
  if (Loaded_GameVersion<ThisGame_GameVersion) {
    if (upgradesave = False) {
      if (OldestAllowedVersion_Double<=Loaded_GameVersion) {
        msg ("WARNING! The SaveCode you are attempting to load is from an older game version.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br><br>Would you like to attempt to upgrade this save to the current version? (Results may vary...)")
        // Need to save SaveGameCodeDecoded as an attribute temporarily so it can be used by the ShowMenu function
        create ("SaveGameDecodedObj")
        set (SaveGameDecodedObj, "value", SaveGameCodeDecoded)
        ShowMenu ("", Split("Yes;No"), false) {
          switch (result) {
            case ("Yes") {
              SuppressTurnscripts
              msg ("Save code identified! Proceeding with load, please wait...")
              OlderSaveCode = SaveGameDecodedObj.value+"✓✓"
              LoadGameCode (OlderSaveCode)
              destroy ("SaveGameDecodedObj")
            }
            case ("No") {
              SuppressTurnscripts
              msg ("Load Aborted.")
              destroy ("SaveGameDecodedObj")
            }
          }
        }
      }
      else {
        error ("ERROR: The SaveCode you are attempting to load is from an INCOMPATIBLE older game version, and thus cannot be loaded by this version of the game.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Oldest Compatible Version: v"+ToString(OldestAllowedVersion)+"<br><br>Loading aborted...")
      }
    }
    else {
      msg ("Applying savecode from older version...")
      Proceed = True
    }
  }
  else if (Loaded_GameVersion>ThisGame_GameVersion) {
    error ("ERROR: The SaveCode you are attempting to load is from a newer version of this game and is not compatible.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Please try a different SaveCode or use an updated game file.<br><br>Load aborted.")
  }
  else {
    msg ("Proceeding with load...")
    Proceed = True
  }
  if (Proceed=True) {
    // Create any objects noted in the CreatedList, if there are any, so their relevant attributes can be added without error...
    if (ListCount(CreatedList)>0) {
      foreach (o, CreatedList) {
        // Check that objects don't already exist...
        IsThere = GetObject(o)
        if (Equal(IsThere,null)) {
          // If not, create the object
          create (o)
          CreatedObjDebugList = CreatedObjDebugList+o+"<br>"
        }
      }
    }
    player.grid_coordinates = null
    // Split the save code up into all objects. Then parse through the value of each object attribute
    SavedObjectList = Split(SaveCode, D1)
    foreach (o, SavedObjectList) {
      Skip_Att = False
      objelements = Split(o, D2)
      objectname = ListItem (objelements, 0)
      object = GetObject (objectname)
      attributename = ListItem (objelements, 1)
      fullname = objectname+"."+attributename
      preload_att_value = GetAttribute (object, attributename)
      att_datatype = ListItem (objelements, 2)
      if (ListCount(objelements)=3) {
        att_value = ""
      }
      else {
        att_value = ListItem (objelements, 3)
      }
      // Check that the attribute is supported
      if (not att_datatype="string" and not att_datatype="boolean" and not att_datatype="object" and not att_datatype="int" and not att_datatype="double" and not att_datatype="stringlist" and not att_datatype="objectlist" and not att_datatype="stringdictionary" and not att_datatype="objectdictionary") {
        msg ("WARNING! Unsupported datatype \""+att_datatype+"\" detected in SaveCode attribute \""+fullname+"\"! Skipping and moving on to next attribute...")
        Skip_Att = True
        SkippedAttList = SkippedAttList+fullname+"<br>"
      }
      // Convert the string attribute value and convert it to the datatype that it actually needs to be. This att_value_obj variable will also be directly compared to preload_att_value to determine if the pre- and post- load values are equal or not...
      att_value_obj = att_value
      if (att_datatype="object") {
        if (StartsWith(att_value,"Object: ")) {
          att_value_obj = GetObject(Right(att_value,LengthOf(att_value)-LengthOf("Object: ")))
        }
        else {
          att_value_obj = GetObject(att_value)
        }
      }
      else if (att_datatype="boolean") {
        if (att_value="True") {
          att_value_obj = True
        }
        else {
          att_value_obj = False
        }
      }
      else if (att_datatype="int") {
        att_value_obj = ToInt(att_value)
      }
      else if (att_datatype="double") {
        att_value_obj = ToDouble(att_value)
      }
      else if (att_datatype="stringlist") {
        if (att_value="") {
          att_value_obj = NewStringList()
        }
        else {
          att_value_obj = Split (att_value, D3)
          // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
          list remove (att_value_obj, " ")
        }
      }
      else if (att_datatype="objectlist") {
        if (att_value="") {
          att_value_obj = NewObjectList()
        }
        else {
          att_value_obj = NewObjectList()
          objlistlist = Split (att_value, D3)
          foreach (olt, objlistlist) {
            // Need to remove the "Object: " that will preceed each entry, and turn the string entry into the actual object before re-adding to list. We put it into the following "if" statement in order to exclude the blank list entry that gets created at the end of the list by loading
            if (StartsWith(olt,"Object: ")) {
              value = GetObject(Right(olt,LengthOf(olt)-LengthOf("Object: ")))
              if (not value=null) {
                list add (att_value_obj, value)
              }
              else {
                msg ("WARNING! Object \""+olt+"\" detected in saved objectlist \""+fullname+"\" does not exist! Object \""+olt+"\" not added to list! Loaded game may not work properly!")
                SkippedAttList = SkippedAttList+"Objectlist '"+fullname+"' item: "+olt+"<br>"
              }
            }
          }
        }
      }
      else if (att_datatype="stringdictionary") {
        if (att_value="") {
          att_value_obj = NewStringDictionary()
        }
        else {
          att_value_obj = NewStringDictionary()
          // Add dictionary values from SaveGame
          dictrows = Split(att_value, ";")
          foreach (kv, dictrows) {
            if (DebugMode) {
              msg ("StringDict '"+fullname+"' key-value: "+ToString(kv))
            }
            KeyValList = Split(kv," = ")
            key = ListItem(KeyValList, 0)
            value = ListItem(KeyValList, 1)
            DictionaryAdd (att_value_obj, key, value)
          }
        }
      }
      else if (att_datatype="objectdictionary") {
        if (att_value="") {
          att_value_obj = NewObjectDictionary()
        }
        else {
          att_value_obj = NewObjectDictionary()
          dictrows = Split(att_value, ";")
          foreach (kv, dictrows) {
            if (DebugMode) {
              msg ("ObjDict  '"+fullname+"' key-value: "+ToString(kv))
            }
            KeyValList = Split(kv," = ")
            key = ListItem(KeyValList, 0)
            obj = ListItem(KeyValList, 1)
            if (StartsWith(obj,"Object: ")) {
              value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
            }
            else {
              value = obj
            }
            if (not value=null) {
              DictionaryAdd (att_value_obj, key, value)
            }
            else {
              msg ("WARNING! Object \""+obj+"\" detected in saved objectdictionary \""+fullname+"\" does not exist! Object \""+obj+"\" not added to dictionary! Loaded game may not work properly!")
              SkippedAttList = SkippedAttList+"Objectdictionary '"+fullname+"' item: "+olt+"<br>"
            }
          }
        }
      }
      if (objectname=GamePOVName and attributename="parent") {
        // Check that the attribute is NOT game.pov.parent. If so, we want to make sure that gets updated last
        Skip_Att = True
        GamePOVParent = att_value_obj
      }
      // Make sure the object you are trying to add/update the attribute to exists, otherwise you'd get an error trying to update/create its attribute. If the attribute doesn't exist but the object does, then this function will create it. Also, don't update the game.version or game.pov: The game.version should not be updated from the savecode if you're loading from a previous version, and the game.pov is updated last.
      if (not Equal(object,null) and not Equal(att_value_obj,null) and not fullname="game.gameid" and not fullname="game.version" and not fullname="game.pov" and not Skip_Att=True) {
        if (Equal(preload_att_value,null)) {
          if (DebugMode) {
            msg ("<br>ATTENTION: Attribute '"+fullname+"' does NOT exist in current game, but its parent object '"+objectname+"' does, so attribute will be created!<br>")
          }
          preload_att_value = "null"
        }
        // Msgs for debugging:
        if (DebugMode) {
          msg ("objectname="+objectname)
          msg ("attributename="+attributename)
          msg ("att_datatype="+att_datatype)
          msg ("preload_att_value="+ToString(preload_att_value))
          if (Equal(preload_att_value,"null")) {
            msg ("preload_att_datatype=null")
          }
          else {
            msg ("preload_att_datatype="+TypeOf(preload_att_value))
          }
          msg ("att_value="+ToString(att_value))
          msg ("att_value_obj="+ToString(att_value_obj))
          msg ("isEqual att_value=preload_att_value?: "+ToString(Equal(att_value,preload_att_value)))
          msg ("isEqual att_value_obj=preload_att_value?: "+ToString(Equal(att_value_obj,preload_att_value)))
          msg ("isEqual ToString(att_value_obj)=ToString(preload_att_value)?: "+ToString(Equal(ToString(att_value_obj),ToString(preload_att_value))))
          msg ("<br>")
        }
        // If attributes are already equal to those in the savecode, no need to change them. Else, change 'em.
        if (not Equal(att_value,preload_att_value) and not Equal(att_value_obj,preload_att_value) and not Equal(ToString(att_value_obj),ToString(preload_att_value))) {
          if (DebugMode) {
            msg ("Updating attribute: "+fullname+"<br><br>")
          }
          // Check if attribute has an associated change script. If so, this section will make sure that setting the attribute on load WON'T activate its associate turnscript
          cha = "changed" + attributename
          if (HasAttribute (object, cha)) {
            // If the attribute DOES have an associated change script, temporarily blank it out so it does not execute during loading
            scr = GetAttribute (object, cha)
            set (object, cha, bla)
          }
          // Update the attributes in the game with those from the SaveCode...
          if (att_datatype="boolean") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="int") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="double") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="object") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="stringlist" or att_datatype="objectlist" or att_datatype="stringdictionary" or att_datatype="objectdictionary") {
            // NOTE TO DEVELOPER:
            // Alter the following logic below to fit your needs. Especially important to make sure this works properly for YOUR game for compatibility between game versions!
            // If ReplaceContents = True, then any list or dictionary in your game will be COMPLETELY REPLACED by its corresponding list/dictionary from the savecode.
            // If ReplaceContents = False, then the list/dictionary contents in the SaveCode will be ADDED to the existing corresponding list/dictionary. NOTE: When adding to an existing list/dict, the code, as-written, will REMOVE ANY DUPLICATES from the lists/dictionaries! ALSO, be careful where you allow LoadGame() to be called in cases where ReplaceContents=False, ESPECIALLY if the list/dict contents can change through the course of the game! Calling this LoadGameCode function only from a titlescreen (before any lists/dictionaries have changed), for instance, may be one possible way to account for this.
            // ReplaceContents=True by default, but this may not be desirable in all cases (i.e. if you updated the contents of a permanent dictionary/list between versions), so it is up to YOU to ensure this section behaves as you want it to. Remember that the "object" and "attributename" variables exist at this point to call out specific list/dictionary objects.
            if (upgradesave = True) {
              // This section will trigger if the player is loading a save from a previous game version
              ReplaceContents = True
            }
            else {
              // If this savecode is NOT coming from a previous game version, then I assume it is safe to completely replace the existing dictionary with the saved one.
              ReplaceContents = True
            }
            if (att_datatype="stringlist") {
              if (ReplaceContents = True) {
                // Completely replace stringlist contents with those found in the SaveCode
                set (object, attributename, att_value_obj)
              }
              else {
                // Add the contents of the saved stringlist TO the existing stringlist in-game
                FinalList = NewStringList()
                // Retrieve the contents of the existing list
                PreLoadList = preload_att_value
                CombinedList = ListCombine (PreLoadList, att_value_obj)
                // Remove duplicates
                CompactList = ListCompact (CombinedList)
                // CompactList will be a generic "list" type object, need to convert it back to a stringlist...
                foreach (olt, CompactList) {
                  list add (FinalList, olt)
                }
                set (object, attributename, FinalList)
              }
            }
            else if (att_datatype="objectlist") {
              if (ReplaceContents = True) {
                // Completely replace objectlist contents with those found in the SaveCode
                set (object, attributename, att_value_obj)
              }
              else {
                // Add the contents of the saved objectlist TO the existing objectlist in-game
                // Retrieve the contents of the existing list
                PreLoadList = preload_att_value
                CombinedList = ListCombine (PreLoadList, att_value_obj)
                // Remove duplicates
                FinalList = ObjectListCompact (CombinedList)
                set (object, attributename, FinalList)
              }
            }
            else if (att_datatype="stringdictionary") {
              if (ReplaceContents = True) {
                // Then completely overwrite existing stringdictionary contents with those in the savecode
                set (object, attributename, att_value_obj)
                Dummy = NewStringDictionary()
              }
              else {
                // Add the contents of the saved stringdictionary TO the existing stringdictionary in-game
                Dummy = preload_att_value
                // Add dictionary values from SaveGame
                dictrows = Split(att_value, ";")
                foreach (kv, dictrows) {
                  KeyValList = Split(kv," = ")
                  key = ListItem(KeyValList, 0)
                  value = ListItem(KeyValList, 1)
                  DictionaryAdd (Dummy, key, value)
                }
                set (object, attributename, Dummy)
              }
            }
            else if (att_datatype="objectdictionary") {
              if (upgradesave = False) {
                // Then completely overwrite existing objectdictionary contents with those in the savecode
                set (object, attributename, att_value_obj)
              }
              else {
                // Add the contents of the saved objectdictionary TO the existing objectdictionary in-game
                Dummy = preload_att_value
                // Add dictionary values from SaveGame
                dictrows = Split(att_value, ";")
                foreach (kv, dictrows) {
                  KeyValList = Split(kv," = ")
                  key = ListItem(KeyValList, 0)
                  value = ListItem(KeyValList, 1)
                  if (StartsWith(value,"Object: ")) {
                    value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
                  }
                  DictionaryAdd (Dummy, key, value)
                }
                set (object, attributename, Dummy)
              }
            }
          }
          else if (att_datatype="string") {
            set (object, attributename, att_value)
          }
          else {
            error ("ERROR: Unsupported object type detected in SaveCode: "+fullname+" of "+att_datatype+" datatype.")
          }
          if (HasAttribute (object, cha)) {
            // If a change script exists for this attribute, set change script back to original value after attribute has been changed
            set (object, cha, scr)
            scr => {
            }
          }
        }
      }
    }
    // Extract and update map data from saved grid_coordinates. Because grid_coordinates is a dictionary of dictionaries, it needed to be saved in a special way. Thus, it needs to be loaded in a special way as well.
    // If D1=|,D2=$,D3=;,and D4=@, then grid_coordinates will be saved in form: "ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|" etc.
    AllSavedGrids = Split(GridGInfo,D4)
    if (DebugMode) {
      msg ("<br>AllSavedGrids: "+ToString(AllSavedGrids))
    }
    foreach (A, AllSavedGrids) {
      UDictionary = NewDictionary()
      ItemAndValue = Split(A,"&%&")
      ObjAndAtt = ListItem(ItemAndValue,0)
      ObjAndAtt = Split(ObjAndAtt,D2)
      objectname = ListItem(ObjAndAtt,0)
      attributename = ListItem(ObjAndAtt,1)
      object = GetObject(objectname)
      GridVals = ListItem(ItemAndValue,1)
      GridVals = Split(GridVals,D1)
      foreach (B, GridVals) {
        UKeyAndUVal = Split(B,D2)
        UKey = ListItem(UKeyAndUVal,0)
        UVal = ListItem(UKeyAndUVal,1)
        UVal = Split(UVal,D3)
        LDictionary = NewDictionary()
        foreach (C, UVal) {
          LkeyAndLval = Split(C," = ")
          Lkey = ListItem(LkeyAndLval,0)
          LvalAndType = ListItem(LkeyAndLval,1)
          LvalAndType = Split(LvalAndType,":")
          Lval_str = ListItem(LvalAndType,0)
          LType = ListItem(LvalAndType,1)
          if (LType="int") {
            Lval = ToInt(Lval_str)
          }
          else if (LType="double") {
            Lval = ToDouble(Lval_str)
          }
          else if (LType="boolean") {
            if (Lval_str="True") {
              Lval = True
            }
            else {
              Lval = False
            }
          }
          else {
            error ("ERROR: Unsupported datatype found in "+objectname+"."+attributename+"! Datatype '"+LType+"' not supported!")
          }
          DictionaryAdd (LDictionary, Lkey, Lval)
        }
        DictionaryAdd (UDictionary, UKey, LDictionary)
      }
      if (DebugMode) {
        msg ("<br>"+objectname+"."+attributename+" UDictionary: "+ToString(UDictionary))
      }
      set (object, attributename, UDictionary)
    }
    // Destroy any objects that the player destroyed during their saved game, if any
    if (ListCount(DestroyedList)>0) {
      foreach (o, DestroyedList) {
        // Check that objects still exist...
        IsThere = GetObject(o)
        if (not Equal(IsThere,null)) {
          // If its there, destroy the object
          destroy (o)
          DestroyedObjDebugList = DestroyedObjDebugList+o+"<br>"
        }
      }
    }
    msg ("Load complete!")
    if (DebugMode) {
      msg ("Created objects: "+CreatedObjDebugList)
      msg ("Destroyed objects: "+DestroyedObjDebugList)
      msg ("Skipped Attributes:<br>"+SkippedAttList)
    }
    // Finally, update game.pov.parent and game.pov
    wait {
      set (GamePOVObject, "parent", GamePOVParent)
      game.pov = GamePOVObject
      // player.grid_coordinates = null
      JS.Grid_ClearAllLayers ()
      Grid_Redraw
      Grid_DrawPlayerInRoom (game.pov.parent)
      ClearScreen
      ShowRoomDescription
    }
  }
}

The SaveCheckpoint function code:

(Function below is new with v3.0)

// SaveCheckpoint(CheckPointName) Function to locally save checkpoints to the game.checkpoints parameter. Functionally works just like SaveGameCode (minus saving game.checkpoints), except it does not convert the SaveString to base64 or present the SaveCode to the player, instead storing it in the game.checkpoints stringdictionary.
// The CheckpointName input parameter is a string value that will become the Key in the game.checkpoints string dictionary for the generated checkpoint SaveString value. If the CheckpointName already exists in game.checkpoints, then this function will overwrite it, allowing checkpoint names to be re-used multiple times.
// If CheckpointName="", then the SaveString will simply be returned as an output, rather than saved to game.checkpoints.
// Will not save non-string/non-object lists/dictionaries (with grid_coordinates as an exception), will not save script attributes, will not save script dictionaries, will not save delegate attributes, will not save command patterns, will not save the "look" attribute (as it should not change), and will not save the "description" attribute (as it should not change).
// Will not grab any other attributes attached to the "game" object except for game.gameid, game.version, game.pov, and game.timeelapsed. IF YOU WOULD LIKE TO SAVE ANY ADDITIONAL ATTRIBUTES ATTACHED TO "game", you will need to add the attribute names to a game.SaveAtts STRINGLIST attribute OR EDIT THIS FUNCTION TO CALL THEM OUT SPECIFICALLY. If you'd like to go the latter route I've noted the section below where I would recommend adding custom "game" attributes with a ***
// Will not save timer status UNLESS the names of the timers are added to a game.SaveTimers STRINGLIST attribute OR YOU EDIT THIS FUNCTION DIRECTLY! If you want to go the latter route, I would recommend adding these to the section below marked with ***
SuppressTurnscripts
// Check if game.checkpoints exists. If not, create it.
if (not HasAttribute(game, "checkpoints")) {
  set (game, "checkpoints", NewStringDictionary())
}
// Make sure CheckpointName input is of 'string' datatype
if (not TypeOf(CheckpointName)="string") {
  X = TypeOf(CheckpointName)
  error ("ERROR: SaveCheckpoint function expected input 'CheckpointName' to be of type 'string', but instead recieved an input of type '"+X+"'!")
}
SaveString = ""
CreatedObj = NewStringList()
DestroyedObj = NewStringList()
// Set delimiters.
// WARNING: D1 or D2 CANNOT be present in any object names, attribute names, or attribute values that you intend to save. Otherwise, the save will not load properly.
// WARNING: D3 CANNOT be present in any object names or attribute names, but CAN be present in an attribute's value. This is because D3 MUST be the delimiter used to separate List entries in order to load lists properly
// D1 delimiter will separate full object attributes, D2 delimiter will separate the data that comprises an attribute.
// D3 is not set by this function, but instead is what the LoadGame() function will assume separates all list entries. As a reminder, by-default Quest will use ; as the List delimiter.
D1 = "|"
D2 = "$"
D3 = ";"
D4 = "@"
// Save the player's current map before saving
// Make sure first two entries are gameid and version (for load function)
SaveString = SaveString+"game"+D2+"gameid"+D2+"string"+D2+game.gameid+D1
SaveString = SaveString+"game"+D2+"version"+D2+"string"+D2+game.version+D1
// Grab current active player (game.pov). This way the LoadGame knows who the player object is and to update its parent last
SaveString = SaveString+"game"+D2+"pov"+D2+"object"+D2+game.pov+D1
// Record all changable object attributes
foreach (o, AllObjects()) {
  objectname = o.name
  // Check to see if object was created by player mid-game by comparing to the objectlist at start of game
  if (not ListContains(game.StartingObjStrList, objectname)) {
    // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
    if (not ListContains(CreatedObj, objectname)) {
      list add (CreatedObj, objectname)
    }
    // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
    IncludeTypeFlag = True
  }
  else {
    IncludeTypeFlag = False
  }
  foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
    fullname = objectname+"."+attributename
    att_datatype = ToString(TypeOf(o, attributename))
    if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
      if (att_datatype="object") {
        v = GetAttribute (o, attributename)
        att_value = v.name
      }
      else if (att_datatype="stringlist" or att_datatype="objectlist") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "List: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("List: "))
        // Confirm there are no banned delimiters in the list entries
        v = GetAttribute (o, attributename)
        if (ListCount(v)>0) {
          if (att_datatype="stringlist") {
            foreach (listcheck, v) {
              // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
              if (Instr(listcheck,D1)>0 or Instr(listcheck,D2)>0 or Instr(listcheck,D3)>0 or Instr(listcheck,D4)>0) {
                error ("ERROR: Banned delimiter detected in \""+fullname+"\" list entry '"+listcheck+"'! Consider editting SaveGameCode function to change delimiters, or renaming list entry. Current banned list entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
              }
            }
          }
        }
      }
      else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
        // Confirm there are no banned delimiters in the dictionary entries
        v = GetAttribute (o, attributename)
        if (DictionaryCount(v)>0) {
          foreach (dictkey, v) {
            if (Instr(dictkey,D1)>0 or Instr(dictkey,D2)>0 or Instr(dictkey,D3)>0 or Instr(dictkey,D4)>0) {
              error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key '"+dictkey+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary key. Current banned dictionary key delimiters: "+D1+" "+D2+" "+D3+" "+D4)
            }
            if (att_datatype="stringdictionary") {
              dictitm = DictionaryItem (v, dictkey)
              // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
              if (Instr(dictitm,D1)>0 or Instr(dictitm,D2)>0 or Instr(dictitm,D3)>0 or Instr(dictitm,D4)>0) {
                error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key.value '"+dictkey+"."+dictitm+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary value. Current banned dictionary entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
              }
            }
          }
        }
      }
      else {
        att_value = ToString(GetAttribute (o, attributename))
      }
      // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
      if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object.Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
      }
      SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
    }
  }
}
foreach (o, AllExits()) {
  objectname = o.name
  // Check to see if exit was created by player mid-game by comparing to the objectlist at start of game
  if (not ListContains(game.StartingObjStrList, objectname)) {
    // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
    if (not ListContains(CreatedObj, objectname)) {
      list add (CreatedObj, objectname)
    }
    // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
    IncludeTypeFlag = True
  }
  else {
    IncludeTypeFlag = False
  }
  foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
    fullname = objectname+"."+attributename
    att_datatype = ToString(TypeOf(o, attributename))
    if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate" and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
      if (att_datatype="object") {
        v = GetAttribute (o, attributename)
        att_value = v.name
      }
      else if (att_datatype="stringlist" or att_datatype="objectlist") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "List: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("List: "))
      }
      else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
        X = ToString(GetAttribute (o, attributename))
        // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
        att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
      }
      else {
        att_value = ToString(GetAttribute (o, attributename))
      }
      // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
      if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object. Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
      }
      else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
        error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
      }
      SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
    }
  }
}
foreach (turnscript, AllTurnScripts()) {
  // Check for which turnscripts are enabled/disabled
  if (GetBoolean(turnscript, "enabled")) {
    SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"True"+D1
  }
  else {
    SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"False"+D1
  }
}
// Determine if any objects were destroyed by the player since game start...
foreach (objectname, game.StartingObjStrList) {
  IsThere = GetObject(objectname)
  if (Equal(IsThere,null)) {
    list add (DestroyedObj, objectname)
  }
}
// Save the game.timeelapsed attribute
SaveString = SaveString+"game"+D2+"timeelapsed"+D2+"int"+D2+ToString(game.timeelapsed)+D1
// Check if game.SaveAtts and/or game.SaveTimers exists.
// game.SaveAtts is expected to be a stringlist containing a list of game attributes to save.
if (HasAttribute (game, "SaveAtts")) {
  SaveAttType = TypeOf(game.SaveAtts)
  if (SaveAttType="stringlist") {
    if (ListCount(game.SaveAtts)>0) {
      foreach (x, game.SaveAtts) {
        AttValue = GetAttribute (game, x)
        att_datatype = TypeOf(AttValue)
        if (not Equal(AttValue,null) and not Equal(x,"checkpoints") and not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern") {
          SaveString = SaveString+"game"+D2+x+D2+att_datatype+D2+ToString(AttValue)+D1
        }
        else if (Equal(x,"checkpoints")) {
          error ("ERROR: game.SaveAtts - Banned attribute 'checkpoints' found in game.SaveAtts. game.checkpoints cannot be saved using game.SaveAtts!")
        }
        else if (Equal(AttValue,null)) {
          error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not found attached to game object!")
        }
        else {
          error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not allowed. SaveGameCode cannot save attributes of type: "+att_datatype)
        }
      }
    }
  }
  else {
    error ("ERROR: game.SaveAtts expected to be a stringlist containing a list of game attributes to save. Instead, game.SaveAtts found is of datatype: "+SaveAttType)
  }
}
// game.SaveTimers is expected to be an stringlist containing a list of the names of all timers in the game that the author wants to save (ideally, all timers in the game).
if (HasAttribute (game, "SaveTimers")) {
  SaveAttType = TypeOf(game.SaveTimers)
  if (SaveAttType="stringlist") {
    if (ListCount(game.SaveTimers)>0) {
      foreach (x, game.SaveTimers) {
        T = GetObject(x)
        if (not Equal(T,null)) {
          TimerName = x.name
          TimerValue1 = x.trigger
          TimerValue2 = x.interval
          TimerValue3 = x.enabled
          TimerValue1Type = TypeOf(TimerValue1)
          TimerValue2Type = TypeOf(TimerValue2)
          TimerValue3Type = TypeOf(TimerValue3)
          SaveString = SaveString+TimerName+D2+"trigger"+D2+TimerValue1Type+D2+ToString(TimerValue1)+D1
          SaveString = SaveString+TimerName+D2+"interval"+D2+TimerValue2Type+D2+ToString(TimerValue2)+D1
          SaveString = SaveString+TimerName+D2+"enabled"+D2+TimerValue3Type+D2+ToString(TimerValue3)+D1
        }
        else {
          error ("ERROR: game.SaveTimers - Timer named '"+x+"' not found!")
        }
      }
    }
  }
  else {
    error ("ERROR: game.SaveTimers expected to be a stringlist containing a list of the names of timers. Instead, game.SaveTimers found is of datatype: "+SaveAttType)
  }
}
// If neither of those attributes exist, then the developer can also add their own custom attributes to save using the template below...
// *** TO DEVELOPER: Recommend putting timer status and other "game" attributes that can change based on user action during the game in this function below:
// The template to save additional attributes is SaveString=SaveString+{string objectname}+D2+{string attributename}+D2+{datatype string}+D2+ToString({attribute value})+D1
// For example, to save the "timer.enabled" attribute for a timer named BeeTimer: SaveString=SaveString+"BeeTimer"+D2+"enabled"+D2+"boolean"+D2+ToString(BeeTimer.enabled)+D1
// ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
// DO NOT APPEND THE SAVESTRING WITH ANY ADDITIONAL ATTRIBUTES BELOW THIS POINT. The game.pov.grid_coordinates, Created/Destroyed objectlist, and delimiters MUST be added on last in order for the LoadGame() Function to load properly.
// Save the player.grid_coordinates so the player's map is saved. Because it is a dictionary of dictionaries, it must be saved in a special way...
// grid_coordinates will be saved in form: "StuffBefore|@ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|"
foreach (o, AllObjects()) {
  foreach (attributename, GetAttributeNames(o,false)) {
    objectname = o.name
    fullname = objectname+"."+attributename
    att_datatype = ToString(TypeOf(o, attributename))
    if (att_datatype="dictionary" and StartsWith(attributename,"saved_map_for_")) {
      // ASSUMES THAT ANY SAVED MAP DATA (for teleporting and keeping your map) STARTS WITH 'saved_map_for'. This follows the naming convention recommended by https://docs.textadventures.co.uk/quest/showing_a_map.html
      SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
      foreach (UKey, GetAttribute(o, attributename)) {
        SaveString = SaveString+UKey+D2
        UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
        foreach (Lkey, UVal) {
          Lval = DictionaryItem(UVal, Lkey)
          Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
          SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
        }
        SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
      }
    }
    else if (att_datatype="dictionary" and attributename="grid_coordinates") {
      // Save the current map. Typically this is game.pov.grid_coordinates, but if player character can change, there may be multiple occurences of 'grid_coordinates'. Save them all.
      SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
      foreach (UKey, GetAttribute(o, attributename)) {
        SaveString = SaveString+UKey+D2
        UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
        foreach (Lkey, UVal) {
          Lval = DictionaryItem(UVal, Lkey)
          Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
          SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
        }
        SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
      }
    }
  }
}
// Add on the list of created/destroyed objects...
X = ToString(CreatedObj)
// Cut off the "List: " string that preceeds its values when you use the ToString() command
CreatedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
X = ToString(DestroyedObj)
// Cut off the "List: " string that preceeds its values when you use the ToString() command
DestroyedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
SaveString = SaveString+D4+CreatedObjStr+D1+DestroyedObjStr
// Append the end of the SaveString with the delimiters used, so LoadGame() knows what delimiter maps to what...
SaveString = SaveString+D1+D2+D3+D4
// msg for Debugging:
// msg (SaveString+"<br><br>")
// Save SaveString to game.checkpoints if CheckpointName not empty. Else if CheckpointName="", then simply return the SaveString.
if (not CheckpointName="") {
  DictionaryAdd (game.checkpoints, CheckpointName, SaveString)
}
return (SaveString)

The LoadCheckpoint function code:

(Function below is new with v3.0)

// LoadCheckpoint(CheckpointName) function to load a SaveCheckpoint checkpoint. Works similarly to LoadGameCode except it does not print any messages to the player. Also will not prompt the player if a save is found to be from an older version, it will just load according to the OldestAllowedVersion variable.
// Input CheckpointName is the name of a checkpoint saved in the game.checkpoints stringdictionary attribute that you would like to load.
SuppressTurnscripts
// TO DEVELOPER: Set the OldestAllowedVersion to the oldest compatible game version that a player can load saved game data from. Setting OldestAllowedVersion=0 will essentially allow the player to load saves from any old version. Setting OldestAllowedVersion=game.version will make it so the player can ONLY load saves from the current game version.
OldestAllowedVersion = 2.0
// TO DEVELOPER: Setting DebugMode to 'True' will enable the printing of debug messages to the screen when running. Very useful for testing out the function if you've made any custom edits for compatibility or the like.
DebugMode = False
// Msg for Debugging:
if (DebugMode) {
  msg ("<br>Full decoded SaveCode:<br>"+SaveGameCodeDecoded)
}
// Make sure CheckpointName input is of type 'string'
if (not TypeOf(CheckpointName)="string") {
  X = TypeOf(CheckpointName)
  error ("ERROR: LoadCheckpoint function expected input 'CheckpointName' to be of type 'string', but instead recieved an input of type '"+X+"'!")
}
// Retrieve SaveString from game.checkpoints dictionary
if (HasAttribute(game, "checkpoints")) {
  if (DictionaryContains(game.checkpoints, CheckpointName)) {
    SaveGameCodeDecoded = DictionaryItem(game.checkpoints, CheckpointName)
  }
  else {
    error ("ERROR: Checkpoint named '"+CheckpointName+"' not found!")
  }
}
else {
  error ("ERROR: Cannot load checkpoint as game.checkpoints attribute does not exist!")
}
// Set up other variables for later
bla => {
}
upgradesave = False
Proceed = False
SkippedAttList = ""
CreatedObjDebugList = ""
DestroyedObjDebugList = ""
// Retrieve delimiters from end of SaveGameCodeDecoded
Dls = Right(SaveGameCodeDecoded,4)
D1 = Mid (Dls, 1, 1)
D2 = Mid (Dls, 2, 1)
D3 = Mid (Dls, 3, 1)
D4 = Mid (Dls, 4, 1)
// Remove delimiters from end of SaveGameCode
SaveCode = Left(SaveGameCodeDecoded, LengthOf(SaveGameCodeDecoded)-(LengthOf(Dls)))
// Extract the Created/Destroyed object lists. The START of the created/destroyed section should be after the LAST D4 delimiter...
CreatedDestroyedInfo = Right(SaveCode, LengthOf(SaveCode)-InstrRev(SaveCode, D4))
if (DebugMode) {
  msg ("<br>CreatedDestroyedInfo: "+CreatedDestroyedInfo)
}
CDList = Split(CreatedDestroyedInfo,D1)
CSection = ListItem (CDList, 0)
DSection = ListItem (CDList, 1)
if (CSection="") {
  CreatedList = NewStringList()
}
else {
  CreatedList = Split (CSection, D3)
  // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
  list remove (CreatedList, " ")
}
if (DSection="") {
  DestroyedList = NewStringList()
}
else {
  DestroyedList = Split (DSection, D3)
  // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
  list remove (DestroyedList, " ")
}
// Remove Created/Destroyed list from end of SaveCode, also removing the final D1 and D4 delimiter...
SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(CreatedDestroyedInfo)+2))
// Extract the player.grid_coordinates info separately from the rest of the savecode. It has special rules for decoding it since it is a dictionary of dictionaries.
GridGInfo = Right(SaveCode, LengthOf(SaveCode)-Instr(SaveCode, D4))
if (DebugMode) {
  msg ("<br>GridGInfo: "+GridGInfo)
}
// Remove player.grid_coordinates info from end of SaveCode also remove the final D1 & D4 delimiter separating the grid_coordinates from the rest of the attributes
SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(GridGInfo)+2))
if (DebugMode) {
  msg ("<br>SaveCode w/o player.grid_coordinate or create/destroy info:<br>"+SaveCode)
}
// Note: if the "SaveCode" begins with the word "Error:", then an error was encountered when trying to convert from Base64.
// Extract SaveCode game info. This includes the gameid, game version, and current player (game.pov)...
GameIdDelim = Instr (SaveCode, D1)
GameVersionDelim = Instr(GameIdDelim+1,SaveCode,D1)
GamePOVDelim = Instr(GameVersionDelim+1,SaveCode,D1)
GameInfo = Split(Left(SaveCode,GamePOVDelim-1),D1)
GameIdObjectEl = ListItem (GameInfo,0)
GameIdElements = Split(GameIdObjectEl,D2)
Loaded_GameId = ListItem (GameIdElements, 3)
GameVerObjectEl = ListItem (GameInfo,1)
GameVerElements = Split(GameVerObjectEl,D2)
VersionString = ListItem (GameVerElements, 3)
Loaded_GameVersion = ToDouble(VersionString)
GamePOVObjectEl = ListItem (GameInfo,2)
GamePOVElements = Split(GamePOVObjectEl,D2)
GamePOVName = ListItem (GamePOVElements, 3)
if (StartsWith(GamePOVName,"Object: ")) {
  GamePOVName = Right(GamePOVName,LengthOf(GamePOVName)-LengthOf("Object: "))
}
GamePOVObject = GetObject (GamePOVName)
GamePOVParent = GetAttribute (GamePOVObject, "parent")
// Check that the save belongs to this game by comparing gameIds
if (not Loaded_GameId=game.gameid) {
  error ("Load Aborted: SaveCode not identified by this game. GameID mismatch.")
}
else {
  // Compare version of game in SaveCode to version of game loading it
  ThisGame_GameVersion = ToDouble(game.version)
  if (not TypeOf(OldestAllowedVersion)="double") {
    OldestAllowedVersion_Double = ToDouble(OldestAllowedVersion)
  }
  else {
    OldestAllowedVersion_Double = OldestAllowedVersion
  }
  // If upgrading from an old game version, then arbitrarily set Loaded_GameVersion to ThisGameVersion to proceed.
  if (Loaded_GameVersion<ThisGame_GameVersion) {
    if (OldestAllowedVersion_Double<=Loaded_GameVersion) {
      upgradesave = True
      Proceed = True
    }
    else {
      error ("ERROR: The SaveCode you are attempting to load is from an INCOMPATIBLE older game version, and thus cannot be loaded by this version of the game.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Oldest Compatible Version: v"+ToString(OldestAllowedVersion)+"<br><br>Loading aborted...")
    }
  }
  else if (Loaded_GameVersion>ThisGame_GameVersion) {
    error ("ERROR: The SaveCode you are attempting to load is from a newer version of this game and is not compatible.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Please try a different SaveCode or use an updated game file.<br><br>Load aborted.")
  }
  else {
    Proceed = True
  }
  if (Proceed=True) {
    // Create any objects noted in the CreatedList, if there are any, so their relevant attributes can be added without error...
    if (ListCount(CreatedList)>0) {
      foreach (o, CreatedList) {
        // Check that objects don't already exist...
        IsThere = GetObject(o)
        if (Equal(IsThere,null)) {
          // If not, create the object
          create (o)
          CreatedObjDebugList = CreatedObjDebugList+o+"<br>"
        }
      }
    }
    player.grid_coordinates = null
    // Split the save code up into all objects. Then parse through the value of each object attribute
    SavedObjectList = Split(SaveCode, D1)
    foreach (o, SavedObjectList) {
      Skip_Att = False
      objelements = Split(o, D2)
      objectname = ListItem (objelements, 0)
      object = GetObject (objectname)
      attributename = ListItem (objelements, 1)
      fullname = objectname+"."+attributename
      preload_att_value = GetAttribute (object, attributename)
      att_datatype = ListItem (objelements, 2)
      if (ListCount(objelements)=3) {
        att_value = ""
      }
      else {
        att_value = ListItem (objelements, 3)
      }
      // Check that the attribute is supported
      if (not att_datatype="string" and not att_datatype="boolean" and not att_datatype="object" and not att_datatype="int" and not att_datatype="double" and not att_datatype="stringlist" and not att_datatype="objectlist" and not att_datatype="stringdictionary" and not att_datatype="objectdictionary") {
        msg ("WARNING! Unsupported datatype \""+att_datatype+"\" detected in SaveCode attribute \""+fullname+"\"! Skipping and moving on to next attribute...")
        Skip_Att = True
        SkippedAttList = SkippedAttList+fullname+"<br>"
      }
      // Convert the string attribute value and convert it to the datatype that it actually needs to be. This att_value_obj variable will also be directly compared to preload_att_value to determine if the pre- and post- load values are equal or not...
      att_value_obj = att_value
      if (att_datatype="object") {
        if (StartsWith(att_value,"Object: ")) {
          att_value_obj = GetObject(Right(att_value,LengthOf(att_value)-LengthOf("Object: ")))
        }
        else {
          att_value_obj = GetObject(att_value)
        }
      }
      else if (att_datatype="boolean") {
        if (att_value="True") {
          att_value_obj = True
        }
        else {
          att_value_obj = False
        }
      }
      else if (att_datatype="int") {
        att_value_obj = ToInt(att_value)
      }
      else if (att_datatype="double") {
        att_value_obj = ToDouble(att_value)
      }
      else if (att_datatype="stringlist") {
        if (att_value="") {
          att_value_obj = NewStringList()
        }
        else {
          att_value_obj = Split (att_value, D3)
          // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
          list remove (att_value_obj, " ")
        }
      }
      else if (att_datatype="objectlist") {
        if (att_value="") {
          att_value_obj = NewObjectList()
        }
        else {
          att_value_obj = NewObjectList()
          objlistlist = Split (att_value, D3)
          foreach (olt, objlistlist) {
            // Need to remove the "Object: " that will preceed each entry, and turn the string entry into the actual object before re-adding to list. We put it into the following "if" statement in order to exclude the blank list entry that gets created at the end of the list by loading
            if (StartsWith(olt,"Object: ")) {
              value = GetObject(Right(olt,LengthOf(olt)-LengthOf("Object: ")))
              if (not value=null) {
                list add (att_value_obj, value)
              }
              else {
                msg ("WARNING! Object \""+olt+"\" detected in saved objectlist \""+fullname+"\" does not exist! Object \""+olt+"\" not added to list! Loaded game may not work properly!")
                SkippedAttList = SkippedAttList+"Objectlist '"+fullname+"' item: "+olt+"<br>"
              }
            }
          }
        }
      }
      else if (att_datatype="stringdictionary") {
        if (att_value="") {
          att_value_obj = NewStringDictionary()
        }
        else {
          att_value_obj = NewStringDictionary()
          // Add dictionary values from SaveGame
          dictrows = Split(att_value, ";")
          foreach (kv, dictrows) {
            if (DebugMode) {
              msg ("StringDict '"+fullname+"' key-value: "+ToString(kv))
            }
            KeyValList = Split(kv," = ")
            key = ListItem(KeyValList, 0)
            value = ListItem(KeyValList, 1)
            DictionaryAdd (att_value_obj, key, value)
          }
        }
      }
      else if (att_datatype="objectdictionary") {
        if (att_value="") {
          att_value_obj = NewObjectDictionary()
        }
        else {
          att_value_obj = NewObjectDictionary()
          dictrows = Split(att_value, ";")
          foreach (kv, dictrows) {
            if (DebugMode) {
              msg ("ObjDict  '"+fullname+"' key-value: "+ToString(kv))
            }
            KeyValList = Split(kv," = ")
            key = ListItem(KeyValList, 0)
            obj = ListItem(KeyValList, 1)
            if (StartsWith(obj,"Object: ")) {
              value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
            }
            else {
              value = obj
            }
            if (not value=null) {
              DictionaryAdd (att_value_obj, key, value)
            }
            else {
              msg ("WARNING! Object \""+obj+"\" detected in saved objectdictionary \""+fullname+"\" does not exist! Object \""+obj+"\" not added to dictionary! Loaded game may not work properly!")
              SkippedAttList = SkippedAttList+"Objectdictionary '"+fullname+"' item: "+olt+"<br>"
            }
          }
        }
      }
      if (objectname=GamePOVName and attributename="parent") {
        // Check that the attribute is NOT game.pov.parent. If so, we want to make sure that gets updated last
        Skip_Att = True
        GamePOVParent = att_value_obj
      }
      // Make sure the object you are trying to add/update the attribute to exists, otherwise you'd get an error trying to update/create its attribute. If the attribute doesn't exist but the object does, then this function will create it. Also, don't update the game.version or game.pov: The game.version should not be updated from the savecode if you're loading from a previous version, and the game.pov is updated last.
      if (not Equal(object,null) and not Equal(att_value_obj,null) and not fullname="game.gameid" and not fullname="game.version" and not fullname="game.pov" and not Skip_Att=True) {
        if (Equal(preload_att_value,null)) {
          if (DebugMode) {
            msg ("<br>ATTENTION: Attribute '"+fullname+"' does NOT exist in current game, but its parent object '"+objectname+"' does, so attribute will be created!<br>")
          }
          preload_att_value = "null"
        }
        // Msgs for debugging:
        if (DebugMode) {
          msg ("objectname="+objectname)
          msg ("attributename="+attributename)
          msg ("att_datatype="+att_datatype)
          msg ("preload_att_value="+ToString(preload_att_value))
          if (Equal(preload_att_value,"null")) {
            msg ("preload_att_datatype=null")
          }
          else {
            msg ("preload_att_datatype="+TypeOf(preload_att_value))
          }
          msg ("att_value="+ToString(att_value))
          msg ("att_value_obj="+ToString(att_value_obj))
          msg ("isEqual att_value=preload_att_value?: "+ToString(Equal(att_value,preload_att_value)))
          msg ("isEqual att_value_obj=preload_att_value?: "+ToString(Equal(att_value_obj,preload_att_value)))
          msg ("isEqual ToString(att_value_obj)=ToString(preload_att_value)?: "+ToString(Equal(ToString(att_value_obj),ToString(preload_att_value))))
          msg ("<br>")
        }
        // If attributes are already equal to those in the savecode, no need to change them. Else, change 'em.
        if (not Equal(att_value,preload_att_value) and not Equal(att_value_obj,preload_att_value) and not Equal(ToString(att_value_obj),ToString(preload_att_value))) {
          if (DebugMode) {
            msg ("Updating attribute: "+fullname+"<br><br>")
          }
          // Check if attribute has an associated change script. If so, this section will make sure that setting the attribute on load WON'T activate its associate turnscript
          cha = "changed" + attributename
          if (HasAttribute (object, cha)) {
            // If the attribute DOES have an associated change script, temporarily blank it out so it does not execute during loading
            scr = GetAttribute (object, cha)
            set (object, cha, bla)
          }
          // Update the attributes in the game with those from the SaveCode...
          if (att_datatype="boolean") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="int") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="double") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="object") {
            set (object, attributename, att_value_obj)
          }
          else if (att_datatype="stringlist" or att_datatype="objectlist" or att_datatype="stringdictionary" or att_datatype="objectdictionary") {
            // NOTE TO DEVELOPER:
            // Alter the following logic below to fit your needs. Especially important to make sure this works properly for YOUR game for compatibility between game versions!
            // If ReplaceContents = True, then any list or dictionary in your game will be COMPLETELY REPLACED by its corresponding list/dictionary from the savecode.
            // If ReplaceContents = False, then the list/dictionary contents in the SaveCode will be ADDED to the existing corresponding list/dictionary. NOTE: When adding to an existing list/dict, the code, as-written, will REMOVE ANY DUPLICATES from the lists/dictionaries! ALSO, be careful where you allow LoadGame() to be called in cases where ReplaceContents=False, ESPECIALLY if the list/dict contents can change through the course of the game! Calling this LoadGameCode function only from a titlescreen (before any lists/dictionaries have changed), for instance, may be one possible way to account for this.
            // ReplaceContents=True by default, but this may not be desirable in all cases (i.e. if you updated the contents of a permanent dictionary/list between versions), so it is up to YOU to ensure this section behaves as you want it to. Remember that the "object" and "attributename" variables exist at this point to call out specific list/dictionary objects.
            if (upgradesave = True) {
              // This section will trigger if the player is loading a save from a previous game version
              ReplaceContents = True
            }
            else {
              // If this savecode is NOT coming from a previous game version, then I assume it is safe to completely replace the existing dictionary with the saved one.
              ReplaceContents = True
            }
            if (att_datatype="stringlist") {
              if (ReplaceContents = True) {
                // Completely replace stringlist contents with those found in the SaveCode
                set (object, attributename, att_value_obj)
              }
              else {
                // Add the contents of the saved stringlist TO the existing stringlist in-game
                FinalList = NewStringList()
                // Retrieve the contents of the existing list
                PreLoadList = preload_att_value
                CombinedList = ListCombine (PreLoadList, att_value_obj)
                // Remove duplicates
                CompactList = ListCompact (CombinedList)
                // CompactList will be a generic "list" type object, need to convert it back to a stringlist...
                foreach (olt, CompactList) {
                  list add (FinalList, olt)
                }
                set (object, attributename, FinalList)
              }
            }
            else if (att_datatype="objectlist") {
              if (ReplaceContents = True) {
                // Completely replace objectlist contents with those found in the SaveCode
                set (object, attributename, att_value_obj)
              }
              else {
                // Add the contents of the saved objectlist TO the existing objectlist in-game
                // Retrieve the contents of the existing list
                PreLoadList = preload_att_value
                CombinedList = ListCombine (PreLoadList, att_value_obj)
                // Remove duplicates
                FinalList = ObjectListCompact (CombinedList)
                set (object, attributename, FinalList)
              }
            }
            else if (att_datatype="stringdictionary") {
              if (ReplaceContents = True) {
                // Then completely overwrite existing stringdictionary contents with those in the savecode
                set (object, attributename, att_value_obj)
                Dummy = NewStringDictionary()
              }
              else {
                // Add the contents of the saved stringdictionary TO the existing stringdictionary in-game
                Dummy = preload_att_value
                // Add dictionary values from SaveGame
                dictrows = Split(att_value, ";")
                foreach (kv, dictrows) {
                  KeyValList = Split(kv," = ")
                  key = ListItem(KeyValList, 0)
                  value = ListItem(KeyValList, 1)
                  DictionaryAdd (Dummy, key, value)
                }
                set (object, attributename, Dummy)
              }
            }
            else if (att_datatype="objectdictionary") {
              if (upgradesave = False) {
                // Then completely overwrite existing objectdictionary contents with those in the savecode
                set (object, attributename, att_value_obj)
              }
              else {
                // Add the contents of the saved objectdictionary TO the existing objectdictionary in-game
                Dummy = preload_att_value
                // Add dictionary values from SaveGame
                dictrows = Split(att_value, ";")
                foreach (kv, dictrows) {
                  KeyValList = Split(kv," = ")
                  key = ListItem(KeyValList, 0)
                  value = ListItem(KeyValList, 1)
                  if (StartsWith(value,"Object: ")) {
                    value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
                  }
                  DictionaryAdd (Dummy, key, value)
                }
                set (object, attributename, Dummy)
              }
            }
          }
          else if (att_datatype="string") {
            set (object, attributename, att_value)
          }
          else {
            error ("ERROR: Unsupported object type detected in SaveCode: "+fullname+" of "+att_datatype+" datatype.")
          }
          if (HasAttribute (object, cha)) {
            // If a change script exists for this attribute, set change script back to original value after attribute has been changed
            set (object, cha, scr)
            scr => {
            }
          }
        }
      }
    }
    // Extract and update map data from saved grid_coordinates. Because grid_coordinates is a dictionary of dictionaries, it needed to be saved in a special way. Thus, it needs to be loaded in a special way as well.
    // If D1=|,D2=$,D3=;,and D4=@, then grid_coordinates will be saved in form: "ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|" etc.
    AllSavedGrids = Split(GridGInfo,D4)
    if (DebugMode) {
      msg ("<br>AllSavedGrids: "+ToString(AllSavedGrids))
    }
    foreach (A, AllSavedGrids) {
      UDictionary = NewDictionary()
      ItemAndValue = Split(A,"&%&")
      ObjAndAtt = ListItem(ItemAndValue,0)
      ObjAndAtt = Split(ObjAndAtt,D2)
      objectname = ListItem(ObjAndAtt,0)
      attributename = ListItem(ObjAndAtt,1)
      object = GetObject(objectname)
      GridVals = ListItem(ItemAndValue,1)
      GridVals = Split(GridVals,D1)
      foreach (B, GridVals) {
        UKeyAndUVal = Split(B,D2)
        UKey = ListItem(UKeyAndUVal,0)
        UVal = ListItem(UKeyAndUVal,1)
        UVal = Split(UVal,D3)
        LDictionary = NewDictionary()
        foreach (C, UVal) {
          LkeyAndLval = Split(C," = ")
          Lkey = ListItem(LkeyAndLval,0)
          LvalAndType = ListItem(LkeyAndLval,1)
          LvalAndType = Split(LvalAndType,":")
          Lval_str = ListItem(LvalAndType,0)
          LType = ListItem(LvalAndType,1)
          if (LType="int") {
            Lval = ToInt(Lval_str)
          }
          else if (LType="double") {
            Lval = ToDouble(Lval_str)
          }
          else if (LType="boolean") {
            if (Lval_str="True") {
              Lval = True
            }
            else {
              Lval = False
            }
          }
          else {
            error ("ERROR: Unsupported datatype found in "+objectname+"."+attributename+"! Datatype '"+LType+"' not supported!")
          }
          DictionaryAdd (LDictionary, Lkey, Lval)
        }
        DictionaryAdd (UDictionary, UKey, LDictionary)
      }
      if (DebugMode) {
        msg ("<br>"+objectname+"."+attributename+" UDictionary: "+ToString(UDictionary))
      }
      set (object, attributename, UDictionary)
    }
    // Destroy any objects that the player destroyed during their saved game, if any
    if (ListCount(DestroyedList)>0) {
      foreach (o, DestroyedList) {
        // Check that objects still exist...
        IsThere = GetObject(o)
        if (not Equal(IsThere,null)) {
          // If its there, destroy the object
          destroy (o)
          DestroyedObjDebugList = DestroyedObjDebugList+o+"<br>"
        }
      }
    }
    if (DebugMode) {
      msg ("Created objects: "+CreatedObjDebugList)
      msg ("Destroyed objects: "+DestroyedObjDebugList)
      msg ("Skipped Attributes:<br>"+SkippedAttList)
    }
    // Finally, update game.pov.parent and game.pov
    set (GamePOVObject, "parent", GamePOVParent)
    game.pov = GamePOVObject
    // player.grid_coordinates = null
    JS.Grid_ClearAllLayers ()
    Grid_Redraw
    Grid_DrawPlayerInRoom (game.pov.parent)
    ClearScreen
    ShowRoomDescription
  }
}

The GetSaveGameCodeDelims function code:

(Function below is new with v3.0)

// GetSaveGameCodeDelims() function that returns the delimiters used by the SaveGameCode function in a stringlist in the order [D1,D2,D3,D4].
// Useful for getting a list of delimiters to ban from user-entered fields (i.e. "enter your name")
DelimList = NewStringList()
SaveString = SaveGameCode(False)
// Retrieve delimiters from end of SaveString
Dls = Right(SaveString,4)
D1 = Mid (Dls, 1, 1)
D2 = Mid (Dls, 2, 1)
D3 = Mid (Dls, 3, 1)
D4 = Mid (Dls, 4, 1)
// Add to list
list add (DelimList, D1)
list add (DelimList, D2)
list add (DelimList, D3)
list add (DelimList, D4)
// Return the list of delimiters as a string list
return (DelimList)

The GetSaveCheckpointDelims function code:

(Function below is new with v3.0)

// GetSaveCheckpointDelims() function that returns the delimiters used by the SaveCheckpoint function in a stringlist in the order [D1,D2,D3,D4].
// Useful for getting a list of delimiters to ban from user-entered fields (i.e. "enter your name")
DelimList = NewStringList()
SaveString = SaveCheckpoint("")
// Retrieve delimiters from end of SaveString
Dls = Right(SaveString,4)
D1 = Mid (Dls, 1, 1)
D2 = Mid (Dls, 2, 1)
D3 = Mid (Dls, 3, 1)
D4 = Mid (Dls, 4, 1)
// Add to list
list add (DelimList, D1)
list add (DelimList, D2)
list add (DelimList, D3)
list add (DelimList, D4)
// Return the list of delimiters as a string list
return (DelimList)

The code for the entire test game:

<!--Saved by Quest 5.8.6836.13983-->
<asl version="580">
  <include ref="English.aslx" />
  <include ref="Core.aslx" />
  <game name="Save Testing">
    <gameid>932164b2-0eae-47d6-94b4-402d8a2238d9</gameid>
    <version>3.0</version>
    <firstpublished>2022</firstpublished>
    <feature_devmode />
    <attr name="devmode_setinitscript" type="boolean">false</attr>
    <attr name="devmode_changepovpos" type="boolean">false</attr>
    <devmode_setverbs />
    <gridmap />
    <showscore type="boolean">false</showscore>
    <showhealth type="boolean">false</showhealth>
    <showmoney type="boolean">false</showmoney>
    <attr name="feature_limitinventory" type="boolean">false</attr>
    <attr name="feature_lightdark" type="boolean">false</attr>
    <attr name="feature_asktell" type="boolean">false</attr>
    <attr name="feature_annotations" type="boolean">false</attr>
    <attr name="feature_advancedwearables" type="boolean">false</attr>
    <attr name="feature_advancedscripts" type="boolean">false</attr>
    <appendobjectdescription type="boolean">false</appendobjectdescription>
    <allowlookdirections type="boolean">false</allowlookdirections>
    <multiplecommands type="boolean">false</multiplecommands>
    <command_newline />
    <clearscreenonroomenter type="boolean">false</clearscreenonroomenter>
    <autodisplayverbs />
    <author>Leviathon</author>
    <start type="script">
      // Initialization script for SaveLoadCode functionality...
      // Create a list of all object and exit names present in game at start. This way we can keep track of which objects/exits the player has created/destroyed while playing by comparing to this list when saving/loading.
      set (game, "StartingObjStrList", NewStringList ())
      foreach (startobj, AllObjects()) {
        list add (game.StartingObjStrList, startobj.name)
      }
      foreach (startobj, AllExits()) {
        list add (game.StartingObjStrList, startobj.name)
      }
      set (game, "checkpoints", NewStringDictionary())
      // End of SaveLoadCode initialization script
    </start>
  </game>
  <object name="room">
    <inherit name="editor_room" />
    <isroom />
    <description><![CDATA[This is the starting room of the SaveLoadCode functionality demo.<br/><br/>Type "SaveCode" at any time to create a save code, allowing you to load later. Copy the code to a notepad document to save it for later!<br/><br/>Type "LoadCode" at any time to load a previously saved code. Paste in your previously-saved save code to load your game!]]></description>
    <object name="player">
      <inherit name="editor_object" />
      <inherit name="editor_player" />
      <StringAttribute>Stringy Bingy</StringAttribute>
      <alias>Francis</alias>
      <ObjectListRedux type="objectlist"></ObjectListRedux>
    </object>
    <object name="BoingoBall">
      <inherit name="editor_object" />
      <IsBall />
      <MemoryDict type="stringdictionary">
        <item>
          <key>Mem1</key>
          <value>Memories of 1</value>
        </item>
        <item>
          <key>Mem2</key>
          <value>Memories of 2</value>
        </item>
      </MemoryDict>
      <ListOfString type="stringlist">
        <value>Brock</value>
        <value>Misty</value>
        <value>Ash</value>
        <value>Pikachu</value>
      </ListOfString>
      <look>This is an object with a bunch of attributes attached to it. The SaveGameCode and LoadGameCode functions will save all its attributes and update them on load if they've changed.</look>
    </object>
    <exit name="KeyRoomDoor" alias="east" to="Key Room">
      <inherit name="eastdirection" />
      <locked />
      <runscript />
      <script type="script"><![CDATA[
        if (DoorKey.parent=game.pov and this.locked=true) {
          msg ("<i>You unlock the door and enter</i>")
          this.locked = false
          game.pov.parent = Key Room
        }
        else if (this.locked=true) {
          msg ("That way is locked.")
        }
        else {
          game.pov.parent = Key Room
        }
      ]]></script>
    </exit>
    <exit alias="north" to="SideRoom">
      <inherit name="northdirection" />
    </exit>
    <object name="DoorKey">
      <inherit name="editor_object" />
      <take />
      <alias>Key</alias>
      <feature_usegive />
      <use type="script"><![CDATA[
        if (game.pov.parent=room and KeyRoomDoor.locked=True) {
          msg ("<i>Using the key, you unlock the door!</i>")
          KeyRoomDoor.locked = False
          msg ("<br>As you walk through the door, the key disintegrates in your hand, <i>destroying</i> it. (i.e. The Key object itself has been destroyed. If you type 'SaveCode' now, when you load that save the Key will still be destroyed!)")
          destroy ("DoorKey")
        }
        else if (game.pov.parent=room and KeyRoomDoor.locked=False) {
          msg ("<i>The door is already unlocked!</i>")
        }
        else {
          msg ("<i>There are no locks to use this on here!</i>")
        }
      ]]></use>
    </object>
    <object name="SaveCheckpoint Button">
      <inherit name="editor_object" />
      <look><![CDATA[Using this button will create a checkpoint! Using it more than once will overwrite the existing checkpoint.<br/><br/>If you find the LoadCheckpoint Button, you can then load the checkpoint created here.]]></look>
      <feature_usegive />
      <displayverbs type="stringlist">
        <value>Look at</value>
        <value>Use</value>
      </displayverbs>
      <use type="script">
        SaveCheckpoint ("Checkpoint")
      </use>
    </object>
  </object>
  <object name="Key Room">
    <inherit name="editor_room" />
    <description><![CDATA[You made it in!<br/><br/>If you save now and load from a new game, this room will still be unlocked!]]></description>
    <enter type="script">
    </enter>
    <firstenter type="script">
    </firstenter>
    <exit alias="west" to="room">
      <inherit name="westdirection" />
    </exit>
  </object>
  <object name="SideRoom">
    <inherit name="editor_room" />
    <description><![CDATA[A side room. When you load, this room will show up on the map since you've explored it now! (It was harder to make this happen than you think it would be...)<br/><br/>{here DoorKey:You see a {object:DoorKey} on the floor!}]]></description>
    <exit alias="south" to="room">
      <inherit name="southdirection" />
    </exit>
    <exit alias="west" to="OtherSideRoom">
      <inherit name="westdirection" />
    </exit>
    <exit alias="down" to="Basement">
      <inherit name="downdirection" />
    </exit>
    <object name="LoadCheckpoint Button">
      <inherit name="editor_object" />
      <feature_usegive />
      <use type="script">
        LoadCheckpoint ("Checkpoint")
      </use>
      <displayverbs type="stringlist">
        <value>Look at</value>
        <value>Use</value>
      </displayverbs>
      <look>Using this button will load the checkpoint created by the SaveCheckpoint Button (if one was created...)</look>
    </object>
  </object>
  <object name="OtherSideRoom">
    <inherit name="editor_room" />
    <exit alias="east" to="SideRoom">
      <inherit name="eastdirection" />
    </exit>
  </object>
  <object name="Basement">
    <inherit name="editor_room" />
    <attr name="grid_fill">Teal</attr>
    <exit alias="up" to="SideRoom">
      <inherit name="updirection" />
    </exit>
    <exit alias="east" to="Basement Side">
      <inherit name="eastdirection" />
    </exit>
  </object>
  <object name="Basement Side">
    <inherit name="editor_room" />
    <attr name="grid_fill">Teal</attr>
    <exit alias="west" to="Basement">
      <inherit name="westdirection" />
    </exit>
  </object>
  <command>
    <pattern>SaveCode</pattern>
    <script>
      SuppressTurnscripts
      SaveGameCode (True)
    </script>
  </command>
  <command>
    <pattern>LoadCode</pattern>
    <script>
      SuppressTurnscripts
      JS.LoadGamePrompt ()
    </script>
  </command>
  <function name="LoadGameCode" parameters="SaveGameCodeDecoded"><![CDATA[
    // LoadGameCode(SaveGameCodeDecoded) function to load a SaveGameCode save-state. Takes a decoded (not in base64) SaveGameCode created by SaveGameCode and decoded from base64 by java functions. Requires SaveLoadJavaCode.js in order to function!
    SuppressTurnscripts
    // NOTE: Requires either JS.LoadSaveCode to be called with a SaveGameCode as an input parameter, or JS.LoadGamePrompt to be called elsewhere (i.e. by a custom "LoadGame" command), which will make a popup appear for the player to enter their SaveCode, eventually piping it to this function.
    // TO DEVELOPER: Set the OldestAllowedVersion to the oldest compatible game version that a player can load saved game data from. Setting OldestAllowedVersion=0 will essentially allow the player to load saves from any old version. Setting OldestAllowedVersion=game.version will make it so the player can ONLY load saves from the current game version.
    OldestAllowedVersion = 2.0
    // TO DEVELOPER: Setting DebugMode to 'True' will enable the printing of debug messages to the screen when running. Very useful for testing out the function if you've made any custom edits for compatibility or the like.
    DebugMode = False
    // Msg for Debugging:
    if (DebugMode) {
      msg ("<br>Full decoded SaveCode:<br>"+SaveGameCodeDecoded)
    }
    // Set up other variables for later
    bla => {
    }
    upgradesave = False
    Proceed = False
    SkippedAttList = ""
    CreatedObjDebugList = ""
    DestroyedObjDebugList = ""
    // Check for a "✓✓" at the end of the SaveGameCodeDecoded string. If it's there, then the function knows this savecode is for an older game version. "But how do we know that ✓✓ isn't being used as a custom delimiter?" Because custom delimiters can only be one character long and two delimiters cannot be the same. Also ✓ (theoretically) cannot be converted to base64, so the user would get an error trying to make a SaveGameCode with ✓ as a delimiter.
    CheckForCheck = Right(SaveGameCodeDecoded,2)
    if (CheckForCheck="✓✓") {
      upgradesave = True
      SaveGameCodeDecoded = Left(SaveGameCodeDecoded, LengthOf(SaveGameCodeDecoded)-2)
    }
    // Retrieve delimiters from end of SaveGameCodeDecoded
    Dls = Right(SaveGameCodeDecoded,4)
    D1 = Mid (Dls, 1, 1)
    D2 = Mid (Dls, 2, 1)
    D3 = Mid (Dls, 3, 1)
    D4 = Mid (Dls, 4, 1)
    // Remove delimiters from end of SaveGameCode
    SaveCode = Left(SaveGameCodeDecoded, LengthOf(SaveGameCodeDecoded)-(LengthOf(Dls)))
    // Special logic to extract saved game.checkpoints attribute
    D4toD1 = D4+D3+D2+D1
    CheckpointSplit = Split(SaveCode, D4toD1)
    SaveCode = ListItem(CheckpointSplit,0)
    CheckpointKeys = ListItem(CheckpointSplit,1)
    // If CheckpointKeys not an empty string, then that means there was game.checkpoints data saved. Extract it.
    if (not CheckpointKeys="") {
      CheckpointKeyList = Split(CheckpointKeys, D3)
      game.checkpoints = NewStringDictionary()
      for (xx, 0, ListCount(CheckpointKeyList)-1) {
        key = ListItem(CheckpointKeyList,xx)
        val = ListItem(CheckpointSplit,xx+1)
        DictionaryAdd (game.checkpoints, key, val)
      }
    }
    // Extract the Created/Destroyed object lists. The START of the created/destroyed section should be after the LAST D4 delimiter...
    CreatedDestroyedInfo = Right(SaveCode, LengthOf(SaveCode)-InstrRev(SaveCode, D4))
    if (DebugMode) {
      msg ("<br>CreatedDestroyedInfo: "+CreatedDestroyedInfo)
    }
    CDList = Split(CreatedDestroyedInfo,D1)
    CSection = ListItem (CDList, 0)
    DSection = ListItem (CDList, 1)
    if (CSection="") {
      CreatedList = NewStringList()
    }
    else {
      CreatedList = Split (CSection, D3)
      // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
      list remove (CreatedList, " ")
    }
    if (DSection="") {
      DestroyedList = NewStringList()
    }
    else {
      DestroyedList = Split (DSection, D3)
      // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
      list remove (DestroyedList, " ")
    }
    // Remove Created/Destroyed list from end of SaveCode, also removing the final D1 and D4 delimiter...
    SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(CreatedDestroyedInfo)+2))
    // Extract the player.grid_coordinates info separately from the rest of the savecode. It has special rules for decoding it since it is a dictionary of dictionaries.
    GridGInfo = Right(SaveCode, LengthOf(SaveCode)-Instr(SaveCode, D4))
    if (DebugMode) {
      msg ("<br>GridGInfo: "+GridGInfo)
    }
    // Remove player.grid_coordinates info from end of SaveCode also remove the final D1 & D4 delimiter separating the grid_coordinates from the rest of the attributes
    SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(GridGInfo)+2))
    if (DebugMode) {
      msg ("<br>SaveCode w/o player.grid_coordinate or create/destroy info:<br>"+SaveCode)
    }
    // Note: if the "SaveCode" begins with the word "Error:", then an error was encountered when trying to convert from Base64.
    // Extract SaveCode game info. This includes the gameid, game version, and current player (game.pov)...
    GameIdDelim = Instr (SaveCode, D1)
    GameVersionDelim = Instr(GameIdDelim+1,SaveCode,D1)
    GamePOVDelim = Instr(GameVersionDelim+1,SaveCode,D1)
    GameInfo = Split(Left(SaveCode,GamePOVDelim-1),D1)
    GameIdObjectEl = ListItem (GameInfo,0)
    GameIdElements = Split(GameIdObjectEl,D2)
    Loaded_GameId = ListItem (GameIdElements, 3)
    GameVerObjectEl = ListItem (GameInfo,1)
    GameVerElements = Split(GameVerObjectEl,D2)
    VersionString = ListItem (GameVerElements, 3)
    Loaded_GameVersion = ToDouble(VersionString)
    GamePOVObjectEl = ListItem (GameInfo,2)
    GamePOVElements = Split(GamePOVObjectEl,D2)
    GamePOVName = ListItem (GamePOVElements, 3)
    if (StartsWith(GamePOVName,"Object: ")) {
      GamePOVName = Right(GamePOVName,LengthOf(GamePOVName)-LengthOf("Object: "))
    }
    GamePOVObject = GetObject (GamePOVName)
    GamePOVParent = GetAttribute (GamePOVObject, "parent")
    // Check that the save belongs to this game by comparing gameIds
    if (not Loaded_GameId=game.gameid) {
      error ("Load Aborted: SaveCode not identified by this game. GameID mismatch.")
    }
    else {
      // Compare version of game in SaveCode to version of game loading it
      ThisGame_GameVersion = ToDouble(game.version)
      if (not TypeOf(OldestAllowedVersion)="double") {
        OldestAllowedVersion_Double = ToDouble(OldestAllowedVersion)
      }
      else {
        OldestAllowedVersion_Double = OldestAllowedVersion
      }
      // If upgrading from an old game version, then arbitrarily set Loaded_GameVersion to ThisGameVersion to proceed.
      if (Loaded_GameVersion<ThisGame_GameVersion) {
        if (upgradesave = False) {
          if (OldestAllowedVersion_Double<=Loaded_GameVersion) {
            msg ("WARNING! The SaveCode you are attempting to load is from an older game version.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br><br>Would you like to attempt to upgrade this save to the current version? (Results may vary...)")
            // Need to save SaveGameCodeDecoded as an attribute temporarily so it can be used by the ShowMenu function
            create ("SaveGameDecodedObj")
            set (SaveGameDecodedObj, "value", SaveGameCodeDecoded)
            ShowMenu ("", Split("Yes;No"), false) {
              switch (result) {
                case ("Yes") {
                  SuppressTurnscripts
                  msg ("Save code identified! Proceeding with load, please wait...")
                  OlderSaveCode = SaveGameDecodedObj.value+"✓✓"
                  LoadGameCode (OlderSaveCode)
                  destroy ("SaveGameDecodedObj")
                }
                case ("No") {
                  SuppressTurnscripts
                  msg ("Load Aborted.")
                  destroy ("SaveGameDecodedObj")
                }
              }
            }
          }
          else {
            error ("ERROR: The SaveCode you are attempting to load is from an INCOMPATIBLE older game version, and thus cannot be loaded by this version of the game.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Oldest Compatible Version: v"+ToString(OldestAllowedVersion)+"<br><br>Loading aborted...")
          }
        }
        else {
          msg ("Applying savecode from older version...")
          Proceed = True
        }
      }
      else if (Loaded_GameVersion>ThisGame_GameVersion) {
        error ("ERROR: The SaveCode you are attempting to load is from a newer version of this game and is not compatible.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Please try a different SaveCode or use an updated game file.<br><br>Load aborted.")
      }
      else {
        msg ("Proceeding with load...")
        Proceed = True
      }
      if (Proceed=True) {
        // Create any objects noted in the CreatedList, if there are any, so their relevant attributes can be added without error...
        if (ListCount(CreatedList)>0) {
          foreach (o, CreatedList) {
            // Check that objects don't already exist...
            IsThere = GetObject(o)
            if (Equal(IsThere,null)) {
              // If not, create the object
              create (o)
              CreatedObjDebugList = CreatedObjDebugList+o+"<br>"
            }
          }
        }
        player.grid_coordinates = null
        // Split the save code up into all objects. Then parse through the value of each object attribute
        SavedObjectList = Split(SaveCode, D1)
        foreach (o, SavedObjectList) {
          Skip_Att = False
          objelements = Split(o, D2)
          objectname = ListItem (objelements, 0)
          object = GetObject (objectname)
          attributename = ListItem (objelements, 1)
          fullname = objectname+"."+attributename
          preload_att_value = GetAttribute (object, attributename)
          att_datatype = ListItem (objelements, 2)
          if (ListCount(objelements)=3) {
            att_value = ""
          }
          else {
            att_value = ListItem (objelements, 3)
          }
          // Check that the attribute is supported
          if (not att_datatype="string" and not att_datatype="boolean" and not att_datatype="object" and not att_datatype="int" and not att_datatype="double" and not att_datatype="stringlist" and not att_datatype="objectlist" and not att_datatype="stringdictionary" and not att_datatype="objectdictionary") {
            msg ("WARNING! Unsupported datatype \""+att_datatype+"\" detected in SaveCode attribute \""+fullname+"\"! Skipping and moving on to next attribute...")
            Skip_Att = True
            SkippedAttList = SkippedAttList+fullname+"<br>"
          }
          // Convert the string attribute value and convert it to the datatype that it actually needs to be. This att_value_obj variable will also be directly compared to preload_att_value to determine if the pre- and post- load values are equal or not...
          att_value_obj = att_value
          if (att_datatype="object") {
            if (StartsWith(att_value,"Object: ")) {
              att_value_obj = GetObject(Right(att_value,LengthOf(att_value)-LengthOf("Object: ")))
            }
            else {
              att_value_obj = GetObject(att_value)
            }
          }
          else if (att_datatype="boolean") {
            if (att_value="True") {
              att_value_obj = True
            }
            else {
              att_value_obj = False
            }
          }
          else if (att_datatype="int") {
            att_value_obj = ToInt(att_value)
          }
          else if (att_datatype="double") {
            att_value_obj = ToDouble(att_value)
          }
          else if (att_datatype="stringlist") {
            if (att_value="") {
              att_value_obj = NewStringList()
            }
            else {
              att_value_obj = Split (att_value, D3)
              // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
              list remove (att_value_obj, " ")
            }
          }
          else if (att_datatype="objectlist") {
            if (att_value="") {
              att_value_obj = NewObjectList()
            }
            else {
              att_value_obj = NewObjectList()
              objlistlist = Split (att_value, D3)
              foreach (olt, objlistlist) {
                // Need to remove the "Object: " that will preceed each entry, and turn the string entry into the actual object before re-adding to list. We put it into the following "if" statement in order to exclude the blank list entry that gets created at the end of the list by loading
                if (StartsWith(olt,"Object: ")) {
                  value = GetObject(Right(olt,LengthOf(olt)-LengthOf("Object: ")))
                  if (not value=null) {
                    list add (att_value_obj, value)
                  }
                  else {
                    msg ("WARNING! Object \""+olt+"\" detected in saved objectlist \""+fullname+"\" does not exist! Object \""+olt+"\" not added to list! Loaded game may not work properly!")
                    SkippedAttList = SkippedAttList+"Objectlist '"+fullname+"' item: "+olt+"<br>"
                  }
                }
              }
            }
          }
          else if (att_datatype="stringdictionary") {
            if (att_value="") {
              att_value_obj = NewStringDictionary()
            }
            else {
              att_value_obj = NewStringDictionary()
              // Add dictionary values from SaveGame
              dictrows = Split(att_value, ";")
              foreach (kv, dictrows) {
                if (DebugMode) {
                  msg ("StringDict '"+fullname+"' key-value: "+ToString(kv))
                }
                KeyValList = Split(kv," = ")
                key = ListItem(KeyValList, 0)
                value = ListItem(KeyValList, 1)
                DictionaryAdd (att_value_obj, key, value)
              }
            }
          }
          else if (att_datatype="objectdictionary") {
            if (att_value="") {
              att_value_obj = NewObjectDictionary()
            }
            else {
              att_value_obj = NewObjectDictionary()
              dictrows = Split(att_value, ";")
              foreach (kv, dictrows) {
                if (DebugMode) {
                  msg ("ObjDict  '"+fullname+"' key-value: "+ToString(kv))
                }
                KeyValList = Split(kv," = ")
                key = ListItem(KeyValList, 0)
                obj = ListItem(KeyValList, 1)
                if (StartsWith(obj,"Object: ")) {
                  value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
                }
                else {
                  value = obj
                }
                if (not value=null) {
                  DictionaryAdd (att_value_obj, key, value)
                }
                else {
                  msg ("WARNING! Object \""+obj+"\" detected in saved objectdictionary \""+fullname+"\" does not exist! Object \""+obj+"\" not added to dictionary! Loaded game may not work properly!")
                  SkippedAttList = SkippedAttList+"Objectdictionary '"+fullname+"' item: "+olt+"<br>"
                }
              }
            }
          }
          if (objectname=GamePOVName and attributename="parent") {
            // Check that the attribute is NOT game.pov.parent. If so, we want to make sure that gets updated last
            Skip_Att = True
            GamePOVParent = att_value_obj
          }
          // Make sure the object you are trying to add/update the attribute to exists, otherwise you'd get an error trying to update/create its attribute. If the attribute doesn't exist but the object does, then this function will create it. Also, don't update the game.version or game.pov: The game.version should not be updated from the savecode if you're loading from a previous version, and the game.pov is updated last.
          if (not Equal(object,null) and not Equal(att_value_obj,null) and not fullname="game.gameid" and not fullname="game.version" and not fullname="game.pov" and not Skip_Att=True) {
            if (Equal(preload_att_value,null)) {
              if (DebugMode) {
                msg ("<br>ATTENTION: Attribute '"+fullname+"' does NOT exist in current game, but its parent object '"+objectname+"' does, so attribute will be created!<br>")
              }
              preload_att_value = "null"
            }
            // Msgs for debugging:
            if (DebugMode) {
              msg ("objectname="+objectname)
              msg ("attributename="+attributename)
              msg ("att_datatype="+att_datatype)
              msg ("preload_att_value="+ToString(preload_att_value))
              if (Equal(preload_att_value,"null")) {
                msg ("preload_att_datatype=null")
              }
              else {
                msg ("preload_att_datatype="+TypeOf(preload_att_value))
              }
              msg ("att_value="+ToString(att_value))
              msg ("att_value_obj="+ToString(att_value_obj))
              msg ("isEqual att_value=preload_att_value?: "+ToString(Equal(att_value,preload_att_value)))
              msg ("isEqual att_value_obj=preload_att_value?: "+ToString(Equal(att_value_obj,preload_att_value)))
              msg ("isEqual ToString(att_value_obj)=ToString(preload_att_value)?: "+ToString(Equal(ToString(att_value_obj),ToString(preload_att_value))))
              msg ("<br>")
            }
            // If attributes are already equal to those in the savecode, no need to change them. Else, change 'em.
            if (not Equal(att_value,preload_att_value) and not Equal(att_value_obj,preload_att_value) and not Equal(ToString(att_value_obj),ToString(preload_att_value))) {
              if (DebugMode) {
                msg ("Updating attribute: "+fullname+"<br><br>")
              }
              // Check if attribute has an associated change script. If so, this section will make sure that setting the attribute on load WON'T activate its associate turnscript
              cha = "changed" + attributename
              if (HasAttribute (object, cha)) {
                // If the attribute DOES have an associated change script, temporarily blank it out so it does not execute during loading
                scr = GetAttribute (object, cha)
                set (object, cha, bla)
              }
              // Update the attributes in the game with those from the SaveCode...
              if (att_datatype="boolean") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="int") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="double") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="object") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="stringlist" or att_datatype="objectlist" or att_datatype="stringdictionary" or att_datatype="objectdictionary") {
                // NOTE TO DEVELOPER:
                // Alter the following logic below to fit your needs. Especially important to make sure this works properly for YOUR game for compatibility between game versions!
                // If ReplaceContents = True, then any list or dictionary in your game will be COMPLETELY REPLACED by its corresponding list/dictionary from the savecode.
                // If ReplaceContents = False, then the list/dictionary contents in the SaveCode will be ADDED to the existing corresponding list/dictionary. NOTE: When adding to an existing list/dict, the code, as-written, will REMOVE ANY DUPLICATES from the lists/dictionaries! ALSO, be careful where you allow LoadGame() to be called in cases where ReplaceContents=False, ESPECIALLY if the list/dict contents can change through the course of the game! Calling this LoadGameCode function only from a titlescreen (before any lists/dictionaries have changed), for instance, may be one possible way to account for this.
                // ReplaceContents=True by default, but this may not be desirable in all cases (i.e. if you updated the contents of a permanent dictionary/list between versions), so it is up to YOU to ensure this section behaves as you want it to. Remember that the "object" and "attributename" variables exist at this point to call out specific list/dictionary objects.
                if (upgradesave = True) {
                  // This section will trigger if the player is loading a save from a previous game version
                  ReplaceContents = True
                }
                else {
                  // If this savecode is NOT coming from a previous game version, then I assume it is safe to completely replace the existing dictionary with the saved one.
                  ReplaceContents = True
                }
                if (att_datatype="stringlist") {
                  if (ReplaceContents = True) {
                    // Completely replace stringlist contents with those found in the SaveCode
                    set (object, attributename, att_value_obj)
                  }
                  else {
                    // Add the contents of the saved stringlist TO the existing stringlist in-game
                    FinalList = NewStringList()
                    // Retrieve the contents of the existing list
                    PreLoadList = preload_att_value
                    CombinedList = ListCombine (PreLoadList, att_value_obj)
                    // Remove duplicates
                    CompactList = ListCompact (CombinedList)
                    // CompactList will be a generic "list" type object, need to convert it back to a stringlist...
                    foreach (olt, CompactList) {
                      list add (FinalList, olt)
                    }
                    set (object, attributename, FinalList)
                  }
                }
                else if (att_datatype="objectlist") {
                  if (ReplaceContents = True) {
                    // Completely replace objectlist contents with those found in the SaveCode
                    set (object, attributename, att_value_obj)
                  }
                  else {
                    // Add the contents of the saved objectlist TO the existing objectlist in-game
                    // Retrieve the contents of the existing list
                    PreLoadList = preload_att_value
                    CombinedList = ListCombine (PreLoadList, att_value_obj)
                    // Remove duplicates
                    FinalList = ObjectListCompact (CombinedList)
                    set (object, attributename, FinalList)
                  }
                }
                else if (att_datatype="stringdictionary") {
                  if (ReplaceContents = True) {
                    // Then completely overwrite existing stringdictionary contents with those in the savecode
                    set (object, attributename, att_value_obj)
                    Dummy = NewStringDictionary()
                  }
                  else {
                    // Add the contents of the saved stringdictionary TO the existing stringdictionary in-game
                    Dummy = preload_att_value
                    // Add dictionary values from SaveGame
                    dictrows = Split(att_value, ";")
                    foreach (kv, dictrows) {
                      KeyValList = Split(kv," = ")
                      key = ListItem(KeyValList, 0)
                      value = ListItem(KeyValList, 1)
                      DictionaryAdd (Dummy, key, value)
                    }
                    set (object, attributename, Dummy)
                  }
                }
                else if (att_datatype="objectdictionary") {
                  if (upgradesave = False) {
                    // Then completely overwrite existing objectdictionary contents with those in the savecode
                    set (object, attributename, att_value_obj)
                  }
                  else {
                    // Add the contents of the saved objectdictionary TO the existing objectdictionary in-game
                    Dummy = preload_att_value
                    // Add dictionary values from SaveGame
                    dictrows = Split(att_value, ";")
                    foreach (kv, dictrows) {
                      KeyValList = Split(kv," = ")
                      key = ListItem(KeyValList, 0)
                      value = ListItem(KeyValList, 1)
                      if (StartsWith(value,"Object: ")) {
                        value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
                      }
                      DictionaryAdd (Dummy, key, value)
                    }
                    set (object, attributename, Dummy)
                  }
                }
              }
              else if (att_datatype="string") {
                set (object, attributename, att_value)
              }
              else {
                error ("ERROR: Unsupported object type detected in SaveCode: "+fullname+" of "+att_datatype+" datatype.")
              }
              if (HasAttribute (object, cha)) {
                // If a change script exists for this attribute, set change script back to original value after attribute has been changed
                set (object, cha, scr)
                scr => {
                }
              }
            }
          }
        }
        // Extract and update map data from saved grid_coordinates. Because grid_coordinates is a dictionary of dictionaries, it needed to be saved in a special way. Thus, it needs to be loaded in a special way as well.
        // If D1=|,D2=$,D3=;,and D4=@, then grid_coordinates will be saved in form: "ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|" etc.
        AllSavedGrids = Split(GridGInfo,D4)
        if (DebugMode) {
          msg ("<br>AllSavedGrids: "+ToString(AllSavedGrids))
        }
        foreach (A, AllSavedGrids) {
          UDictionary = NewDictionary()
          ItemAndValue = Split(A,"&%&")
          ObjAndAtt = ListItem(ItemAndValue,0)
          ObjAndAtt = Split(ObjAndAtt,D2)
          objectname = ListItem(ObjAndAtt,0)
          attributename = ListItem(ObjAndAtt,1)
          object = GetObject(objectname)
          GridVals = ListItem(ItemAndValue,1)
          GridVals = Split(GridVals,D1)
          foreach (B, GridVals) {
            UKeyAndUVal = Split(B,D2)
            UKey = ListItem(UKeyAndUVal,0)
            UVal = ListItem(UKeyAndUVal,1)
            UVal = Split(UVal,D3)
            LDictionary = NewDictionary()
            foreach (C, UVal) {
              LkeyAndLval = Split(C," = ")
              Lkey = ListItem(LkeyAndLval,0)
              LvalAndType = ListItem(LkeyAndLval,1)
              LvalAndType = Split(LvalAndType,":")
              Lval_str = ListItem(LvalAndType,0)
              LType = ListItem(LvalAndType,1)
              if (LType="int") {
                Lval = ToInt(Lval_str)
              }
              else if (LType="double") {
                Lval = ToDouble(Lval_str)
              }
              else if (LType="boolean") {
                if (Lval_str="True") {
                  Lval = True
                }
                else {
                  Lval = False
                }
              }
              else {
                error ("ERROR: Unsupported datatype found in "+objectname+"."+attributename+"! Datatype '"+LType+"' not supported!")
              }
              DictionaryAdd (LDictionary, Lkey, Lval)
            }
            DictionaryAdd (UDictionary, UKey, LDictionary)
          }
          if (DebugMode) {
            msg ("<br>"+objectname+"."+attributename+" UDictionary: "+ToString(UDictionary))
          }
          set (object, attributename, UDictionary)
        }
        // Destroy any objects that the player destroyed during their saved game, if any
        if (ListCount(DestroyedList)>0) {
          foreach (o, DestroyedList) {
            // Check that objects still exist...
            IsThere = GetObject(o)
            if (not Equal(IsThere,null)) {
              // If its there, destroy the object
              destroy (o)
              DestroyedObjDebugList = DestroyedObjDebugList+o+"<br>"
            }
          }
        }
        msg ("Load complete!")
        if (DebugMode) {
          msg ("Created objects: "+CreatedObjDebugList)
          msg ("Destroyed objects: "+DestroyedObjDebugList)
          msg ("Skipped Attributes:<br>"+SkippedAttList)
        }
        // Finally, update game.pov.parent and game.pov
        wait {
          set (GamePOVObject, "parent", GamePOVParent)
          game.pov = GamePOVObject
          // player.grid_coordinates = null
          JS.Grid_ClearAllLayers ()
          Grid_Redraw
          Grid_DrawPlayerInRoom (game.pov.parent)
          ClearScreen
          ShowRoomDescription
        }
      }
    }
  ]]></function>
  <function name="SaveGameCode" parameters="ShowCodePopupFlag" type="string"><![CDATA[
    // SaveGameCode(ShowCodePopupFlag) Function to collect changeable attributes into a string in order to generate a SaveCode for LoadGameCode to load.
    // The ShowCodePopupFlag input parameter is a boolean value. If TRUE, then the function will present the player with a popup window containing their encoded save code. If FALSE, the function will instead RETURN the SaveString (so if X=SaveGameCode(False), then X will equal the generated SaveString).
    // Will not save non-string/non-object lists/dictionaries (with grid_coordinates as an exception), will not save script attributes, will not save script dictionaries, will not save delegate attributes, will not save command patterns, will not save the "look" attribute (as it should not change), and will not save the "description" attribute (as it should not change).
    // Will not grab any other attributes attached to the "game" object except for game.gameid, game.version, game.pov, and game.timeelapsed. IF YOU WOULD LIKE TO SAVE ANY ADDITIONAL ATTRIBUTES ATTACHED TO "game", you will need to add the attribute names to a game.SaveAtts STRINGLIST attribute OR EDIT THIS FUNCTION TO CALL THEM OUT SPECIFICALLY. If you'd like to go the latter route I've noted the section below where I would recommend adding custom "game" attributes with a ***
    // Will not save timer status UNLESS the names of the timers are added to a game.SaveTimers STRINGLIST attribute OR YOU EDIT THIS FUNCTION DIRECTLY! If you want to go the latter route, I would recommend adding these to the section below marked with ***
    SuppressTurnscripts
    // Make sure ShowCodePopupFlag is of type 'boolean'
    if (not TypeOf(ShowCodePopupFlag)="boolean") {
      X = TypeOf(ShowCodePopupFlag)
      error ("ERROR: SaveGameCode function expected input 'ShowCodePopupFlag' to be of type 'boolean', but instead recieved an input of type '"+X+"'!")
    }
    SaveString = ""
    CreatedObj = NewStringList()
    DestroyedObj = NewStringList()
    // Set delimiters.
    // WARNING: D1 or D2 CANNOT be present in any object names, attribute names, or attribute values that you intend to save. Otherwise, the save will not load properly.
    // WARNING: D3 CANNOT be present in any object names or attribute names, but CAN be present in an attribute's value. This is because D3 MUST be the delimiter used to separate List entries in order to load lists properly
    // D1 delimiter will separate full object attributes, D2 delimiter will separate the data that comprises an attribute.
    // D3 is not set by this function, but instead is what the LoadGame() function will assume separates all list entries. As a reminder, by-default Quest will use ; as the List delimiter.
    D1 = "|"
    D2 = "$"
    D3 = ";"
    D4 = "@"
    // Save the player's current map before saving
    // Make sure first two entries are gameid and version (for load function)
    SaveString = SaveString+"game"+D2+"gameid"+D2+"string"+D2+game.gameid+D1
    SaveString = SaveString+"game"+D2+"version"+D2+"string"+D2+game.version+D1
    // Grab current active player (game.pov). This way the LoadGame knows who the player object is and to update its parent last
    SaveString = SaveString+"game"+D2+"pov"+D2+"object"+D2+game.pov+D1
    // Record all changable object attributes
    foreach (o, AllObjects()) {
      objectname = o.name
      // Check to see if object was created by player mid-game by comparing to the objectlist at start of game
      if (not ListContains(game.StartingObjStrList, objectname)) {
        // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
        if (not ListContains(CreatedObj, objectname)) {
          list add (CreatedObj, objectname)
        }
        // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
        IncludeTypeFlag = True
      }
      else {
        IncludeTypeFlag = False
      }
      foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
        fullname = objectname+"."+attributename
        att_datatype = ToString(TypeOf(o, attributename))
        if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
          if (att_datatype="object") {
            v = GetAttribute (o, attributename)
            att_value = v.name
          }
          else if (att_datatype="stringlist" or att_datatype="objectlist") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "List: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("List: "))
            // Confirm there are no banned delimiters in the list entries
            v = GetAttribute (o, attributename)
            if (ListCount(v)>0) {
              if (att_datatype="stringlist") {
                foreach (listcheck, v) {
                  // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
                  if (Instr(listcheck,D1)>0 or Instr(listcheck,D2)>0 or Instr(listcheck,D3)>0 or Instr(listcheck,D4)>0) {
                    error ("ERROR: Banned delimiter detected in \""+fullname+"\" list entry '"+listcheck+"'! Consider editting SaveGameCode function to change delimiters, or renaming list entry. Current banned list entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
                  }
                }
              }
            }
          }
          else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
            // Confirm there are no banned delimiters in the dictionary entries
            v = GetAttribute (o, attributename)
            if (DictionaryCount(v)>0) {
              foreach (dictkey, v) {
                if (Instr(dictkey,D1)>0 or Instr(dictkey,D2)>0 or Instr(dictkey,D3)>0 or Instr(dictkey,D4)>0) {
                  error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key '"+dictkey+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary key. Current banned dictionary key delimiters: "+D1+" "+D2+" "+D3+" "+D4)
                }
                if (att_datatype="stringdictionary") {
                  dictitm = DictionaryItem (v, dictkey)
                  // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
                  if (Instr(dictitm,D1)>0 or Instr(dictitm,D2)>0 or Instr(dictitm,D3)>0 or Instr(dictitm,D4)>0) {
                    error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key.value '"+dictkey+"."+dictitm+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary value. Current banned dictionary entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
                  }
                }
              }
            }
          }
          else {
            att_value = ToString(GetAttribute (o, attributename))
          }
          // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
          if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object.Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
          }
          SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
        }
      }
    }
    foreach (o, AllExits()) {
      objectname = o.name
      // Check to see if exit was created by player mid-game by comparing to the objectlist at start of game
      if (not ListContains(game.StartingObjStrList, objectname)) {
        // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
        if (not ListContains(CreatedObj, objectname)) {
          list add (CreatedObj, objectname)
        }
        // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
        IncludeTypeFlag = True
      }
      else {
        IncludeTypeFlag = False
      }
      foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
        fullname = objectname+"."+attributename
        att_datatype = ToString(TypeOf(o, attributename))
        if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate" and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
          if (att_datatype="object") {
            v = GetAttribute (o, attributename)
            att_value = v.name
          }
          else if (att_datatype="stringlist" or att_datatype="objectlist") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "List: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("List: "))
          }
          else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
          }
          else {
            att_value = ToString(GetAttribute (o, attributename))
          }
          // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
          if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object. Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
          }
          SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
        }
      }
    }
    foreach (turnscript, AllTurnScripts()) {
      // Check for which turnscripts are enabled/disabled
      if (GetBoolean(turnscript, "enabled")) {
        SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"True"+D1
      }
      else {
        SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"False"+D1
      }
    }
    // Determine if any objects were destroyed by the player since game start...
    foreach (objectname, game.StartingObjStrList) {
      IsThere = GetObject(objectname)
      if (Equal(IsThere,null)) {
        list add (DestroyedObj, objectname)
      }
    }
    // Save the game.timeelapsed attribute
    SaveString = SaveString+"game"+D2+"timeelapsed"+D2+"int"+D2+ToString(game.timeelapsed)+D1
    // Check if game.SaveAtts and/or game.SaveTimers exists.
    // game.SaveAtts is expected to be a stringlist containing a list of game attributes to save.
    if (HasAttribute (game, "SaveAtts")) {
      SaveAttType = TypeOf(game.SaveAtts)
      if (SaveAttType="stringlist") {
        if (ListCount(game.SaveAtts)>0) {
          foreach (x, game.SaveAtts) {
            AttValue = GetAttribute (game, x)
            att_datatype = TypeOf(AttValue)
            if (not Equal(AttValue,null) and not Equal(x,"checkpoints") and not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern") {
              SaveString = SaveString+"game"+D2+x+D2+att_datatype+D2+ToString(AttValue)+D1
            }
            else if (Equal(x,"checkpoints")) {
              error ("ERROR: game.SaveAtts - Banned attribute 'checkpoints' found in game.SaveAtts. game.checkpoints cannot be saved using game.SaveAtts!")
            }
            else if (Equal(AttValue,null)) {
              error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not found attached to game object!")
            }
            else {
              error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not allowed. SaveGameCode cannot save attributes of type: "+att_datatype)
            }
          }
        }
      }
      else {
        error ("ERROR: game.SaveAtts expected to be a stringlist containing a list of game attributes to save. Instead, game.SaveAtts found is of datatype: "+SaveAttType)
      }
    }
    // game.SaveTimers is expected to be an stringlist containing a list of the names of all timers in the game that the author wants to save (ideally, all timers in the game).
    if (HasAttribute (game, "SaveTimers")) {
      SaveAttType = TypeOf(game.SaveTimers)
      if (SaveAttType="stringlist") {
        if (ListCount(game.SaveTimers)>0) {
          foreach (x, game.SaveTimers) {
            T = GetObject(x)
            if (not Equal(T,null)) {
              TimerName = x.name
              TimerValue1 = x.trigger
              TimerValue2 = x.interval
              TimerValue3 = x.enabled
              TimerValue1Type = TypeOf(TimerValue1)
              TimerValue2Type = TypeOf(TimerValue2)
              TimerValue3Type = TypeOf(TimerValue3)
              SaveString = SaveString+TimerName+D2+"trigger"+D2+TimerValue1Type+D2+ToString(TimerValue1)+D1
              SaveString = SaveString+TimerName+D2+"interval"+D2+TimerValue2Type+D2+ToString(TimerValue2)+D1
              SaveString = SaveString+TimerName+D2+"enabled"+D2+TimerValue3Type+D2+ToString(TimerValue3)+D1
            }
            else {
              error ("ERROR: game.SaveTimers - Timer named '"+x+"' not found!")
            }
          }
        }
      }
      else {
        error ("ERROR: game.SaveTimers expected to be a stringlist containing a list of the names of timers. Instead, game.SaveTimers found is of datatype: "+SaveAttType)
      }
    }
    // If neither of those attributes exist, then the developer can also add their own custom attributes to save using the template below...
    // *** TO DEVELOPER: Recommend putting timer status and other "game" attributes that can change based on user action during the game in this function below:
    // The template to save additional attributes is SaveString=SaveString+{string objectname}+D2+{string attributename}+D2+{datatype string}+D2+ToString({attribute value})+D1
    // For example, to save the "timer.enabled" attribute for a timer named BeeTimer: SaveString=SaveString+"BeeTimer"+D2+"enabled"+D2+"boolean"+D2+ToString(BeeTimer.enabled)+D1
    // ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    // DO NOT APPEND THE SAVESTRING WITH ANY ADDITIONAL ATTRIBUTES BELOW THIS POINT. The game.pov.grid_coordinates, Created/Destroyed objectlist, and delimiters MUST be added on last in order for the LoadGame() Function to load properly.
    // Save the player.grid_coordinates so the player's map is saved. Because it is a dictionary of dictionaries, it must be saved in a special way...
    // grid_coordinates will be saved in form: "StuffBefore|@ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|"
    foreach (o, AllObjects()) {
      foreach (attributename, GetAttributeNames(o,false)) {
        objectname = o.name
        fullname = objectname+"."+attributename
        att_datatype = ToString(TypeOf(o, attributename))
        if (att_datatype="dictionary" and StartsWith(attributename,"saved_map_for_")) {
          // ASSUMES THAT ANY SAVED MAP DATA (for teleporting and keeping your map) STARTS WITH 'saved_map_for'. This follows the naming convention recommended by https://docs.textadventures.co.uk/quest/showing_a_map.html
          SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
          foreach (UKey, GetAttribute(o, attributename)) {
            SaveString = SaveString+UKey+D2
            UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
            foreach (Lkey, UVal) {
              Lval = DictionaryItem(UVal, Lkey)
              Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
              SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
            }
            SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
          }
        }
        else if (att_datatype="dictionary" and attributename="grid_coordinates") {
          // Save the current map. Typically this is game.pov.grid_coordinates, but if player character can change, there may be multiple occurences of 'grid_coordinates'. Save them all.
          SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
          foreach (UKey, GetAttribute(o, attributename)) {
            SaveString = SaveString+UKey+D2
            UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
            foreach (Lkey, UVal) {
              Lval = DictionaryItem(UVal, Lkey)
              Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
              SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
            }
            SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
          }
        }
      }
    }
    // Add on the list of created/destroyed objects...
    X = ToString(CreatedObj)
    // Cut off the "List: " string that preceeds its values when you use the ToString() command
    CreatedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
    X = ToString(DestroyedObj)
    // Cut off the "List: " string that preceeds its values when you use the ToString() command
    DestroyedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
    SaveString = SaveString+D4+CreatedObjStr+D1+DestroyedObjStr
    // Special logic needed in order to save the game.checkpoints attribute
    D4toD1 = D4+D3+D2+D1
    if (HasAttribute(game,"checkpoints")) {
      if (DictionaryCount(game.checkpoints)>0) {
        KeyList = ""
        CheckValList = ""
        foreach (k, game.checkpoints) {
          KeyList = KeyList+k+D3
          val = DictionaryItem(game.checkpoints, k)
          CheckValList = CheckValList+val+D4toD1
        }
        // Remove final D3 from KeyList string and final D4toD1 from CheckValList
        KeyList = Left(KeyList, LengthOf(KeyList)-LengthOf(D3))
        CheckValList = Left(CheckValList, LengthOf(CheckValList)-LengthOf(D4toD1))
        // Add game.checkpoints data to SaveString
        SaveString = SaveString+D4toD1+KeyList+D4toD1+CheckValList
      }
      else {
        // If game.checkpoints empty, just add D4toD1 to SaveString.
        SaveString = SaveString+D4toD1
      }
    }
    else {
      // If game.checkpoints non-existant, just add D4toD1 to SaveString.
      SaveString = SaveString+D4toD1
    }
    // Append the end of the SaveString with the delimiters used, so LoadGame() knows what delimiter maps to what...
    SaveString = SaveString+D1+D2+D3+D4
    // msg for Debugging:
    // msg (SaveString+"<br><br>")
    if (ShowCodePopupFlag=True) {
      // Create save code and present to player in textbox
      JS.CreateSaveCode (SaveString)
      JS.setCss ("#msgbox", "word-wrap:break-word;max-height:250px;")
    }
    return (SaveString)
  ]]></function>
  <function name="SaveCheckpoint" parameters="CheckpointName" type="string"><![CDATA[
    // SaveCheckpoint(CheckPointName) Function to locally save checkpoints to the game.checkpoints parameter. Functionally works just like SaveGameCode (minus saving game.checkpoints), except it does not convert the SaveString to base64 or present the SaveCode to the player, instead storing it in the game.checkpoints stringdictionary.
    // The CheckpointName input parameter is a string value that will become the Key in the game.checkpoints string dictionary for the generated checkpoint SaveString value. If the CheckpointName already exists in game.checkpoints, then this function will overwrite it, allowing checkpoint names to be re-used multiple times.
    // If CheckpointName="", then the SaveString will simply be returned as an output, rather than saved to game.checkpoints.
    // Will not save non-string/non-object lists/dictionaries (with grid_coordinates as an exception), will not save script attributes, will not save script dictionaries, will not save delegate attributes, will not save command patterns, will not save the "look" attribute (as it should not change), and will not save the "description" attribute (as it should not change).
    // Will not grab any other attributes attached to the "game" object except for game.gameid, game.version, game.pov, and game.timeelapsed. IF YOU WOULD LIKE TO SAVE ANY ADDITIONAL ATTRIBUTES ATTACHED TO "game", you will need to add the attribute names to a game.SaveAtts STRINGLIST attribute OR EDIT THIS FUNCTION TO CALL THEM OUT SPECIFICALLY. If you'd like to go the latter route I've noted the section below where I would recommend adding custom "game" attributes with a ***
    // Will not save timer status UNLESS the names of the timers are added to a game.SaveTimers STRINGLIST attribute OR YOU EDIT THIS FUNCTION DIRECTLY! If you want to go the latter route, I would recommend adding these to the section below marked with ***
    SuppressTurnscripts
    // Check if game.checkpoints exists. If not, create it.
    if (not HasAttribute(game, "checkpoints")) {
      set (game, "checkpoints", NewStringDictionary())
    }
    // Make sure CheckpointName input is of 'string' datatype
    if (not TypeOf(CheckpointName)="string") {
      X = TypeOf(CheckpointName)
      error ("ERROR: SaveCheckpoint function expected input 'CheckpointName' to be of type 'string', but instead recieved an input of type '"+X+"'!")
    }
    SaveString = ""
    CreatedObj = NewStringList()
    DestroyedObj = NewStringList()
    // Set delimiters.
    // WARNING: D1 or D2 CANNOT be present in any object names, attribute names, or attribute values that you intend to save. Otherwise, the save will not load properly.
    // WARNING: D3 CANNOT be present in any object names or attribute names, but CAN be present in an attribute's value. This is because D3 MUST be the delimiter used to separate List entries in order to load lists properly
    // D1 delimiter will separate full object attributes, D2 delimiter will separate the data that comprises an attribute.
    // D3 is not set by this function, but instead is what the LoadGame() function will assume separates all list entries. As a reminder, by-default Quest will use ; as the List delimiter.
    D1 = "|"
    D2 = "$"
    D3 = ";"
    D4 = "@"
    // Save the player's current map before saving
    // Make sure first two entries are gameid and version (for load function)
    SaveString = SaveString+"game"+D2+"gameid"+D2+"string"+D2+game.gameid+D1
    SaveString = SaveString+"game"+D2+"version"+D2+"string"+D2+game.version+D1
    // Grab current active player (game.pov). This way the LoadGame knows who the player object is and to update its parent last
    SaveString = SaveString+"game"+D2+"pov"+D2+"object"+D2+game.pov+D1
    // Record all changable object attributes
    foreach (o, AllObjects()) {
      objectname = o.name
      // Check to see if object was created by player mid-game by comparing to the objectlist at start of game
      if (not ListContains(game.StartingObjStrList, objectname)) {
        // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
        if (not ListContains(CreatedObj, objectname)) {
          list add (CreatedObj, objectname)
        }
        // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
        IncludeTypeFlag = True
      }
      else {
        IncludeTypeFlag = False
      }
      foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
        fullname = objectname+"."+attributename
        att_datatype = ToString(TypeOf(o, attributename))
        if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
          if (att_datatype="object") {
            v = GetAttribute (o, attributename)
            att_value = v.name
          }
          else if (att_datatype="stringlist" or att_datatype="objectlist") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "List: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("List: "))
            // Confirm there are no banned delimiters in the list entries
            v = GetAttribute (o, attributename)
            if (ListCount(v)>0) {
              if (att_datatype="stringlist") {
                foreach (listcheck, v) {
                  // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
                  if (Instr(listcheck,D1)>0 or Instr(listcheck,D2)>0 or Instr(listcheck,D3)>0 or Instr(listcheck,D4)>0) {
                    error ("ERROR: Banned delimiter detected in \""+fullname+"\" list entry '"+listcheck+"'! Consider editting SaveGameCode function to change delimiters, or renaming list entry. Current banned list entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
                  }
                }
              }
            }
          }
          else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
            // Confirm there are no banned delimiters in the dictionary entries
            v = GetAttribute (o, attributename)
            if (DictionaryCount(v)>0) {
              foreach (dictkey, v) {
                if (Instr(dictkey,D1)>0 or Instr(dictkey,D2)>0 or Instr(dictkey,D3)>0 or Instr(dictkey,D4)>0) {
                  error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key '"+dictkey+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary key. Current banned dictionary key delimiters: "+D1+" "+D2+" "+D3+" "+D4)
                }
                if (att_datatype="stringdictionary") {
                  dictitm = DictionaryItem (v, dictkey)
                  // Check if there are delimiters in the names of the list entries. If so, warn the player that their save won't work.
                  if (Instr(dictitm,D1)>0 or Instr(dictitm,D2)>0 or Instr(dictitm,D3)>0 or Instr(dictitm,D4)>0) {
                    error ("ERROR: Banned delimiter detected in \""+fullname+"\" dictionary key.value '"+dictkey+"."+dictitm+"'! Consider editting SaveGameCode function to change delimiters, or renaming dictionary value. Current banned dictionary entry delimiters: "+D1+" "+D2+" "+D3+" "+D4)
                  }
                }
              }
            }
          }
          else {
            att_value = ToString(GetAttribute (o, attributename))
          }
          // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
          if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object.Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
          }
          SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
        }
      }
    }
    foreach (o, AllExits()) {
      objectname = o.name
      // Check to see if exit was created by player mid-game by comparing to the objectlist at start of game
      if (not ListContains(game.StartingObjStrList, objectname)) {
        // Then object was created by player. Double-check that it isn't already in CreatedObj list. If not, add it.
        if (not ListContains(CreatedObj, objectname)) {
          list add (CreatedObj, objectname)
        }
        // If the object was created mid-game, then we might want to capture additional inherited type info to help when it gets recreated on load...
        IncludeTypeFlag = True
      }
      else {
        IncludeTypeFlag = False
      }
      foreach (attributename, GetAttributeNames(o,IncludeTypeFlag)) {
        fullname = objectname+"."+attributename
        att_datatype = ToString(TypeOf(o, attributename))
        if (not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate" and not att_datatype="command pattern" and not attributename="look" and not attributename="description") {
          if (att_datatype="object") {
            v = GetAttribute (o, attributename)
            att_value = v.name
          }
          else if (att_datatype="stringlist" or att_datatype="objectlist") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "List: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("List: "))
          }
          else if (att_datatype="stringdictionary" or att_datatype="objectdictionary") {
            X = ToString(GetAttribute (o, attributename))
            // Cut off the "Dictionary: " string that preceeds its values when you use the ToString() command
            att_value = Right(X,LengthOf(X)-LengthOf("Dictionary: "))
          }
          else {
            att_value = ToString(GetAttribute (o, attributename))
          }
          // Check if there are delimiters in any of the names/values. If so, warn the player that their save won't work.
          if (Instr(objectname,D1)>0 or Instr(objectname,D2)>0 or Instr(objectname,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" object name! Consider editting SaveGameCode function to change delimiters, or renaming object. Current banned objectname delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(attributename,D1)>0 or Instr(attributename,D2)>0 or Instr(attributename,D3)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute name! Consider editting SaveGameCode function to change delimiters, or renaming attribute. Current banned attributename delimiters: "+D1+" "+D2+" "+D3+" "+D4)
          }
          else if (Instr(att_value,D1)>0 or Instr(att_value,D2)>0 or Instr(objectname,D4)>0) {
            error ("ERROR: Banned delimiter detected in \""+fullname+"\" attribute value! Consider editting SaveGameCode function to change delimiters, or changing attribute value. Current banned attribute value delimiters: "+D1+" "+D2+" "+D4)
          }
          SaveString = SaveString+objectname+D2+attributename+D2+att_datatype+D2+att_value+D1
        }
      }
    }
    foreach (turnscript, AllTurnScripts()) {
      // Check for which turnscripts are enabled/disabled
      if (GetBoolean(turnscript, "enabled")) {
        SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"True"+D1
      }
      else {
        SaveString = SaveString+turnscript.name+D2+"enabled"+D2+"boolean"+D2+"False"+D1
      }
    }
    // Determine if any objects were destroyed by the player since game start...
    foreach (objectname, game.StartingObjStrList) {
      IsThere = GetObject(objectname)
      if (Equal(IsThere,null)) {
        list add (DestroyedObj, objectname)
      }
    }
    // Save the game.timeelapsed attribute
    SaveString = SaveString+"game"+D2+"timeelapsed"+D2+"int"+D2+ToString(game.timeelapsed)+D1
    // Check if game.SaveAtts and/or game.SaveTimers exists.
    // game.SaveAtts is expected to be a stringlist containing a list of game attributes to save.
    if (HasAttribute (game, "SaveAtts")) {
      SaveAttType = TypeOf(game.SaveAtts)
      if (SaveAttType="stringlist") {
        if (ListCount(game.SaveAtts)>0) {
          foreach (x, game.SaveAtts) {
            AttValue = GetAttribute (game, x)
            att_datatype = TypeOf(AttValue)
            if (not Equal(AttValue,null) and not Equal(x,"checkpoints") and not att_datatype="script" and not att_datatype="scriptdictionary" and not att_datatype="dictionary" and not att_datatype="list" and not att_datatype="delegate"and not att_datatype="command pattern") {
              SaveString = SaveString+"game"+D2+x+D2+att_datatype+D2+ToString(AttValue)+D1
            }
            else if (Equal(x,"checkpoints")) {
              error ("ERROR: game.SaveAtts - Banned attribute 'checkpoints' found in game.SaveAtts. game.checkpoints cannot be saved using game.SaveAtts!")
            }
            else if (Equal(AttValue,null)) {
              error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not found attached to game object!")
            }
            else {
              error ("ERROR: game.SaveAtts - Attribute entry '"+x+"' not allowed. SaveGameCode cannot save attributes of type: "+att_datatype)
            }
          }
        }
      }
      else {
        error ("ERROR: game.SaveAtts expected to be a stringlist containing a list of game attributes to save. Instead, game.SaveAtts found is of datatype: "+SaveAttType)
      }
    }
    // game.SaveTimers is expected to be an stringlist containing a list of the names of all timers in the game that the author wants to save (ideally, all timers in the game).
    if (HasAttribute (game, "SaveTimers")) {
      SaveAttType = TypeOf(game.SaveTimers)
      if (SaveAttType="stringlist") {
        if (ListCount(game.SaveTimers)>0) {
          foreach (x, game.SaveTimers) {
            T = GetObject(x)
            if (not Equal(T,null)) {
              TimerName = x.name
              TimerValue1 = x.trigger
              TimerValue2 = x.interval
              TimerValue3 = x.enabled
              TimerValue1Type = TypeOf(TimerValue1)
              TimerValue2Type = TypeOf(TimerValue2)
              TimerValue3Type = TypeOf(TimerValue3)
              SaveString = SaveString+TimerName+D2+"trigger"+D2+TimerValue1Type+D2+ToString(TimerValue1)+D1
              SaveString = SaveString+TimerName+D2+"interval"+D2+TimerValue2Type+D2+ToString(TimerValue2)+D1
              SaveString = SaveString+TimerName+D2+"enabled"+D2+TimerValue3Type+D2+ToString(TimerValue3)+D1
            }
            else {
              error ("ERROR: game.SaveTimers - Timer named '"+x+"' not found!")
            }
          }
        }
      }
      else {
        error ("ERROR: game.SaveTimers expected to be a stringlist containing a list of the names of timers. Instead, game.SaveTimers found is of datatype: "+SaveAttType)
      }
    }
    // If neither of those attributes exist, then the developer can also add their own custom attributes to save using the template below...
    // *** TO DEVELOPER: Recommend putting timer status and other "game" attributes that can change based on user action during the game in this function below:
    // The template to save additional attributes is SaveString=SaveString+{string objectname}+D2+{string attributename}+D2+{datatype string}+D2+ToString({attribute value})+D1
    // For example, to save the "timer.enabled" attribute for a timer named BeeTimer: SaveString=SaveString+"BeeTimer"+D2+"enabled"+D2+"boolean"+D2+ToString(BeeTimer.enabled)+D1
    // ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
    // DO NOT APPEND THE SAVESTRING WITH ANY ADDITIONAL ATTRIBUTES BELOW THIS POINT. The game.pov.grid_coordinates, Created/Destroyed objectlist, and delimiters MUST be added on last in order for the LoadGame() Function to load properly.
    // Save the player.grid_coordinates so the player's map is saved. Because it is a dictionary of dictionaries, it must be saved in a special way...
    // grid_coordinates will be saved in form: "StuffBefore|@ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|"
    foreach (o, AllObjects()) {
      foreach (attributename, GetAttributeNames(o,false)) {
        objectname = o.name
        fullname = objectname+"."+attributename
        att_datatype = ToString(TypeOf(o, attributename))
        if (att_datatype="dictionary" and StartsWith(attributename,"saved_map_for_")) {
          // ASSUMES THAT ANY SAVED MAP DATA (for teleporting and keeping your map) STARTS WITH 'saved_map_for'. This follows the naming convention recommended by https://docs.textadventures.co.uk/quest/showing_a_map.html
          SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
          foreach (UKey, GetAttribute(o, attributename)) {
            SaveString = SaveString+UKey+D2
            UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
            foreach (Lkey, UVal) {
              Lval = DictionaryItem(UVal, Lkey)
              Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
              SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
            }
            SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
          }
        }
        else if (att_datatype="dictionary" and attributename="grid_coordinates") {
          // Save the current map. Typically this is game.pov.grid_coordinates, but if player character can change, there may be multiple occurences of 'grid_coordinates'. Save them all.
          SaveString = SaveString + D4 + objectname + D2 + attributename + "&%&"
          foreach (UKey, GetAttribute(o, attributename)) {
            SaveString = SaveString+UKey+D2
            UVal = DictionaryItem(GetAttribute(o, attributename), UKey)
            foreach (Lkey, UVal) {
              Lval = DictionaryItem(UVal, Lkey)
              Lval = ToString(Lval)+":"+ToString(TypeOf(Lval))
              SaveString = SaveString+Lkey+" = "+ToString(Lval)+D3
            }
            SaveString = Left(SaveString,(LengthOf(SaveString)-1))+D1
          }
        }
      }
    }
    // Add on the list of created/destroyed objects...
    X = ToString(CreatedObj)
    // Cut off the "List: " string that preceeds its values when you use the ToString() command
    CreatedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
    X = ToString(DestroyedObj)
    // Cut off the "List: " string that preceeds its values when you use the ToString() command
    DestroyedObjStr = Right(X,LengthOf(X)-LengthOf("List: "))
    SaveString = SaveString+D4+CreatedObjStr+D1+DestroyedObjStr
    // Append the end of the SaveString with the delimiters used, so LoadGame() knows what delimiter maps to what...
    SaveString = SaveString+D1+D2+D3+D4
    // msg for Debugging:
    // msg (SaveString+"<br><br>")
    // Save SaveString to game.checkpoints if CheckpointName not empty. Else if CheckpointName="", then simply return the SaveString.
    if (not CheckpointName="") {
      DictionaryAdd (game.checkpoints, CheckpointName, SaveString)
    }
    return (SaveString)
  ]]></function>
  <function name="LoadCheckpoint" parameters="CheckpointName"><![CDATA[
    // LoadCheckpoint(CheckpointName) function to load a SaveCheckpoint checkpoint. Works similarly to LoadGameCode except it does not print any messages to the player. Also will not prompt the player if a save is found to be from an older version, it will just load according to the OldestAllowedVersion variable.
    // Input CheckpointName is the name of a checkpoint saved in the game.checkpoints stringdictionary attribute that you would like to load.
    SuppressTurnscripts
    // TO DEVELOPER: Set the OldestAllowedVersion to the oldest compatible game version that a player can load saved game data from. Setting OldestAllowedVersion=0 will essentially allow the player to load saves from any old version. Setting OldestAllowedVersion=game.version will make it so the player can ONLY load saves from the current game version.
    OldestAllowedVersion = 2.0
    // TO DEVELOPER: Setting DebugMode to 'True' will enable the printing of debug messages to the screen when running. Very useful for testing out the function if you've made any custom edits for compatibility or the like.
    DebugMode = False
    // Msg for Debugging:
    if (DebugMode) {
      msg ("<br>Full decoded SaveCode:<br>"+SaveGameCodeDecoded)
    }
    // Make sure CheckpointName input is of type 'string'
    if (not TypeOf(CheckpointName)="string") {
      X = TypeOf(CheckpointName)
      error ("ERROR: LoadCheckpoint function expected input 'CheckpointName' to be of type 'string', but instead recieved an input of type '"+X+"'!")
    }
    // Retrieve SaveString from game.checkpoints dictionary
    if (HasAttribute(game, "checkpoints")) {
      if (DictionaryContains(game.checkpoints, CheckpointName)) {
        SaveGameCodeDecoded = DictionaryItem(game.checkpoints, CheckpointName)
      }
      else {
        error ("ERROR: Checkpoint named '"+CheckpointName+"' not found!")
      }
    }
    else {
      error ("ERROR: Cannot load checkpoint as game.checkpoints attribute does not exist!")
    }
    // Set up other variables for later
    bla => {
    }
    upgradesave = False
    Proceed = False
    SkippedAttList = ""
    CreatedObjDebugList = ""
    DestroyedObjDebugList = ""
    // Retrieve delimiters from end of SaveGameCodeDecoded
    Dls = Right(SaveGameCodeDecoded,4)
    D1 = Mid (Dls, 1, 1)
    D2 = Mid (Dls, 2, 1)
    D3 = Mid (Dls, 3, 1)
    D4 = Mid (Dls, 4, 1)
    // Remove delimiters from end of SaveGameCode
    SaveCode = Left(SaveGameCodeDecoded, LengthOf(SaveGameCodeDecoded)-(LengthOf(Dls)))
    // Extract the Created/Destroyed object lists. The START of the created/destroyed section should be after the LAST D4 delimiter...
    CreatedDestroyedInfo = Right(SaveCode, LengthOf(SaveCode)-InstrRev(SaveCode, D4))
    if (DebugMode) {
      msg ("<br>CreatedDestroyedInfo: "+CreatedDestroyedInfo)
    }
    CDList = Split(CreatedDestroyedInfo,D1)
    CSection = ListItem (CDList, 0)
    DSection = ListItem (CDList, 1)
    if (CSection="") {
      CreatedList = NewStringList()
    }
    else {
      CreatedList = Split (CSection, D3)
      // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
      list remove (CreatedList, " ")
    }
    if (DSection="") {
      DestroyedList = NewStringList()
    }
    else {
      DestroyedList = Split (DSection, D3)
      // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
      list remove (DestroyedList, " ")
    }
    // Remove Created/Destroyed list from end of SaveCode, also removing the final D1 and D4 delimiter...
    SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(CreatedDestroyedInfo)+2))
    // Extract the player.grid_coordinates info separately from the rest of the savecode. It has special rules for decoding it since it is a dictionary of dictionaries.
    GridGInfo = Right(SaveCode, LengthOf(SaveCode)-Instr(SaveCode, D4))
    if (DebugMode) {
      msg ("<br>GridGInfo: "+GridGInfo)
    }
    // Remove player.grid_coordinates info from end of SaveCode also remove the final D1 & D4 delimiter separating the grid_coordinates from the rest of the attributes
    SaveCode = Left(SaveCode, LengthOf(SaveCode)-(LengthOf(GridGInfo)+2))
    if (DebugMode) {
      msg ("<br>SaveCode w/o player.grid_coordinate or create/destroy info:<br>"+SaveCode)
    }
    // Note: if the "SaveCode" begins with the word "Error:", then an error was encountered when trying to convert from Base64.
    // Extract SaveCode game info. This includes the gameid, game version, and current player (game.pov)...
    GameIdDelim = Instr (SaveCode, D1)
    GameVersionDelim = Instr(GameIdDelim+1,SaveCode,D1)
    GamePOVDelim = Instr(GameVersionDelim+1,SaveCode,D1)
    GameInfo = Split(Left(SaveCode,GamePOVDelim-1),D1)
    GameIdObjectEl = ListItem (GameInfo,0)
    GameIdElements = Split(GameIdObjectEl,D2)
    Loaded_GameId = ListItem (GameIdElements, 3)
    GameVerObjectEl = ListItem (GameInfo,1)
    GameVerElements = Split(GameVerObjectEl,D2)
    VersionString = ListItem (GameVerElements, 3)
    Loaded_GameVersion = ToDouble(VersionString)
    GamePOVObjectEl = ListItem (GameInfo,2)
    GamePOVElements = Split(GamePOVObjectEl,D2)
    GamePOVName = ListItem (GamePOVElements, 3)
    if (StartsWith(GamePOVName,"Object: ")) {
      GamePOVName = Right(GamePOVName,LengthOf(GamePOVName)-LengthOf("Object: "))
    }
    GamePOVObject = GetObject (GamePOVName)
    GamePOVParent = GetAttribute (GamePOVObject, "parent")
    // Check that the save belongs to this game by comparing gameIds
    if (not Loaded_GameId=game.gameid) {
      error ("Load Aborted: SaveCode not identified by this game. GameID mismatch.")
    }
    else {
      // Compare version of game in SaveCode to version of game loading it
      ThisGame_GameVersion = ToDouble(game.version)
      if (not TypeOf(OldestAllowedVersion)="double") {
        OldestAllowedVersion_Double = ToDouble(OldestAllowedVersion)
      }
      else {
        OldestAllowedVersion_Double = OldestAllowedVersion
      }
      // If upgrading from an old game version, then arbitrarily set Loaded_GameVersion to ThisGameVersion to proceed.
      if (Loaded_GameVersion<ThisGame_GameVersion) {
        if (OldestAllowedVersion_Double<=Loaded_GameVersion) {
          upgradesave = True
          Proceed = True
        }
        else {
          error ("ERROR: The SaveCode you are attempting to load is from an INCOMPATIBLE older game version, and thus cannot be loaded by this version of the game.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Oldest Compatible Version: v"+ToString(OldestAllowedVersion)+"<br><br>Loading aborted...")
        }
      }
      else if (Loaded_GameVersion>ThisGame_GameVersion) {
        error ("ERROR: The SaveCode you are attempting to load is from a newer version of this game and is not compatible.<br>Saved Game: v"+ToString(Loaded_GameVersion)+"<br>This Game: v"+ToString(ThisGame_GameVersion)+"<br>Please try a different SaveCode or use an updated game file.<br><br>Load aborted.")
      }
      else {
        Proceed = True
      }
      if (Proceed=True) {
        // Create any objects noted in the CreatedList, if there are any, so their relevant attributes can be added without error...
        if (ListCount(CreatedList)>0) {
          foreach (o, CreatedList) {
            // Check that objects don't already exist...
            IsThere = GetObject(o)
            if (Equal(IsThere,null)) {
              // If not, create the object
              create (o)
              CreatedObjDebugList = CreatedObjDebugList+o+"<br>"
            }
          }
        }
        player.grid_coordinates = null
        // Split the save code up into all objects. Then parse through the value of each object attribute
        SavedObjectList = Split(SaveCode, D1)
        foreach (o, SavedObjectList) {
          Skip_Att = False
          objelements = Split(o, D2)
          objectname = ListItem (objelements, 0)
          object = GetObject (objectname)
          attributename = ListItem (objelements, 1)
          fullname = objectname+"."+attributename
          preload_att_value = GetAttribute (object, attributename)
          att_datatype = ListItem (objelements, 2)
          if (ListCount(objelements)=3) {
            att_value = ""
          }
          else {
            att_value = ListItem (objelements, 3)
          }
          // Check that the attribute is supported
          if (not att_datatype="string" and not att_datatype="boolean" and not att_datatype="object" and not att_datatype="int" and not att_datatype="double" and not att_datatype="stringlist" and not att_datatype="objectlist" and not att_datatype="stringdictionary" and not att_datatype="objectdictionary") {
            msg ("WARNING! Unsupported datatype \""+att_datatype+"\" detected in SaveCode attribute \""+fullname+"\"! Skipping and moving on to next attribute...")
            Skip_Att = True
            SkippedAttList = SkippedAttList+fullname+"<br>"
          }
          // Convert the string attribute value and convert it to the datatype that it actually needs to be. This att_value_obj variable will also be directly compared to preload_att_value to determine if the pre- and post- load values are equal or not...
          att_value_obj = att_value
          if (att_datatype="object") {
            if (StartsWith(att_value,"Object: ")) {
              att_value_obj = GetObject(Right(att_value,LengthOf(att_value)-LengthOf("Object: ")))
            }
            else {
              att_value_obj = GetObject(att_value)
            }
          }
          else if (att_datatype="boolean") {
            if (att_value="True") {
              att_value_obj = True
            }
            else {
              att_value_obj = False
            }
          }
          else if (att_datatype="int") {
            att_value_obj = ToInt(att_value)
          }
          else if (att_datatype="double") {
            att_value_obj = ToDouble(att_value)
          }
          else if (att_datatype="stringlist") {
            if (att_value="") {
              att_value_obj = NewStringList()
            }
            else {
              att_value_obj = Split (att_value, D3)
              // The way the lists are saved, when you load it, a blank entry will be created. Let's remove that...
              list remove (att_value_obj, " ")
            }
          }
          else if (att_datatype="objectlist") {
            if (att_value="") {
              att_value_obj = NewObjectList()
            }
            else {
              att_value_obj = NewObjectList()
              objlistlist = Split (att_value, D3)
              foreach (olt, objlistlist) {
                // Need to remove the "Object: " that will preceed each entry, and turn the string entry into the actual object before re-adding to list. We put it into the following "if" statement in order to exclude the blank list entry that gets created at the end of the list by loading
                if (StartsWith(olt,"Object: ")) {
                  value = GetObject(Right(olt,LengthOf(olt)-LengthOf("Object: ")))
                  if (not value=null) {
                    list add (att_value_obj, value)
                  }
                  else {
                    msg ("WARNING! Object \""+olt+"\" detected in saved objectlist \""+fullname+"\" does not exist! Object \""+olt+"\" not added to list! Loaded game may not work properly!")
                    SkippedAttList = SkippedAttList+"Objectlist '"+fullname+"' item: "+olt+"<br>"
                  }
                }
              }
            }
          }
          else if (att_datatype="stringdictionary") {
            if (att_value="") {
              att_value_obj = NewStringDictionary()
            }
            else {
              att_value_obj = NewStringDictionary()
              // Add dictionary values from SaveGame
              dictrows = Split(att_value, ";")
              foreach (kv, dictrows) {
                if (DebugMode) {
                  msg ("StringDict '"+fullname+"' key-value: "+ToString(kv))
                }
                KeyValList = Split(kv," = ")
                key = ListItem(KeyValList, 0)
                value = ListItem(KeyValList, 1)
                DictionaryAdd (att_value_obj, key, value)
              }
            }
          }
          else if (att_datatype="objectdictionary") {
            if (att_value="") {
              att_value_obj = NewObjectDictionary()
            }
            else {
              att_value_obj = NewObjectDictionary()
              dictrows = Split(att_value, ";")
              foreach (kv, dictrows) {
                if (DebugMode) {
                  msg ("ObjDict  '"+fullname+"' key-value: "+ToString(kv))
                }
                KeyValList = Split(kv," = ")
                key = ListItem(KeyValList, 0)
                obj = ListItem(KeyValList, 1)
                if (StartsWith(obj,"Object: ")) {
                  value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
                }
                else {
                  value = obj
                }
                if (not value=null) {
                  DictionaryAdd (att_value_obj, key, value)
                }
                else {
                  msg ("WARNING! Object \""+obj+"\" detected in saved objectdictionary \""+fullname+"\" does not exist! Object \""+obj+"\" not added to dictionary! Loaded game may not work properly!")
                  SkippedAttList = SkippedAttList+"Objectdictionary '"+fullname+"' item: "+olt+"<br>"
                }
              }
            }
          }
          if (objectname=GamePOVName and attributename="parent") {
            // Check that the attribute is NOT game.pov.parent. If so, we want to make sure that gets updated last
            Skip_Att = True
            GamePOVParent = att_value_obj
          }
          // Make sure the object you are trying to add/update the attribute to exists, otherwise you'd get an error trying to update/create its attribute. If the attribute doesn't exist but the object does, then this function will create it. Also, don't update the game.version or game.pov: The game.version should not be updated from the savecode if you're loading from a previous version, and the game.pov is updated last.
          if (not Equal(object,null) and not Equal(att_value_obj,null) and not fullname="game.gameid" and not fullname="game.version" and not fullname="game.pov" and not Skip_Att=True) {
            if (Equal(preload_att_value,null)) {
              if (DebugMode) {
                msg ("<br>ATTENTION: Attribute '"+fullname+"' does NOT exist in current game, but its parent object '"+objectname+"' does, so attribute will be created!<br>")
              }
              preload_att_value = "null"
            }
            // Msgs for debugging:
            if (DebugMode) {
              msg ("objectname="+objectname)
              msg ("attributename="+attributename)
              msg ("att_datatype="+att_datatype)
              msg ("preload_att_value="+ToString(preload_att_value))
              if (Equal(preload_att_value,"null")) {
                msg ("preload_att_datatype=null")
              }
              else {
                msg ("preload_att_datatype="+TypeOf(preload_att_value))
              }
              msg ("att_value="+ToString(att_value))
              msg ("att_value_obj="+ToString(att_value_obj))
              msg ("isEqual att_value=preload_att_value?: "+ToString(Equal(att_value,preload_att_value)))
              msg ("isEqual att_value_obj=preload_att_value?: "+ToString(Equal(att_value_obj,preload_att_value)))
              msg ("isEqual ToString(att_value_obj)=ToString(preload_att_value)?: "+ToString(Equal(ToString(att_value_obj),ToString(preload_att_value))))
              msg ("<br>")
            }
            // If attributes are already equal to those in the savecode, no need to change them. Else, change 'em.
            if (not Equal(att_value,preload_att_value) and not Equal(att_value_obj,preload_att_value) and not Equal(ToString(att_value_obj),ToString(preload_att_value))) {
              if (DebugMode) {
                msg ("Updating attribute: "+fullname+"<br><br>")
              }
              // Check if attribute has an associated change script. If so, this section will make sure that setting the attribute on load WON'T activate its associate turnscript
              cha = "changed" + attributename
              if (HasAttribute (object, cha)) {
                // If the attribute DOES have an associated change script, temporarily blank it out so it does not execute during loading
                scr = GetAttribute (object, cha)
                set (object, cha, bla)
              }
              // Update the attributes in the game with those from the SaveCode...
              if (att_datatype="boolean") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="int") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="double") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="object") {
                set (object, attributename, att_value_obj)
              }
              else if (att_datatype="stringlist" or att_datatype="objectlist" or att_datatype="stringdictionary" or att_datatype="objectdictionary") {
                // NOTE TO DEVELOPER:
                // Alter the following logic below to fit your needs. Especially important to make sure this works properly for YOUR game for compatibility between game versions!
                // If ReplaceContents = True, then any list or dictionary in your game will be COMPLETELY REPLACED by its corresponding list/dictionary from the savecode.
                // If ReplaceContents = False, then the list/dictionary contents in the SaveCode will be ADDED to the existing corresponding list/dictionary. NOTE: When adding to an existing list/dict, the code, as-written, will REMOVE ANY DUPLICATES from the lists/dictionaries! ALSO, be careful where you allow LoadGame() to be called in cases where ReplaceContents=False, ESPECIALLY if the list/dict contents can change through the course of the game! Calling this LoadGameCode function only from a titlescreen (before any lists/dictionaries have changed), for instance, may be one possible way to account for this.
                // ReplaceContents=True by default, but this may not be desirable in all cases (i.e. if you updated the contents of a permanent dictionary/list between versions), so it is up to YOU to ensure this section behaves as you want it to. Remember that the "object" and "attributename" variables exist at this point to call out specific list/dictionary objects.
                if (upgradesave = True) {
                  // This section will trigger if the player is loading a save from a previous game version
                  ReplaceContents = True
                }
                else {
                  // If this savecode is NOT coming from a previous game version, then I assume it is safe to completely replace the existing dictionary with the saved one.
                  ReplaceContents = True
                }
                if (att_datatype="stringlist") {
                  if (ReplaceContents = True) {
                    // Completely replace stringlist contents with those found in the SaveCode
                    set (object, attributename, att_value_obj)
                  }
                  else {
                    // Add the contents of the saved stringlist TO the existing stringlist in-game
                    FinalList = NewStringList()
                    // Retrieve the contents of the existing list
                    PreLoadList = preload_att_value
                    CombinedList = ListCombine (PreLoadList, att_value_obj)
                    // Remove duplicates
                    CompactList = ListCompact (CombinedList)
                    // CompactList will be a generic "list" type object, need to convert it back to a stringlist...
                    foreach (olt, CompactList) {
                      list add (FinalList, olt)
                    }
                    set (object, attributename, FinalList)
                  }
                }
                else if (att_datatype="objectlist") {
                  if (ReplaceContents = True) {
                    // Completely replace objectlist contents with those found in the SaveCode
                    set (object, attributename, att_value_obj)
                  }
                  else {
                    // Add the contents of the saved objectlist TO the existing objectlist in-game
                    // Retrieve the contents of the existing list
                    PreLoadList = preload_att_value
                    CombinedList = ListCombine (PreLoadList, att_value_obj)
                    // Remove duplicates
                    FinalList = ObjectListCompact (CombinedList)
                    set (object, attributename, FinalList)
                  }
                }
                else if (att_datatype="stringdictionary") {
                  if (ReplaceContents = True) {
                    // Then completely overwrite existing stringdictionary contents with those in the savecode
                    set (object, attributename, att_value_obj)
                    Dummy = NewStringDictionary()
                  }
                  else {
                    // Add the contents of the saved stringdictionary TO the existing stringdictionary in-game
                    Dummy = preload_att_value
                    // Add dictionary values from SaveGame
                    dictrows = Split(att_value, ";")
                    foreach (kv, dictrows) {
                      KeyValList = Split(kv," = ")
                      key = ListItem(KeyValList, 0)
                      value = ListItem(KeyValList, 1)
                      DictionaryAdd (Dummy, key, value)
                    }
                    set (object, attributename, Dummy)
                  }
                }
                else if (att_datatype="objectdictionary") {
                  if (upgradesave = False) {
                    // Then completely overwrite existing objectdictionary contents with those in the savecode
                    set (object, attributename, att_value_obj)
                  }
                  else {
                    // Add the contents of the saved objectdictionary TO the existing objectdictionary in-game
                    Dummy = preload_att_value
                    // Add dictionary values from SaveGame
                    dictrows = Split(att_value, ";")
                    foreach (kv, dictrows) {
                      KeyValList = Split(kv," = ")
                      key = ListItem(KeyValList, 0)
                      value = ListItem(KeyValList, 1)
                      if (StartsWith(value,"Object: ")) {
                        value = GetObject(Right(value,LengthOf(value)-LengthOf("Object: ")))
                      }
                      DictionaryAdd (Dummy, key, value)
                    }
                    set (object, attributename, Dummy)
                  }
                }
              }
              else if (att_datatype="string") {
                set (object, attributename, att_value)
              }
              else {
                error ("ERROR: Unsupported object type detected in SaveCode: "+fullname+" of "+att_datatype+" datatype.")
              }
              if (HasAttribute (object, cha)) {
                // If a change script exists for this attribute, set change script back to original value after attribute has been changed
                set (object, cha, scr)
                scr => {
                }
              }
            }
          }
        }
        // Extract and update map data from saved grid_coordinates. Because grid_coordinates is a dictionary of dictionaries, it needed to be saved in a special way. Thus, it needs to be loaded in a special way as well.
        // If D1=|,D2=$,D3=;,and D4=@, then grid_coordinates will be saved in form: "ObjectOwner$MapAttributeName&%&Key1$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;|Key2$Lkey1 = Lvalue1:type;Lkey2 = Lvalue2:type;Lkey3 = Lvalue3:type|" etc.
        AllSavedGrids = Split(GridGInfo,D4)
        if (DebugMode) {
          msg ("<br>AllSavedGrids: "+ToString(AllSavedGrids))
        }
        foreach (A, AllSavedGrids) {
          UDictionary = NewDictionary()
          ItemAndValue = Split(A,"&%&")
          ObjAndAtt = ListItem(ItemAndValue,0)
          ObjAndAtt = Split(ObjAndAtt,D2)
          objectname = ListItem(ObjAndAtt,0)
          attributename = ListItem(ObjAndAtt,1)
          object = GetObject(objectname)
          GridVals = ListItem(ItemAndValue,1)
          GridVals = Split(GridVals,D1)
          foreach (B, GridVals) {
            UKeyAndUVal = Split(B,D2)
            UKey = ListItem(UKeyAndUVal,0)
            UVal = ListItem(UKeyAndUVal,1)
            UVal = Split(UVal,D3)
            LDictionary = NewDictionary()
            foreach (C, UVal) {
              LkeyAndLval = Split(C," = ")
              Lkey = ListItem(LkeyAndLval,0)
              LvalAndType = ListItem(LkeyAndLval,1)
              LvalAndType = Split(LvalAndType,":")
              Lval_str = ListItem(LvalAndType,0)
              LType = ListItem(LvalAndType,1)
              if (LType="int") {
                Lval = ToInt(Lval_str)
              }
              else if (LType="double") {
                Lval = ToDouble(Lval_str)
              }
              else if (LType="boolean") {
                if (Lval_str="True") {
                  Lval = True
                }
                else {
                  Lval = False
                }
              }
              else {
                error ("ERROR: Unsupported datatype found in "+objectname+"."+attributename+"! Datatype '"+LType+"' not supported!")
              }
              DictionaryAdd (LDictionary, Lkey, Lval)
            }
            DictionaryAdd (UDictionary, UKey, LDictionary)
          }
          if (DebugMode) {
            msg ("<br>"+objectname+"."+attributename+" UDictionary: "+ToString(UDictionary))
          }
          set (object, attributename, UDictionary)
        }
        // Destroy any objects that the player destroyed during their saved game, if any
        if (ListCount(DestroyedList)>0) {
          foreach (o, DestroyedList) {
            // Check that objects still exist...
            IsThere = GetObject(o)
            if (not Equal(IsThere,null)) {
              // If its there, destroy the object
              destroy (o)
              DestroyedObjDebugList = DestroyedObjDebugList+o+"<br>"
            }
          }
        }
        if (DebugMode) {
          msg ("Created objects: "+CreatedObjDebugList)
          msg ("Destroyed objects: "+DestroyedObjDebugList)
          msg ("Skipped Attributes:<br>"+SkippedAttList)
        }
        // Finally, update game.pov.parent and game.pov
        set (GamePOVObject, "parent", GamePOVParent)
        game.pov = GamePOVObject
        // player.grid_coordinates = null
        JS.Grid_ClearAllLayers ()
        Grid_Redraw
        Grid_DrawPlayerInRoom (game.pov.parent)
        ClearScreen
        ShowRoomDescription
      }
    }
  ]]></function>
  <function name="GetSaveGameCodeDelims" type="stringlist">
    // GetSaveGameCodeDelims() function that returns the delimiters used by the SaveGameCode function in a stringlist in the order [D1,D2,D3,D4].
    // Useful for getting a list of delimiters to ban from user-entered fields (i.e. "enter your name")
    DelimList = NewStringList()
    SaveString = SaveGameCode(False)
    // Retrieve delimiters from end of SaveString
    Dls = Right(SaveString,4)
    D1 = Mid (Dls, 1, 1)
    D2 = Mid (Dls, 2, 1)
    D3 = Mid (Dls, 3, 1)
    D4 = Mid (Dls, 4, 1)
    // Add to list
    list add (DelimList, D1)
    list add (DelimList, D2)
    list add (DelimList, D3)
    list add (DelimList, D4)
    // Return the list of delimiters as a string list
    return (DelimList)
  </function>
  <function name="GetSaveCheckpointDelims" type="stringlist">
    // GetSaveCheckpointDelims() function that returns the delimiters used by the SaveCheckpoint function in a stringlist in the order [D1,D2,D3,D4].
    // Useful for getting a list of delimiters to ban from user-entered fields (i.e. "enter your name")
    DelimList = NewStringList()
    SaveString = SaveCheckpoint("")
    // Retrieve delimiters from end of SaveString
    Dls = Right(SaveString,4)
    D1 = Mid (Dls, 1, 1)
    D2 = Mid (Dls, 2, 1)
    D3 = Mid (Dls, 3, 1)
    D4 = Mid (Dls, 4, 1)
    // Add to list
    list add (DelimList, D1)
    list add (DelimList, D2)
    list add (DelimList, D3)
    list add (DelimList, D4)
    // Return the list of delimiters as a string list
    return (DelimList)
  </function>
  <javascript src="SaveLoadJavaCode.js" />
</asl>

That is excellent!

The Wiki says this will not work with the online version, but here you say it does. Do you mean it does not work with the online editor, but does with the player? It should be possible to get JavaScript working with the online editor, but I have to admit it is a while since I used it, and cannot remember what the issues are now.

I considered using the SaveLoad library created by Pix (https://github.com/ThePix/quest/wiki/Library:-Save-and-Load), but unfortunately some of its limitations didn't quite work for what I had in mind.

Might be worth saying what the differences are so people can make an informed choice between the two. Looks like one is that yours tries to guess what to save, while mine, the author has to be explicit, which I appreciate is extra work for authors.


Thanks Pix! Your praise means a lot!

The Wiki says this will not work with the online version, but here you say it does. Do you mean it does not work with the online editor, but does with the player?

Yes, I've confirmed that it DOES work with the online player, it's the online editor I'm less sure of. Like you said, whether I can add the custom javascript online is the primary roadblock in determining whether it would be online-editor-compatible.

I tried last night but I was unable to get the online editor running to figure out whether I could get the javascript added, but my assumption (based on the "Using Javascript" quest documentation) was that the online editor and custom javascript might not mix well, though I hope I'm wrong!

If I can get the online editor running later today, I'll dig a bit more to see if there's a way to get the javascript in the online editor. I'd love for it to work in the online editor as well, so I'll happily update the wiki and this post if I figure out how to do so!

Might be worth saying what the differences are so people can make an informed choice between the two. Looks like one is that yours tries to guess what to save, while mine, the author has to be explicit, which I appreciate is extra work for authors

Also yes, that's a good idea. I'll go ahead and updated the above post to include that info!

I apologize if my post came off as me trying to say my library was better, because that's certainly not my intent. I think both our save/load libraries are great depending on what the user wants to do. Calling out attributes explicitly in yours, rather than procedurally in mine, for instance, certainly makes it a lot easier to be sure only certain attributes are updated when saving. I also feel like your library might be a bit more user-friendly to those uncomfortable/unfamiliar with coding, as adapting my functions to save more attributes generally requires editting the functions directly.

One of the biggest differences that led me to try making my own library was that I wanted the ability to create a save and store it as a separate file to the user's computer, both in the desktop and online player. I spent a while trying to figure out how to get Quest to create this file on it's own (I dug deep into KV's Log function source code and forum posts to try to see if I could come up with something) before eventually settling on having the user copy/paste the save data themselves.
I also plan on changing the game.pov several times in my game, so I wanted to be sure I accounted for that as well.

After work, I'll dig into both the online editor and our two libraries and update the post/wiki to capture what I find!


I had a quick look through the code… looks like you've got quite a lot of different delimiters. I tried doing this a while back, and rather than sticking to fixed delimiters, decided to prefix each value with a base64 number for its length. Works out surprisingly simple for some things (like allowing more complex list/dictionary structures; which I tend to use for a lot of things)

Do you have any plans to add support for created/destroyed objects?


I tried doing this a while back, and rather than sticking to fixed delimiters, decided to prefix each value with a base64 number for its length.

Ah! This is a great idea! Unfortunately I'm in too deep with my delimiters to change it now, but if I could go back in time I might have ended up going with this instead.

Do you have any plans to add support for created/destroyed objects?

Honestly, I could probably add support for created objects very easily. The function already makes a check to see if an object found in the SaveString is present in the current game (if it isn't then object will equal null), so all it would take is adding an else if (object=null or preload_att_value=null) {Create object} statement onto that logic.

Destroyed objects would be a little trickier, however, since if they aren't in the SaveString, that could mean they were destroyed OR excluded intentionally. I could probably come up with something where I have LoadGameCode generate a SaveString for the current game, then compare the pre-load SaveString to the one that's being loaded to determine which objects are missing and should be destroyed.

The bigger issue, however, is that I could only really modularize create/destroy support for saves of the SAME game version. If an object exists in a SaveString and doesn't exist in the game at time of load, then you can only assume that the object should exist (and thus the function should create it) if both the save and the game you're loading to are the same version. If you're loading an old save into a new game version, that same object might not exist because it was intentionally removed in the latest version. Similarly, if an object doesn't exist in the SaveString but DOES exist in a newer version of the game, we certainly don't want the function to destroy all the new objects we introduced with the latest version.

If you think it'd be worthwhile, I could add a flag to LoadGameCode to allow create/destroy support for loading when the versions are equal, but I'd have to think long and hard to figure out how to properly apply modular support for created/destroyed objects that still allows for old-save-compatibility.

This might be an example where editing the function to fit the conditions of your specific game may be easier than trying to come up with a modular solution at all... Definitely something to think about, though!


The bigger issue, however, is that I could only really modularize create/destroy support for saves of the SAME game version. If an object exists in a SaveString and doesn't exist in the game at time of load, then you can only assume that the object should exist (and thus the function should create it) if both the save and the game you're loading to are the same version. If you're loading an old save into a new game version, that same object might not exist because it was intentionally removed in the latest version. Similarly, if an object doesn't exist in the SaveString but DOES exist in a newer version of the game, we certainly don't want the function to destroy all the new objects we introduced with the latest version.

My approach to that was to have an initialisation script that makes a list of the names of all objects when the game starts. When saving, start by comparing that list to the objects that currently exist. So you're explicitly saving a list of objects that have been created/destroyed between game start and game saved; rather than comparing objects that exist in the old and new versions.

This also means that you don't run into problems where an objectlist somewhere contains a newly created object – because you can create all the new objects at the start, ensuring they exist before adding them to other attributes.

(I went way too deep down this rabbithole; building a serialised string of all objects at the start of the game, and then having the savestring only encode the ones that had changed when saving. That probably adds too much to the complexity for most uses, although it can shrink the resulting save string. With regard to making the string smaller, I tried using a library called LZString.js so that the save string is compressed before converting to base64 (or to UTF16; you can reduce the output string length by about 60% if you use an encoding scheme that includes Chinese characters instead of just ASCII)


My approach to that was to have an initialisation script that makes a list of the names of all objects when the game starts. When saving, start by comparing that list to the objects that currently exist. So you're explicitly saving a list of objects that have been created/destroyed between game start and game saved; rather than comparing objects that exist in the old and new versions.

This... could actually work quite well.

There would still be issues if the objects that can be created/destroyed during gameplay changes between game versions, but that would be much easier for the author to keep track of when making edits for compatibility...

Let me try something real quick!


I also handled game updates when increasing the version; I made a scriptdictionary where the keys are version numbers. When loading a save, it would do something like:


found = false
foreach (key, game.loadscripts) {
  if (version_from_save_file = key) {
    found = true
  }
  if (found) {
    invoke (ScriptDictionaryItem (game.loadscripts, key))
  }
}

That way, you have a dictionary that looks something like:

<loadscripts type="scriptdictionary">
  <item key="1">
    // This script updates a v1 save to v2
  </item>
  <item key="2">
  </item>
  <item key="3">
    // This script updates a v2 or v3 save to v4
  </item>
  <item key="4">
    // As v4 is the current version, this script can do anything that needs to happen when loading a saved game
    mag ("Welcome back! Here's a reminder of your current objectives:")
    DisplayCurrentSidequests()
  </item>
</loadscripts>

May seem a little over-engineered, but I think that progressive updates like this would make it easier for a developer to keep track of all the different changes that might have been made to the game.


Using a script dictionary to help organize and stagger version compatibility is a great idea! I may end up using that idea to maintain compatibility between my own game's versions.

I'm a bit fried right now, so I may or may not consider building something like that into the library itself, but I may just leave it as an exercise for the author. With your permission, may I add it as a possible suggestion/example to the "Tips for Compatibility" section in the library's wiki?

In other news, I've updated the SaveLoadCode library to v2.0. It can now handle created/destroyed objects! I've updated the original post and the test game as well to show off the new feature. Thanks for the idea, mrangel!


Hello again! Made one more update (v3.0 now) to the library to add in some more functionality. Namely:

  • Support for new game list attributes to help the author save game attributes and timers (see wiki for more details):
    • game.SaveAtts
    • game.SaveTimers
      Now the player can simply add the names of additional game attributes they would like to save to game.SaveAtts and the SaveGameCode/SaveCheckpoint functions will save them automatically. Similarly, the player can add the names of any timers created to a game.SaveTimers attribute to save the status of their timers.
  • Added additional validation checks to ensure that no banned delimiters are present in list or dictionary entries at time of saving.

  • SaveGameCode now has a ShowCodePopupFlag boolean input parameter. When SaveGameCode(True) is called, the SaveCode popup box will appear as before. When SaveGameCode(False) is called, the popup box will NOT appear.

  • SaveGameCode now returns the SaveString as an output. (i.e. If you set X=SaveGameCode(False) or X=SaveGameCode(True), then X will equal the generated SaveString before it gets encoded to base64)

  • New SaveCheckpoint(CheckpointName) and LoadCheckpoint(CheckpointName) functions! These functions allow you to save and load local checkpoints. See wiki for more details.

  • After adding checkpoints, the savecodes increased drastically in size, so I decided to take mrangel's advice and compress the SaveCode. The created SaveCode is now compressed (using excerpt from LZ-string library) to cut down on the length/filesize of the SaveString. This is especially useful now since adding the SaveCheckpoint function (as the savecode filesize would increase drastically with each checkpoint saved without compression).

  • New GetSaveGameCodeDelims() function. This function returns a stringlist of delimiters used by the SaveGameCode function. Useful for allowing you to check if user-entered input (i.e. like a "type in your name" input) contains a banned delimiter that would cause issues saving later.

  • New GetSaveCheckpointDelims() function. This function returns a stringlist of delimiters used by the SaveCheckpoint function. Useful for allowing you to check if user-entered input (i.e. like a "type in your name" input) contains a banned delimiter that would cause issues saving later.

I feel like the library is now in a pretty good state functionality-wise, so this will likely be my last update for a while. Now that it's stable I will probably make a post over in the Libraries and Code Samples forum for posterity. Any additional updates to this library will likely be posted there, instead of here.


This topic is now closed. Topics are closed after 60 days of inactivity.

Support

Forums