Building a simple binary serialization save system in Godot 4
The challenge
As the official Godot docs say, game save systems can be complicated. This was definitely my experience in my Godot learning journey. Following the official Godot tutorial on saving games, I was able to implement JSON based saves for very simple objects pretty easily.

As my games got a little more complex, I got stuck. Additionally, I wanted to learn to make saves more compact and secure via binary serialization.
I was working on a game demo (this one) where I wanted each level to contain a number of different currencies. The currencies were controlled by a single class, where the type of currency could be set via an export variable.
Importantly, the mechanics of this game required the player to play the same level multiple times, and I wanted the state of currency to be preserved across plays of the same level. i.e. There was a finite amount of currency in the game, and replaying allowed the player to collect all of them, similar to Star or Yoshi Coin collection in Mario games.
Fortunately, I came across this post by Night Quest Games. It’s a great primer on data serialization/deserialization (storing and unpacking data) and intro to binary serialization/deserialization in Godot 4. This helped me better understand how the binary serialization system works in Godot and how to develop a system to suit the needs of my game.
My solution
For my game, I came up with a relatively simple binary serialization/deserialization that suited my needs and is slightly different than Night Quest’s approach. I leveraged the built in store_var
and get_var
methods of Godot’s FileAccess
class, which relies on the binary serialization API.
To share this system I first build a very simple game with a few levels. In this game you move the player character around a map, collecting two different types of coins while avoiding the spiky obstacles. The code for this game and save system are publicly available on GitHub, and you can play the game directly on Itch.IO.

