Create your own
Lesson illustration

Saving and Loading Data with Web Storage

Hello! Welcome back to our JRPG development journey.

In the last lesson, we completed a crucial architectural task: designing a clean, comprehensive, and serializable game state object. This SerializableGameState is our "single source of truth," a pure data structure that holds everything needed to reconstruct a player's session.

Today, we'll bring that design to life. This lesson directly addresses the learning outcome: Implement save/load functionality using the browser's localStorage or IndexedDB API. We will take our SerializableGameState object and build the persistence layer that writes it to storage and reads it back, effectively making our game's progress saveable.

We'll explore two primary browser storage mechanisms, discussing the trade-offs of each—a decision-making process that should be familiar from your front-end development experience.

1. Choosing the Right Tool: localStorage vs. IndexedDB

The browser provides two main APIs for persisting data on the client-side. The choice between them involves a classic trade-off between simplicity and power.

FeatureLocal StorageIndexedDB
API TypeSynchronousAsynchronous
Data StructureKey-value pairs (strings only)Object stores (like tables in a database)
Storage LimitSmall (typically 5-10 MB per domain)Large (can be several GB, user is prompted)
ComplexityVery simple (setItem, getItem)More complex (requests, transactions, events)
Use CaseSimple settings, small amounts of dataLarge, structured data, offline applications
  • localStorage: This is a simple key-value store. It's incredibly easy to use but has two significant drawbacks for games: it's synchronous, meaning a large save operation could momentarily freeze the main thread, causing a stutter; and its storage limit is relatively small.
  • IndexedDB: This is a full-fledged, transactional, NoSQL database built into the browser. It's asynchronous, so operations don't block rendering. It can handle much larger datasets, making it ideal for games with potentially large save files. The trade-off is a more complex, event-driven API.

For our JRPG, localStorage is a great way to get a functional save system up and running quickly. An entire game state, when serialized to JSON, might only be a few hundred kilobytes, well within the limit. However, IndexedDB is the more robust, professional solution that scales better and guarantees a smooth user experience.

We'll start by implementing the localStorage approach due to its simplicity and then show how to build a more advanced version with IndexedDB.

2. Implementation with localStorage

The core limitation of localStorage is that it can only store strings. This is precisely why we designed a serializable object in the last lesson. We will use JSON.stringify() to convert our SerializableGameState object into a JSON string for storage, and JSON.parse() to convert it back into an object when loading.

Storing Objects with Local Storage in JavaScript

To see this process in action, watch this excellent and concise video from the dcode channel. It perfectly explains the problem of storing objects in localStorage and the solution using JSON.

Watch the segments from the beginning to 06:22. Pay close attention to: The demonstration of why storing an object directly fails (00:48). The use of JSON.stringify() to serialize the object into a string (02:58). The use of JSON.parse() to deserialize the string back into a usable object (04:48).

Now, let's update our PersistenceManager to use this technique. The code will handle multiple save slots by creating a unique key for each slot.

// In a file like 'managers/PersistenceManager.js'

export class PersistenceManager {
    constructor() {
        this.storageKeyPrefix = 'jrpg_save_';
    }

    /**
     * Saves the game state to a specific slot in localStorage.
     * @param {number} slotId - The ID of the save slot (e.g., 1, 2, 3).
     * @param {object} gameState - The serializable game state object.
     * @returns {boolean} - True if successful, false otherwise.
     */
    saveGame(slotId, gameState) {
        const key = this.storageKeyPrefix + slotId;
        try {
            const jsonState = JSON.stringify(gameState);
            localStorage.setItem(key, jsonState);
            console.log(`Game saved to slot ${slotId}.`);
            return true;
        } catch (error) {
            console.error(`Failed to save game to slot ${slotId}:`, error);
            return false;
        }
    }

