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:
- 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.
- 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. - 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()andloadData()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
- Track Timestamps: Our
GameDataclass andPersistenceManager.saveGame()method already store thelastUpdatedtimestamp. - Find the Latest: Create a new method in the
PersistenceManager, likegetMostRecentProfileId(). This method would callgetAllProfilesGameData(), iterate through all the loaded save files, and find the one with the highestlastUpdatedvalue. It would then return the corresponding profile ID. - 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 callpersistenceManager.changeSelectedProfileId()with that ID, thenpersistenceManager.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
SaveLoadScenedynamically 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
JSONobject to easily serialize and deserialize the entire game state, which is stored in the browser'slocalStorage.
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