Create your own
Lesson illustration

Save/Load System with Multiple Slots

Hello! Welcome to the next lesson in our JRPG creation course.

In our last session, we designed and implemented the battle UI, bringing our combat system to life with status displays, command menus, and a targeting cursor. By breaking the complex interface into manageable components, we created a robust and data-driven system.

Today, we address a fundamental feature of any long-form game: persistence. A player's journey can span dozens of hours, and they need a way to save their progress. Our learning outcome is to implement a save/load screen that interacts with multiple save slots. This involves not just creating a UI, but also designing an underlying architecture to handle the serialization and storage of the entire game state.

1. The Architecture of a Modern Save System

Before we write any UI code, let's establish a robust architecture for our persistence layer. Your experience in front-end development has shown the value of separating concerns, such as keeping state management distinct from the view layer. We will apply the same principle here, creating a system that is decoupled, testable, and easy to extend.

A common and powerful pattern involves three main components:

  1. A Persistence Manager: A central, singleton class that orchestrates the entire save/load process. It doesn't know how to save to a file or a database; it only knows when to trigger a save or load operation.
  2. A Data Handler: A dedicated service responsible for the low-level operations of reading from and writing to a storage medium (in our case, the browser's localStorage). It handles the serialization (e.g., converting our game state to a JSON string) and deserialization.
  3. A Savable "Interface": A contract that any object wanting to persist its data must follow. In JavaScript, this will be a conceptual interface: we'll define saveData() and loadData() methods that the Persistence Manager can call on any object that needs to save its state.

This separation means if we ever wanted to switch from localStorage to a cloud-based solution, we would only need to change the DataHandler, leaving the rest of our game logic untouched.

How to make a Save & Load System in Unity

The video 'How to make a Save & Load System in Unity' by Shaped by Rain Studios provides an excellent overview of this exact architecture. While it's in C# for Unity, the design patterns are universal and directly applicable to our JavaScript engine.

Please watch the 'System Architecture Overview' from 00:35 to 02:28. Pay close attention to the roles of the 'Data Persistence Manager', the 'IDataPersistence' interface, and the 'File Data Handler'. This is the blueprint we will follow.

2. Implementing the Core Persistence Logic

Let's translate this architecture into JavaScript.

The GameData Object

First, we need a single object that represents a snapshot of everything we need to save. In our previous lessons, we've been mutating a global gameState object. For saving, we'll create a plain GameData class that will hold a clean copy of this state.

// /state/GameData.js
export class GameData {
    constructor() {
        // Default values for a new game
        this.currentMap = 'town';
        this.playerPosition = { x: 5, y: 5 };
        this.party = [/* initial party members */];
        this.inventory = { items: {}, gold: 100 };
        this.gameFlags = {}; // For switches and variables
        this.lastUpdated = null;
    }
}

The StorageHandler

This class will interact with localStorage. It will serialize our GameData object into JSON for storage and parse it back on load.

// /core/StorageHandler.js
export class StorageHandler {
    constructor() {
        // We could add options for encryption here later
    }

    save(key, data) {
        try {
            localStorage.setItem(key, JSON.stringify(data));
        } catch (e) {
            console.error('Failed to save data to localStorage:', e);
        }
    }

    load(key) {
        try {
            const data = localStorage.getItem(key);
            return data ? JSON.parse(data) : null;
        } catch (e) {
            console.error('Failed to load data from localStorage:', e);
            return null;
        }
    }
}

Note: The Unity tutorial discusses creating a custom SerializableDictionary because Unity's default JSON utility doesn't handle dictionaries. We don't have this problem; JavaScript's JSON.stringify/JSON.parse handles objects, arrays, and nested structures perfectly, simplifying our implementation.

The PersistenceManager

This is our central orchestrator. It will be a singleton to ensure there's only one instance managing our game's state. It will also keep a list of all objects in the game that need their state saved.

// /core/PersistenceManager.js
import { GameData } from '../state/GameData.js';
import { StorageHandler } from './StorageHandler.js';

class PersistenceManager {
    constructor() {
        if (PersistenceManager.instance) {
            return PersistenceManager.instance;
        }
        this.storageHandler = new StorageHandler();
        this.gameData = null;
        this.savableObjects = []; // Objects that implement save/load methods
        PersistenceManager.instance = this;
    }

    register(savableObject) {
        this.savableObjects.push(savableObject);
    }

    newGame() {
        this.gameData = new GameData();
    }

    loadGame() {
        // For now, loads from a single hardcoded key. We'll change this soon.
        this.gameData = this.storageHandler.load('savegame');
        
        if (!this.gameData) {
            console.log('No save data found. Starting a new game.');
            this.newGame();
        }

        // Push the loaded data to all registered objects
        for (const obj of this.savableObjects) {
            obj.loadData(this.gameData);
        }
    }

    saveGame() {
        if (!this.gameData) return;

        // Pull data from all registered objects into the gameData object
        for (const obj of this.savableObjects) {
            obj.saveData(this.gameData);
        }
        
        // Update timestamp
        this.gameData.lastUpdated = new Date().getTime();

        // Save the collected data
        this.storageHandler.save('savegame', this.gameData);
        console.log('Game saved.');
    }
}

export const persistenceManager = new PersistenceManager();

Now, any important part of our game, like the gameState manager or the Player object, would register itself and implement the loadData and saveData methods. For example:

// In our main game initialization file...
import { persistenceManager } from './core/PersistenceManager.js';
import { gameState } from './state/gameState.js';

// Create a savable wrapper for our global gameState
const gameStateManager = {
    loadData: (data) => {
        // Deep copy data into our live gameState
        Object.assign(gameState, JSON.parse(JSON.stringify(data)));
    },
    saveData: (data) => {
        // Deep copy live state into the data object to be saved
        Object.assign(data, JSON.parse(JSON.stringify(gameState)));
    }
};

persistenceManager.register(gameStateManager);

3. Handling Multiple Save Slots

Our current system saves to a single key. To support multiple slots, we need to introduce the concept of a Profile ID. Each save slot will correspond to a unique ID, and this ID will be part of the key we use in localStorage.

How to Implement Save Slots to Manage Multiple Saved Games in Unity | Tutorial

The follow-up video, 'How to Implement Save Slots', explains this concept perfectly. It shows how to modify the architecture to handle multiple files by using a Profile ID.

Please watch the following sections: 'Overview of Save Slot Architecture' (00:58 - 03:20): Understand how a 'Profile ID' is used to create separate data folders (or, in our case, separate localStorage keys). 'Modifying Data Persistence for Multiple Saves' (04:22 - 06:21): See how the FileDataHandler and DataPersistenceManager are modified to accept this Profile ID. 'Loading All Profiles for Save Slot Menu' (06:21 - 09:14): This is crucial. It details a method to scan all possible save locations to gather data for populating the save/load menu.

Let's apply these changes to our PersistenceManager and StorageHandler.

First, update StorageHandler to handle multiple keys and to scan for all saves.

// /core/StorageHandler.js (updated)
export class StorageHandler {
    // ... save and load methods are fine, they already take a key
    
    loadAll() {
        const allSaves = {};
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            if (key.startsWith('savegame_')) {
                const data = this.load(key);
                if (data) {
                    const profileId = key.split('_')[1];
                    allSaves[profileId] = data;
                }
            }
        }
        return allSaves;
    }
}

