Lightweight Saving & Loading System in Godot 4 with C#: A Practical Guide

Romain Mouillard
7 min readJan 12, 2025

--

In the past few weeks, I’ve been working on a lightweight saving and loading system for my current game, The Librarian. Since the game itself is quite simple, I decided to keep the system straightforward, focusing on core elements: saving and loading using JSON, managing save slots, and preserving save compatibility across updates (nobody likes losing their progress after an update!)

In this article, I’ll guide you through how I implemented this system using C# in Godot 4.3, focusing on clarity and flexibility. The complete code, including a working save/load menu, is available on my GitHub Repository. Whether you’re building a small project or just getting started with save systems, this tutorial will help you create a solid foundation.

Choosing JSON for Your Save Files: Pros and Cons

When deciding how to structure save files, several options are available, such as Godot Resources, JSON, and others. For a good overview, I recommend checking out this excellent video by Godotneers. Each format has its pro and cons, so it’s essential to choose the one that best fits your project’s needs.

I opted for JSON due to its human-readable nature and ease of manual editing, which felt intuitive given my background as a web developer. Since my game is relatively simple, I don’t mind the additional steps JSON requires when handling complex data types like Vector2 or Resource objects. If necessary, I plan to use custom JSON converters for those cases.

Whichever format you choose, the core implementation remains quite similar. Let’s dive into how to save and load data.

Implementing the Core Save & Load System

To get started, we only need two classes:

  • SaveGameManager : A singleton responsible for saving, loading, and managing save files.
  • SaveGameData : A data container used to store the information we want to save and to read the loaded data.

Defining SaveGameData: The Content to Save

Let’s start with SaveGameData. This data object represents the content you want to save in the current save file format. When saving, the file will contain a serialized version of an instance of this class. However, there may be situations where you need to load a save created with a previous version of your game. This will be covered in a next article and primarily relies on the SaveVersion property.

/// <summary>
/// Current save game data object.
/// </summary>
public class SaveGameData
{
public int SaveVersion { get; init; }

public string SavedAt { get; init; } = string.Empty;

public bool CharacterCreated { get; init; }

public string CharacterName { get; init; } = "Player";

public float CharacterPositionX { get; init; }

public float CharacterPositionY { get; init; }

public string CharacterDirection { get; init; } = "down";

public SaveGameDataResource ToResource()
{
return new SaveGameDataResource {
SavedAt = SavedAt,
CharacterCreated = CharacterCreated,
CharacterName = CharacterName,
CharacterPosition = new Vector2(CharacterPositionX, CharacterPositionY),
CharacterDirection = CharacterDirection,
};
}
}

There are a couple of important points to highlight in this class:

  • Character Position Handling :
    The character’s position is stored as two separate properties (CharacterPositionX and CharacterPositionY) instead of a Vector2. This is a tradeoff required by the JSON format, which only supports primitive types. Complex types need to be manually converted.
  • The ToResource() Method:
    The ToResource() method converts a SaveGameData instance into a SaveGameDataResource, a class extending Godot’s Resource object. This conversion is necessary because the SaveGameManager emits signals containing the loaded data, and Godot signals can only pass primitive types, built-in Godot types, or objects inheriting from GodotObject. SaveGameData itself doesn’t extend Resource to keep serialization and deserialization simpler.

This example provides a straightforward way to convert save data into a Resource object directly within the SaveGameData class. However, for more complex save structures, it would be cleaner to handle this conversion in dedicated converter classes to maintain better separation of concerns.

Here is what the Resource looks like :

using Godot;

/// <summary>
/// Resource version of the save game data that will be propagated to the game systems through signals.
/// Godot signals do not support raw C# types, so we need to use resources to pass data around.
/// </summary>
public partial class SaveGameDataResource : Resource
{
public string SavedAt { get; set; } = string.Empty;

public bool CharacterCreated { get; set; }

public string CharacterName { get; set; }

public Vector2 CharacterPosition { get; set; }

public string CharacterDirection { get; set; }
}

Implementing the SaveGameManager Class

The SaveGameManager class is a singleton registered as a global node in Godot. This setup allows you to access the save system from anywhere in the project (most commonly in menus) using the standard approach:

SaveGameManager saveManager = GetNode<SaveGameManager>("/root/SaveGameManager");

As expected, this class provides methods for saving and loading game data. In this basic implementation, the SaveGameData object is created directly within the SaveGameManager. However, for more complex projects, it would be better to separate this logic into dedicated classes to keep the code more modular.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Godot;

