Quick answer: Use structs with json_stringify() and json_parse() for save data, write to the sandbox directory, include a version number for migration, and write to a temp file before renaming to prevent corruption.
GameMaker gives you several ways to save data, from INI files to JSON to raw buffers. Choosing the wrong approach or skipping basic safety measures leads to corrupted saves, lost progress, and frustrated players. This guide covers the best practices for building a reliable save system in GameMaker.
Use JSON Structs, Not ds_map
Modern GameMaker (2.3+) supports structs and the json_stringify() / json_parse() functions. This is the recommended approach for save data. The older ds_map and ds_list functions are legacy—they require manual destruction to avoid memory leaks and produce less readable JSON.
// Build save data as a struct
var _save_data = {
version: 1,
timestamp: date_datetime_string(date_current_datetime()),
player: {
x: obj_player.x,
y: obj_player.y,
hp: obj_player.hp,
gold: global.gold,
current_room: room_get_name(room)
},
inventory: global.inventory,
quests_completed: global.quests_completed
};
// Convert to JSON string
var _json = json_stringify(_save_data);
Structs are garbage collected automatically, so you do not need to worry about memory leaks. They also support nested structures, arrays, and all the basic types you need for save data.
The Save Directory
GameMaker uses a sandboxed filesystem. When you write to a filename like "save.json", it goes to a platform-specific directory automatically:
Windows: %LocalAppData%\<game_name>\
macOS: ~/Library/Application Support/<bundle_id>/
Ubuntu: ~/.config/<game_name>/
HTML5: Browser localStorage (5–10 MB limit)
You do not need to (and should not) specify absolute paths. Just use filenames directly and let GameMaker handle the platform differences. Use game_save_id if you need to reference the actual directory path for debugging.
Saving and Loading
Here is a complete save and load pattern with error handling:
/// @function save_game(slot)
function save_game(_slot) {
var _save_data = gather_save_data();
var _json = json_stringify(_save_data);
var _filename = "save_" + string(_slot) + ".json";
var _tmp_filename = _filename + ".tmp";
var _bak_filename = _filename + ".bak";
// Write to temp file first
var _file = file_text_open_write(_tmp_filename);
if (_file == -1) {
show_debug_message("Save failed: cannot open temp file");
return false;
}
file_text_write_string(_file, _json);
file_text_close(_file);
// Backup current save
if (file_exists(_filename)) {
file_copy(_filename, _bak_filename);
}
// Atomic rename
if (file_exists(_filename)) {
file_delete(_filename);
}
file_rename(_tmp_filename, _filename);
show_debug_message("Save complete: " + _filename);
return true;
}
/// @function load_game(slot)
function load_game(_slot) {
var _filename = "save_" + string(_slot) + ".json";
if (!file_exists(_filename)) {
// Try backup
var _bak = _filename + ".bak";
if (file_exists(_bak)) {
_filename = _bak;
} else {
show_debug_message("No save file found");
return undefined;
}
}
var _file = file_text_open_read(_filename);
var _json = "";
while (!file_text_eof(_file)) {
_json += file_text_read_string(_file);
file_text_readln(_file);
}
file_text_close(_file);
var _data = json_parse(_json);
if (!is_struct(_data)) {
show_debug_message("Save file corrupted");
return undefined;
}
return migrate_save(_data);
}
Save File Versioning
Always include a version number in your save data. When you add new fields, rename variables, or restructure your save format, the version number lets you detect old saves and migrate them.
/// @function migrate_save(data)
function migrate_save(_data) {
var _version = struct_exists(_data, "version") ? _data.version : 0;
if (_version < 1) {
// v0 -> v1: renamed "coins" to "gold"
if (struct_exists(_data.player, "coins")) {
_data.player.gold = _data.player.coins;
struct_remove(_data.player, "coins");
}
}
if (_version < 2) {
// v1 -> v2: added difficulty with default
if (!struct_exists(_data, "difficulty")) {
_data.difficulty = "normal";
}
}
_data.version = 2;
return _data;
}
INI Files for Settings
For player settings like audio volume, screen resolution, and control preferences, INI files are a simpler option than JSON. They are easy to edit manually and work well for flat key-value data.
/// @function save_settings()
function save_settings() {
ini_open("settings.ini");
ini_write_real("audio", "master_volume", global.master_volume);
ini_write_real("audio", "music_volume", global.music_volume);
ini_write_real("audio", "sfx_volume", global.sfx_volume);
ini_write_real("video", "fullscreen", global.fullscreen);
ini_write_real("video", "window_scale", global.window_scale);
ini_close();
}
/// @function load_settings()
function load_settings() {
if (!file_exists("settings.ini")) return;
ini_open("settings.ini");
global.master_volume = ini_read_real("audio", "master_volume", 1.0);
global.music_volume = ini_read_real("audio", "music_volume", 0.8);
global.sfx_volume = ini_read_real("audio", "sfx_volume", 1.0);
global.fullscreen = ini_read_real("video", "fullscreen", 0);
global.window_scale = ini_read_real("video", "window_scale", 2);
ini_close();
}
The third argument to ini_read_real and ini_read_string is a default value. This means you can safely add new settings in updates without breaking existing settings files.
Multiple Save Slots
Use a file-per-slot naming convention. Create a function that lists available slots by checking which files exist:
/// @function get_slot_info(slot)
function get_slot_info(_slot) {
var _filename = "save_" + string(_slot) + ".json";
if (!file_exists(_filename)) {
return { empty: true };
}
var _data = load_game(_slot);
return {
empty: false,
timestamp: _data.timestamp,
room_name: _data.player.current_room,
gold: _data.player.gold
};
}
/// @function delete_save(slot)
function delete_save(_slot) {
var _filename = "save_" + string(_slot) + ".json";
if (file_exists(_filename)) file_delete(_filename);
var _bak = _filename + ".bak";
if (file_exists(_bak)) file_delete(_bak);
}
Autosave
Use an alarm or a step counter in a persistent controller object to trigger autosaves on a regular interval. Also save on key events like room transitions or boss defeats.
// obj_save_controller — Create Event
alarm[0] = game_get_speed(gamespeed_fps) * 300; // 5 minutes
// obj_save_controller — Alarm 0 Event
save_game(0); // Autosave to slot 0
alarm[0] = game_get_speed(gamespeed_fps) * 300; // Reset timer
show_debug_message("Autosave complete");
Make the controller object persistent so it survives room transitions. On mobile, also save in the Game End event and the Lose Focus event, because the OS can kill your app at any time after it goes to the background.
Platform Differences
HTML5: GameMaker uses localStorage for the HTML5 target, which has a 5–10 MB limit depending on the browser. Keep save files small. The data can be cleared by the player or by browser privacy settings.
Mobile: Save data goes to the app’s sandboxed storage. On Android, clearing app data deletes saves. On iOS, the app sandbox is backed up to iCloud. For critical saves on mobile, consider cloud save via HTTP requests.
Consoles: Each console platform has its own save data API requirements, including size limits and mandatory save indicators. Check the platform-specific GameMaker documentation for details.
Related Issues
For debugging save corruption across all engines, see How to Debug Game Save Corruption Bugs. For general error logging practices, read Best Practices for Game Error Logging.
Use structs and json_stringify for saves, INI files for settings, and always write to a temp file first. Test your save system on every export target before release, especially HTML5 where storage limits can silently truncate your data.