Next, update PersistenceManager to use a "selected profile ID".

// /core/PersistenceManager.js (updated)
class PersistenceManager {
    constructor() {
        // ... same as before
        this.selectedProfileId = null;
    }
    
    // ... register is the same
    
    changeSelectedProfileId(profileId) {
        this.selectedProfileId = profileId;
    }
    
    newGame() {
        this.gameData = new GameData();
    }

    loadGame() {
        if (!this.selectedProfileId) {
            console.error("No profile selected to load.");
            return;
        }
        const key = `savegame_${this.selectedProfileId}`;
        this.gameData = this.storageHandler.load(key);
        // ... rest is the same
    }

    saveGame() {
        if (!this.selectedProfileId) {
            console.error("No profile selected to save to.");
            return;
        }
        // ... logic to pull data from savable objects
        const key = `savegame_${this.selectedProfileId}`;
        this.storageHandler.save(key, this.gameData);
        console.log(`Game saved to slot ${this.selectedProfileId}.`);
    }

    getAllProfilesGameData() {
        return this.storageHandler.loadAll();
    }
}
Test your understanding!

Imagine you want to add a "Continue" button to your main menu that automatically loads the most recently played game. Based on the architecture above, how would you implement this?

Show answer
  1. Track Timestamps: Our GameData class and PersistenceManager.saveGame() method already store the lastUpdated timestamp.
  2. Find the Latest: Create a new method in the PersistenceManager, like getMostRecentProfileId(). This method would call getAllProfilesGameData(), iterate through all the loaded save files, and find the one with the highest lastUpdated value. It would then return the corresponding profile ID.
  3. Update Main Menu: The main menu logic would call getMostRecentProfileId(). If it returns a valid ID, the "Continue" button is enabled. When clicked, it would call persistenceManager.changeSelectedProfileId() with that ID, then persistenceManager.loadGame(), and finally transition to the game scene.

