Quick answer: Use the LocalStorage plugin with custom JSON data for save files, not the built-in System Save/Load for most games. Include a version number in every save for migration, use key naming conventions for multiple slots, and always handle storage errors since browser storage can be limited or cleared.

Construct 3 runs on web technology, which means your save system has to work within browser storage constraints. The built-in save system captures everything, custom JSON saves capture exactly what you choose. This guide covers the best approach for each situation and the practices that keep save data reliable across platforms.

Built-in Save System vs Custom Saves

Construct 3’s built-in System Save and System Load actions capture the entire project state—every object instance, variable, and layout property. This is convenient for prototypes but has significant drawbacks for production games:

• Save files are large because they include everything, not just game-critical data

• You cannot easily version or migrate the save format between updates

• Adding or removing objects in an update can break old saves

For most games, build a custom save system using the LocalStorage plugin and JSON. This gives you full control over what gets saved, how large the save file is, and how you handle version migration.

LocalStorage for Custom Saves

The LocalStorage plugin stores key-value pairs in the browser’s IndexedDB. All operations are asynchronous, so you need to use the “On item set” and “On item get” triggers to handle completions.

In event sheets, the pattern looks like:

Saving:

1. Build a JSON string from your game variables using the JSON object

2. Use LocalStorage → Set item with a key like "save_slot_0" and the JSON string as the value

3. Handle On item set to confirm the save succeeded

4. Handle On error to catch storage failures

Loading:

1. Use LocalStorage → Get item with the save key

2. Handle On item get to receive the stored value

3. Parse the JSON string back into data using the JSON object

4. Apply the loaded values to your game variables and objects

JSON Save Structure with Scripting

If you use Construct 3’s scripting feature, you get more control over save data construction:

// In a script
function buildSaveData() {
    return {
        version: 1,
        timestamp: new Date().toISOString(),
        player: {
            x: runtime.globalVars.PlayerX,
            y: runtime.globalVars.PlayerY,
            health: runtime.globalVars.PlayerHealth,
            gold: runtime.globalVars.Gold,
            currentLayout: runtime.layout.name
        },
        inventory: runtime.globalVars.InventoryJSON,
        questFlags: runtime.globalVars.QuestFlagsJSON
    };
}

async function saveGame(slot) {
    const data = buildSaveData();
    const json = JSON.stringify(data);
    const key = "save_slot_" + slot;

    try {
        await runtime.storage.setItem(key, json);
        console.log("Save complete: " + key);
        return true;
    } catch (e) {
        console.error("Save failed: " + e.message);
        return false;
    }
}

async function loadGame(slot) {
    const key = "save_slot_" + slot;

    try {
        const json = await runtime.storage.getItem(key);
        if (!json) return null;

        const data = JSON.parse(json);
        return migrateSave(data);
    } catch (e) {
        console.error("Load failed: " + e.message);
        return null;
    }
}

Save Versioning

Always include a version number. When your save format changes between updates, use the version to migrate old saves forward:

function migrateSave(data) {
    let version = data.version || 0;

    if (version < 1) {
        // v0 -> v1: renamed "coins" to "gold"
        if (data.player.coins !== undefined) {
            data.player.gold = data.player.coins;
            delete data.player.coins;
        }
    }

    if (version < 2) {
        // v1 -> v2: added quest flags
        if (!data.questFlags) {
            data.questFlags = "[]";
        }
    }

    data.version = 2;
    return data;
}

Multiple Save Slots

Use a consistent key naming convention in LocalStorage. Each slot gets its own key:

// Keys: "save_slot_0", "save_slot_1", "save_slot_2", "save_autosave"

async function getSlotInfo(slot) {
    const key = "save_slot_" + slot;
    const json = await runtime.storage.getItem(key);
    if (!json) return { empty: true };

    const data = JSON.parse(json);
    return {
        empty: false,
        timestamp: data.timestamp,
        level: data.player.currentLayout,
        gold: data.player.gold
    };
}

async function deleteSave(slot) {
    await runtime.storage.removeItem("save_slot_" + slot);
}

Storage Limits and Error Handling

Browser storage is not unlimited. IndexedDB typically allows 50 MB or more per origin, but the browser may evict data under storage pressure, especially in private browsing mode. Always handle storage errors:

Catch write failures: If setItem throws or triggers an error event, show a clear message to the player. Do not silently lose their save.

Keep saves compact: Only save the data you need to reconstruct game state. Avoid saving derived values that can be recalculated.

Warn about private browsing: In some browsers, storage is cleared when the private browsing session ends. If you can detect private mode, warn the player.

Desktop Exports with NW.js

For NW.js desktop exports, you can write save files directly to disk using Node.js filesystem APIs. This avoids browser storage limits and gives you real files the player can back up.

// In a Construct 3 script (NW.js export only)
const fs = require("fs");
const path = require("path");

function getSaveDir() {
    const appData = process.env.APPDATA
        || process.env.HOME + "/.local/share";
    const dir = path.join(appData, "MyGameName", "saves");
    if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
    }
    return dir;
}

function saveToFile(slot, data) {
    const dir = getSaveDir();
    const filePath = path.join(dir, "slot_" + slot + ".json");
    const tmpPath = filePath + ".tmp";
    const json = JSON.stringify(data, null, 2);

    fs.writeFileSync(tmpPath, json, "utf8");
    fs.renameSync(tmpPath, filePath);
}

Cloud Saves with AJAX

For cross-device save syncing, send save data to a server using the AJAX plugin or the Fetch API in scripting:

async function cloudSave(data) {
    const response = await fetch("/api/saves", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data)
    });
    return response.ok;
}

async function cloudLoad() {
    const response = await fetch("/api/saves");
    if (!response.ok) return null;
    return response.json();
}

Always keep a local copy as the primary save and treat cloud sync as a backup. Network failures should never prevent the player from saving their game.

Autosave

Use Construct 3’s Timer behavior or the Every X Seconds condition to trigger regular autosaves. Also save when the player transitions between layouts or hits key milestones.

In your event sheet:

Every 300 seconds: Call your save function with the autosave slot

On layout change: Save before navigating to the next layout

On visibilitychange (scripting): Save when the browser tab loses focus, since the player might close the tab

// Save when the tab loses focus
document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
        saveGame("autosave");
    }
});

Related Issues

For debugging save corruption across all engines, see How to Debug Game Save Corruption Bugs. For general error logging best practices, read Best Practices for Game Error Logging.

Browser storage can be cleared by the user at any time. Always tell players where their saves live, warn them about private browsing, and offer a manual save export option for your most dedicated players.