    /**
     * Loads the game state from a specific slot in localStorage.
     * @param {number} slotId - The ID of the save slot.
     * @returns {object|null} - The game state object, or null if not found or on error.
     */
    loadGame(slotId) {
        const key = this.storageKeyPrefix + slotId;
        const jsonState = localStorage.getItem(key);

        if (!jsonState) {
            console.log(`No save data found for slot ${slotId}.`);
            return null;
        }

        try {
            const gameState = JSON.parse(jsonState);
            console.log(`Game loaded from slot ${slotId}.`);
            return gameState;
        } catch (error) {
            console.error(`Failed to load/parse game from slot ${slotId}:`, error);
            // It's good practice to remove corrupted data
            localStorage.removeItem(key);
            return null;
        }
    }

    /**
     * Checks if a save slot exists.
     * @param {number} slotId 
     * @returns {boolean}
     */
    saveExists(slotId) {
        return localStorage.getItem(this.storageKeyPrefix + slotId) !== null;
    }
    
    /**
     * Deletes a save slot.
     * @param {number} slotId 
     */
    deleteSave(slotId) {
        const key = this.storageKeyPrefix + slotId;
        localStorage.removeItem(key);
        console.log(`Save data for slot ${slotId} deleted.`);
    }
}

This implementation is simple, effective, and directly leverages the SerializableGameState we designed.

Test your understanding!

You've saved a game state using localStorage.setItem('save_1', JSON.stringify(gameState)). When you reopen the game, you try to load the player's gold with this code:

const savedData = localStorage.getItem('save_1');
console.log(savedData.party.gold); // This line throws an error

Why does this code fail, and what is the one-line fix?

Show answer

The code fails because localStorage.getItem('save_1') returns the game state as a JSON string, not a JavaScript object. You cannot access properties like .party.gold on a string.

The fix is to use JSON.parse() to deserialize the string back into an object before trying to access its properties.

const savedData = JSON.parse(localStorage.getItem('save_1')); // The fix is here
console.log(savedData.party.gold); // This will now work correctly

3. A More Robust Solution with IndexedDB

While localStorage works, IndexedDB is built for this kind of task. Its asynchronous nature prevents game stutters, and its larger capacity ensures we won't run out of space. The learning curve is steeper, but the concepts are standard for any database work.

Core IndexedDB Concepts:

  1. Database: You open a connection to a named and versioned database.
  2. Object Store: Within the database, you create object stores (like tables) to hold your data. We'll create one called save_slots.
  3. Records: Each entry in the object store is a JavaScript object. We'll store objects like { id: 1, data: serializableGameState }. The id will be our slotId.
  4. Transactions: All data operations (read, write, delete) must happen within a transaction. This ensures data integrity; if one part of a multi-step operation fails, the whole thing is rolled back.
  5. Asynchronous Requests: Every operation returns a request object. You attach event handlers (onsuccess, onerror) to these requests to handle the results when they complete.

How to use IndexedDB to store data for your web application in the browser

This video from Alex Eagleson provides a thorough introduction to IndexedDB. We'll focus on the sections that are most relevant to creating our save system.

Please watch the following sections: Setting up IndexedDB (11:15 - 14:52): Understand how to open a database connection (indexedDB.open) and how the crucial onupgradeneeded event is used to define the database schema (its structure) the first time it's created or when you increase the version number. Creating Object Stores (14:52 - 17:05): This part shows how createObjectStore is used inside onupgradeneeded to define our 'table' for save games. Pay attention to the keyPath option, which specifies which property of our objects will serve as the unique key (we'll use id for our slot ID). Adding and Querying Data (18:46 - 24:00): Focus on how transactions are created and how the put() and get() methods work. put() is perfect for saving because it either inserts a new record or updates an existing one. get() is used to retrieve a record by its key.

Now, let's create a new PersistenceManager using IndexedDB. To handle the asynchronous, event-based API in a modern way, we will wrap the operations in Promises. This is a common pattern that makes asynchronous code much cleaner to work with.

// In a file like 'managers/IndexedDBPersistenceManager.js'

export class IndexedDBPersistenceManager {
    constructor() {
        this.db = null;
        this.dbName = 'JRPG_DB';
        this.storeName = 'save_slots';
    }