/// <summary>
/// Manages the saving and loading of the game data.
/// </summary>
public partial class SaveGameManager : Node
{
private const int SAVE_VERSION = 1; // Increment this number when the save game format changes

#region Signals
[Signal]
public delegate void SaveLoadedEventHandler(SaveGameDataResource saveGameData);
#endregion

/// <summary>
/// Save the current game progression to the given save file.
/// </summary>
public void Save(int identifier)
{
var json = JsonSerializer.Serialize(BuildSaveGameData(), GetJsonSerializerOptions());

using var file = FileAccess.Open(GetSaveGameFilePath(identifier), FileAccess.ModeFlags.Write);
file.StoreLine(json);
file.Flush();

GD.Print($"Save game saved to {GetSaveGameFilePath(identifier)}");
}

/// <summary>
/// Load the game progression from the given save file.
/// The silent parameter is used to prevent the signal from being emitted.
/// </summary>
public void Load(int identifier, bool silent = false)
{
if (!FileAccess.FileExists(GetSaveGameFilePath(identifier))) {
return;
}

using var saveFile = FileAccess.Open(GetSaveGameFilePath(identifier), FileAccess.ModeFlags.Read);
GD.Print($"Loading save game from {GetSaveGameFilePath(identifier)}");

var fileContent = saveFile.GetAsText();

// Start by reading the version data to determine how to load the save game
// for that, we use a light version class that only contains the version number
var versionData = JsonSerializer.Deserialize<MetadataSaveData>(fileContent);
GD.Print($"Identified Save Version #{versionData.SaveVersion}");

var loadedData = JsonSerializer.Deserialize<SaveGameData>(fileContent);
if (loadedData == null) {
GD.Print("Failed to load save game");
return;
}

if (!silent) {
EmitSignal(SignalName.SaveLoaded, LoadedSaveGameData);
}
}

/// <summary>
/// Create and return the save game data object to be saved in a file.
/// </summary>
private SaveGameData BuildSaveGameData()
{
var player = GetTree().GetNodesInGroup(Player.PLAYER_GROUP).FirstOrDefault() as Player;

return new SaveGameData {
SaveVersion = SAVE_VERSION,
SavedAt = DateTime.UtcNow.ToString("o"),
CharacterName = player?.CharacterName ?? CurrentSaveSlot.CharacterName,
CharacterPositionX = player?.GlobalPosition.X ?? 0,
CharacterPositionY = player?.GlobalPosition.Y ?? 0,
CharacterDirection = player?.Direction ?? "down"
};
}

private static JsonSerializerOptions GetJsonSerializerOptions()
{
var options = new JsonSerializerOptions {
IncludeFields = true,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
WriteIndented = true
};

return options;
}

private static string GetSaveGameFilePath(int identifier)
{
return $"user://savegame_{identifier.ToString()}.save";
}
}

Here are a few important design choices in the SaveGameManager class:

  • Serialization Approach:
    The class uses the JSON library from the .NET Standard Library for serialization and deserialization. This was a design choice, but alternatives like Newtonsoft.Json.NET or Godot’s built-in JSON tools could also be considered.
  • The silent Parameter:
    The Load() method includes a silent parameter that prevents the emission of the SaveLoaded signal. This is useful when you need to load a save without immediately modifying the current game state. For simpler games, updating the game state can often be handled by simply listening to this event.
  • Data Construction:
    The BuildSaveGameData() method generates the SaveGameData object directly within the SaveGameManager. While this approach works well for smaller projects, it’s generally better to delegate this responsibility to a separate class in larger or more complex games.

Once the SaveGameManager is registered as a global node in your Godot project, saving and loading become as simple as:

var saveGameManager = GetNode<SaveGameManager>("/root/SaveGameManager");
saveGameManager.SaveLoaded += (SaveGameDataResource saveGameData) => {
// This event handler is called when a save is loaded
// it is a fine place to update game state with loaded content
GD.Print($"Character name in this save: {saveGameData.CharacterName}");
}

// Load the first savegame
saveGameManager.Load(1);

// Save game state on the first save
saveGameManager.Save(1);

What’s Next?

With these two classes, you already have a basic yet functional save and load system capable of persisting game progress. In a future article, I’ll explore how to expand this system by adding save slots and implementing proper versioning support for forward compatibility.

On a side note, while implementing this system, I didn’t expect the UI for managing saves to take so much effort — it turns out handling save states visually requires careful attention to detail. Managing new saves, loading, saving, and continuing often involves multiple UI elements, and handling the state changes properly can be tricky. If you’re curious, you can check out a full working implementation of this system, including save menus and a small prototype game, on my GitHub Repository.

I hope this guide helps you set up a basic save system for your projects! If you try it out or adapt it further, feel free to share your feedback or improvements. Happy coding!

Thanks for reading! 🎉

If you enjoyed this article, feel free to check out my other content:
Showcases and updates on my games on my YouTube Channel.
Take a look at my games and enjoy a bit of what each world has to offer.

Stay tuned for more, and feel free to leave any feedback or questions in the comments. Happy coding!

--

--

Romain Mouillard
Romain Mouillard

Written by Romain Mouillard

Indie Game Developer / Fullstack Engineer / Ex-Head of Software @ Meero

No responses yet