With this demo, I wanted to illustrate three patterns that I tend to use in my level-based games.
Saving global game information.
Saving state of specific objects within each level.
Saving state of each level separately.
In describing this demo, I’m assuming a fair bit of intro Godot experience. So, if anything is unclear, let me know in the comments and I’ll do my best to address gaps!
Saving global game information
First, I separate out global state, such as what level the player is currently playing, the number of lives they have left, coin counts, etc into a singleton/autoload script (/scripts/globals/globals.gd).
The magic happens in two methods at the end of this script - save_data()
and load_data()
.
func save_data() -> Dictionary:
var save_dict: Dictionary = {
"current_level": current_level,
"player_lives": player_lives,
"player_pos_x": player_position.x,
"player_pos_y": player_position.y,
"coin_counter": coin_counter,
}
return save_dict
func load_data(data: Dictionary) -> void:
current_level = data["current_level"]
player_lives = data["player_lives"]
player_position.x = data["player_pos_x"]
player_position.y = data["player_pos_y"]
coin_counter = data["coin_counter"]
The save method packs the relevant properties into a Dictionary and returns it. The load method takes a dictionary and re-defines global variables using that dictionary.
But how are these used?
All of the core saving and loading methods are contained in a separate singleton, /scripts/globals/save_load.gd.
Within this file, there are a number of helper methods for setting up file access and encryption. I won’t belabor these, as the code is heavily annotated and I encourage you to read through it to get a better understanding of how it all works.
The saving and loading of global variables happens in two methods:
func _save_globals():
# Open and check file
var status = _open_file(FileAccess.WRITE, _globals_filepath)
if (status != OK):
print("Unable to open the file %s. Received error: %d" % [_globals_filepath, status])
return
# Serialize data to file
var save_data: Dictionary = Globals.save_data()
_s_file.store_var(save_data)
# Close the file
_close_file()
func _load_globals():
# Open and check file
var status = _open_file(FileAccess.READ, _globals_filepath)
if (status != OK):
print("Unable to open the file %s. Received error: %d" % [_globals_filepath, status])
return
var loaded_data: Dictionary = _s_file.get_var()
Globals.load_data(loaded_data)
# Close the file
_close_file()
Here, the save method is opening a save file (the _open_file()
method is opening an encrypted file), saving the global data dictionary using the FileAccess.store_var()
method, then closing the file. The load method is similarly opening the save file, loading the data with FileAccess.get_var()
, calling the Globals.load_data()
method to update the global variables, then closing the file.
Saving the state of objects in each level
Saving and loading individual scenes works identically to how I save and load globals above. In the case of saving the state of a game/level however, your save system needs some way of knowing which types of objects should be saved and which should be loaded fresh each playthrough.
In my research, the most recommended way to do this is using Godot’s grouping system. In other words, for each scene that encodes an object for which you want to save state, you assign that scene to a specific group. In my case I used a “persist” group, but you can call it anything you like.
In this demo, the only persisted scene is the coin scene. So inside the script accompanying this scene (/scripts/coin.gd) you’ll find save_data()
and load_data()
methods similar to those used for the global save/load above.
func save_data() -> Dictionary:
var save_dict: Dictionary = {
"filepath": get_scene_file_path(),
"parent": get_parent().get_path(),
"name": name,
"pos_x": global_position.x,
"pos_y": global_position.y,
"coin_type": coin_type,
}
return save_dict
func load_data(data: Dictionary) -> void:
name = data["name"]
position.x = data["pos_x"]
position.y = data["pos_y"]
coin_type = data["coin_type"]
The primary difference here is that for in-game scenes we MUST store two properties, the file path of the scene, and the path to the parent scene, in order for our load functions to work properly.
The remainder of the saved properties are what’s necessary to reconstruct the in-game object.
In the next section, you’ll see how these are implemented in the save system to store the state of each level in a separate save file.
Saving state of each level separately
The saving and loading of levels in this demo works very similarly to saving and loading globals, with a few key exceptions. Let’s first look at the save method. (Note: I’ve removed some of the annotation for readability here).
func _save_level(level_name: String):
var status = _open_file(FileAccess.WRITE, _get_level_filepath(level_name))
if (status != OK):
print("Unable to open the file %s. Received error: %d" % [_get_level_filepath(level_name), status])
return
var save_nodes = get_tree().get_nodes_in_group("persist")
for node in save_nodes:
var save_data: Dictionary = node.save_data()
_s_file.store_var(save_data)
_close_file()
In this case we similarly must open an encrypted file with write access. Here, we’re using a helper function _get_level_filepath(level_name)
which constructs a file path based on the current level (a global variable).
Next, we identify all of the nodes that are in the group “persist” and save the information for each of those sequentially to our save file before closing the file.
To load the data back in we take a similar approach:
func _load_level(level_name: String):
var status = _open_file(FileAccess.READ, _get_level_filepath(level_name))
if (status != OK):
print("Unable to open the file %s. Received error: %d" % [_get_level_filepath(level_name), status])
return
var save_nodes = get_tree().get_nodes_in_group("persist")
for node in save_nodes:
node.queue_free()
while _s_file.get_position() < _s_file.get_length():
var loaded_data: Dictionary = _s_file.get_var()
var new_object = load(loaded_data["filepath"]).instantiate()
new_object.load_data(loaded_data)
get_node(loaded_data["parent"]).add_child(new_object)
_close_file()
In this case we have to identify all of the persisted nodes in the game and remove them from the game with queue_free()
. We do this because next we are reading and instantiating the nodes that we previously saved. If we don’t first remove all “persist” nodes, we would have duplicates and coins that we previously collected would reappear.
Here you can see why we need to store the filepath and parent path of each node in the save_data()
method of each persisted scene, because we are using these to load the saved objects back to the proper location.
Importantly, if your game is particularly complex, with deeply nested objects in each scene, you may have to create more complex save systems where you load parent objects first. In other words, your save and load functions may get more complicated in order to specify the order in which you save and load specific nodes in your game.
Tradeoffs
In Godot, there are a number of ways to do the same thing.
For example, I chose here to illustrate using Godot’s built in store_var()
and get_var()
methods rather than storing each variable separately in my save_data()
and load_data()
methods (for example as Night Quest does in their tutorial linked above).
As pointed out in this thread on Reddit, this is a tradeoff of speed versus memory efficiency. The built in store_var() and get_var() methods have to store both the type of variable and the variable itself, which uses more memory to store the same information, but may be slightly faster than iterating over each individual variable yourself.
For larger and more complex games, you’d almost certainly want to implement custom methods to save specific variables using appropriate types to serialize and deserialize. However, for simpler games, I find the built in methods very convenient.
Another tradeoff is using binary serialization to save game state as opposed to Godot’s built in ConfigFile system. ConfigFile is intended to store user configuration settings and can be used to store more save data, but I’ve found it unwieldy for more complex saves.
Conclusions
This post is intended to help new game devs using Godot who may be struggling to implement their first save system. I hope the demo project that I have set up (link to that again) is useful in helping you set up your first save system.
For readers that are more advanced engineers and game devs, what did I miss or get wrong? Feel free to provide helpful suggestions to us and other readers in the comments!
Good luck out there and play responsibly!