    /**
     * Initializes the database connection. Must be called before other methods.
     * @returns {Promise<void>}
     */
    init() {
        return new Promise((resolve, reject) => {
            // Check if DB is already initialized
            if (this.db) {
                resolve();
                return;
            }

            const request = indexedDB.open(this.dbName, 1);

            request.onerror = (event) => {
                console.error('IndexedDB error:', event.target.error);
                reject('Database error');
            };

            request.onsuccess = (event) => {
                this.db = event.target.result;
                console.log('Database initialized.');
                resolve();
            };

            // This event only fires when the DB is created for the first time
            // or a new version number is passed to indexedDB.open()
            request.onupgradeneeded = (event) => {
                const db = event.target.result;
                // Create an object store (like a table) to hold our save files.
                // We use 'id' as the keyPath, which is the unique identifier for each record.
                db.createObjectStore(this.storeName, { keyPath: 'id' });
            };
        });
    }

    /**
     * Saves the game state to a specific slot.
     * @param {number} slotId
     * @param {object} gameState
     * @returns {Promise<void>}
     */
    saveGame(slotId, gameState) {
        return new Promise((resolve, reject) => {
            if (!this.db) return reject('Database not initialized.');

            // Start a "readwrite" transaction.
            const transaction = this.db.transaction([this.storeName], 'readwrite');
            const objectStore = transaction.objectStore(this.storeName);
            
            // The record we want to save
            const record = { id: slotId, data: gameState, timestamp: new Date() };

            // The put() method will add a new record or update an existing one.
            const request = objectStore.put(record);

            request.onsuccess = () => {
                console.log(`Game saved to slot ${slotId}.`);
                resolve();
            };

            request.onerror = (event) => {
                console.error('Error saving game:', event.target.error);
                reject('Error saving game');
            };
        });
    }

    /**
     * Loads the game state from a specific slot.
     * @param {number} slotId
     * @returns {Promise<object|null>} The game state object or null.
     */
    loadGame(slotId) {
        return new Promise((resolve, reject) => {
            if (!this.db) return reject('Database not initialized.');

            const transaction = this.db.transaction([this.storeName], 'readonly');
            const objectStore = transaction.objectStore(this.storeName);

            // Get the record by its key (the slotId).
            const request = objectStore.get(slotId);

            request.onsuccess = (event) => {
                if (event.target.result) {
                    console.log(`Game loaded from slot ${slotId}.`);
                    // We resolve with the 'data' property of the stored record.
                    resolve(event.target.result.data);
                } else {
                    console.log(`No save data found for slot ${slotId}.`);
                    resolve(null);
                }
            };

            request.onerror = (event) => {
                console.error('Error loading game:', event.target.error);
                reject('Error loading game');
            };
        });
    }
    
    // ... Implement saveExists and deleteSave using similar Promise-based patterns ...
}

This IndexedDB version is more complex to set up, but the resulting saveGame and loadGame methods are clean and non-blocking, making your game's save/load process feel seamless. You can also inspect the stored data easily using your browser's developer tools, typically under the "Application" tab.

Conclusion

You have now successfully bridged the gap between designing a data structure and physically persisting it. By implementing save and load functionality, you've added one of the most essential features to your game engine.

Key Takeaways:

  • localStorage and IndexedDB are the two primary tools for client-side persistence in the browser.
  • localStorage is simple and synchronous, best for quick implementations with small data. It requires manual serialization with JSON.stringify() and deserialization with JSON.parse().
  • IndexedDB is a more powerful, asynchronous database. While more complex to set up, it's the superior choice for games as it avoids blocking the main thread and handles much larger save files.
  • Wrapping IndexedDB's event-based API in Promises is a modern practice that significantly improves code readability and maintainability.
  • The SerializableGameState object you designed previously is the perfect payload for either storage system.

Preview of the next lesson:
With our game's state now safely persistable, we can turn our attention to another crucial element of the JRPG experience: sound. In the next lesson, we will integrate an audio manager for background music and sound effects using the Web Audio API, bringing your game world to life with an auditory dimension.

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

Sign up