This exactly mirrors the logic shown in the "Implementing 'Continue' and Most Recently Played" section of the LINK video.

4. Building the Save/Load Screen UI

With the backend in place, creating the UI is a familiar task. We will create a new scene, SaveLoadScene, that displays the save slots.

Classic JRPGs like the ones you admire typically feature a list of 10-20 slots. RPG Maker MZ, for instance, provides 20 save slots.

HELP DOCUMENTATION for version 1.7.0 / PC

Let's confirm this standard JRPG pattern by looking at the RPG Maker MZ documentation.

Find the 'In-game Menu Controls' section, then look for the description of the 'Save' command. It explicitly states: 'Select where you wish to save from 20 different save slots.' This confirms our multi-slot design is on the right track.

Our SaveLoadScene will fetch all existing save data from the PersistenceManager and render a SelectableList.

// /scenes/SaveLoadScene.js
import { Scene } from './Scene.js';
import { persistenceManager } from '../core/PersistenceManager.js';
import { SelectableList } from '../components/SelectableList.js';
// ... other imports

export class SaveLoadScene extends Scene {
    constructor() {
        super();
        this.mode = 'load'; // Can be 'save' or 'load'
    }

    init(data) {
        this.mode = data.mode || 'load';
    }

    create() {
        const allSaves = persistenceManager.getAllProfilesGameData();
        const numSlots = 20; // Match RPG Maker standard
        const listItems = [];

        for (let i = 1; i <= numSlots; i++) {
            const profileId = String(i);
            const saveData = allSaves[profileId];
            let text;

            if (saveData) {
                // Display some info, e.g., map name and gold
                text = `Slot ${i}: ${saveData.currentMap} - ${saveData.inventory.gold}G`;
            } else {
                text = `Slot ${i}: --- Empty ---`;
            }
            listItems.push({ text, value: profileId });
        }

        this.saveList = new SelectableList({
            x: 100, y: 50,
            items: listItems,
            onSelect: this.onSlotSelected.bind(this)
        });

        this.add(this.saveList);
        this.saveList.activate();
    }

    onSlotSelected(item) {
        const profileId = item.value;
        persistenceManager.changeSelectedProfileId(profileId);

        if (this.mode === 'save') {
            persistenceManager.saveGame();
            // Optional: transition back to menu or map
            console.log(`Game saved to slot ${profileId}.`);
            this.sceneManager.pop(); // Go back to the previous scene
        } else {
            // Check if the slot is empty before trying to load
            if (persistenceManager.getAllProfilesGameData()[profileId]) {
                persistenceManager.loadGame();
                // Transition to the game map
                this.sceneManager.start('MapScene', { fromSave: true });
            } else {
                console.log("Cannot load from an empty slot.");
                // Re-activate list for another choice
                this.saveList.activate();
            }
        }
    }

    update(input) {
        this.saveList.update(input);
    }

    draw(context) {
        super.draw(context); // Draws children, including the list
    }
}

To use this, our main menu scene would call sceneManager.push('SaveLoadScene', { mode: 'save' }) when the save option is selected.

Conclusion

Excellent work! Today, we've built a complete, robust, and extensible save/load system. We started by designing a clean architecture that separates concerns, a principle you're well-versed in from your software engineering background. We then implemented the core logic for persistence and extended it to handle multiple save slots, a hallmark of the JRPG genre. Finally, we tied it all together with a UI scene that allows the player to interact with their saved games.

Key Takeaways:

  • Decoupled Architecture: Our system uses a PersistenceManager, StorageHandler, and a savable "interface" to create a flexible and maintainable persistence layer.
  • Multiple Save Slots via Profile IDs: We implemented the standard multi-slot save system by using unique Profile IDs to differentiate save data in localStorage.
  • Data-Driven UI: The SaveLoadScene dynamically populates itself by requesting all available save data from the persistence layer, displaying relevant information for each slot.
  • Serialization with JSON: We used JavaScript's native JSON object to easily serialize and deserialize the entire game state, which is stored in the browser's localStorage.

Preview of the next lesson:
We are now entering the final "polish" phase of our project. We have the core mechanics for exploration, events, combat, and menus. The game is fully playable, but it lacks atmosphere. In the next lesson, we will begin to change that by tackling sound design. We will integrate an audio manager for background music and sound effects using the Web Audio API.

Can't find a good explanation? Sign up and we'll make it for you

Sign up