DEV Community

Cover image for How to add saving to your game | A Godot Game Engine Tutorial
Hexblit
Hexblit

Posted on • Edited on

How to add saving to your game | A Godot Game Engine Tutorial

When was the last time you thought about saving? Have you added it to your game? Have you thought about how you should save that might build trust for your players?

Did you save?

Well, perhaps that is asking too much. I am going to discuss how to implement a simple save mechanic in the Godot Game Engine with a game of medium complexity.

Meaning it has some instancing and dynamic elements as well as more static elements that are easier to reload. Our objective will be to make saving easier as we build out our game. Allowing us to hook into a framework without worrying about adding new characters, enemies and objects that cause us to stress about how to keep track of their data over time. Oh and this is part 1 of potentially 4 of saving, each one building on one another. Might want to hit that follow button to make sure dev.to is saving your interests.

While this tutorial focuses on the Godot Engine, the concept of building a save framework to keep your sanity extends to all game development systems regardless of engine.

This series is also in YouTube form if you would rather watch it and then review the details below. Up to you :)

Getting the Project from GitHub

To follow along you can grab the save-load project from my GitHub via the link below. The main branch is the project without save and load being implemented.

GitHub logo HexBlitUniversity / save-load

Get started with Saving and loading in Godot

You can either clone or download the zip file on the main branch.

Download by cloning or zip file

The other branches are for checking the end result to see if your solution matches what we end up creating in the end.

After you have downloaded the project open up the project in Godot.

You should see something like this.

The Block dodge game

Go ahead and open save-load-example.tscn and press F6 to run the sample game. You use the WASD keys to move around and dodge the blocks

The Block dodge game

If you die, the load last save does not work, but the start over does. So let's go ahead and start adding save and loading to this game.

Setting up your project for saving

Open the save-and-load-example.gd file.

Create a save function.

func save():
  pass 
Enter fullscreen mode Exit fullscreen mode

You can save in many locations, a database on a server, a local file, really anywhere you create persistent data is a potential save location.

In our case, we are going to go simple and use a local file directory.

I prefer to keep my save files in a save directory just in case I want to create other kinds of files and do not have to worry about filtering save vs other file types.

Let's go ahead and create a save directory, and let's take advantage of Godot's user:// directory.

func save():
  var dir = Directory.new()
  if !dir.dir_exists("user://save/":
    dir.make_dir("user://save/")
Enter fullscreen mode Exit fullscreen mode

A quick note, the user:// directory allows you to save to a player's local directory and is different per operating system.
Windows - %APPDATA%\Godot<ProjectName>
Mac OSX - ~/Library/Application Support/Godot/
Linux - ~/.config/godot/

Overriding and custom is detailed in [Feature Flags,https://docs.godotengine.org/en/stable/getting_started/workflow/export/feature_tags.html#doc-feature-tags], and not the point of this post.
Source - https://docs.godotengine.org/en/stable/tutorials/io/data_paths.html

Now let's create the save file, and create a data object that will be used to represent the info we want to save.

func save(): 
var dir = Directory.new()
  if !dir.dir_exists("user://save/":
    dir.make_dir("user://save/")

  var save_game = File.new()
  save_game.open("user://save/game_data.save",File.WRITE)
  var save_data = {} 
Enter fullscreen mode Exit fullscreen mode

The strategy for saving the data is to turn the JSON data sets into a string and save that to a file. Then when we want to load the data we would retrieve the JSON string from a file and turn it back into a dictionary.

Let's go ahead and stub that logic in.

func save():
  var dir = Directory.new()
  if !dir.dir_exists("user://save/":
    dir.make_dir("user://save/")

  var save_game = File.new()
  save_game.open("user://save/game_data.save",File.WRITE)

  var save_data = {}

  # Todo: Get save data from our player
  var data_string = var2str(save_data)
  save_game.store_string(data_string)
  save_game.close() 
Enter fullscreen mode Exit fullscreen mode

Godot has an awesome utility method called var2str that let's us turn variables into strings. This comes in handy for our Dictionary of save data.

Feel free to test this and add some data to the save_data string to see the file created with the JSON stored. It is pretty cool to see things working.

Next let's go ahead and put together the framework for loading the data back in.

func load_save(): 
  var save_game = File.new()
  if not save_game.file_exists("user://save/game_data.save"):
    print("No save file detected.")
    return 

  save_game.open("user://save/game_data.save",File.Read)
  var data_string = save_game.get_as_text()
  var save_data : Dictionary = str2var(data_string)

  print(save_data)
  save_game.close()
Enter fullscreen mode Exit fullscreen mode

Now we can not use the name load() because that is already a reserved name, but load_save works well enough for our needs.

We then make sure that the save file exists before we even try to open it.

Then we open the file and grab the file data as text. Then using another handy util function within Godot we use str2var which let's us convert the string data back into a variable.

We then test that save data is being saved by printing out that data and then close the file as we no longer need it.

Testing out save and load

To test out the saving mechanism, add load_save() to the onReady function

func _ready():
    load_save()

    timer.start()
    progression.start()
Enter fullscreen mode Exit fullscreen mode

and the save call to when the progress timer increases 1%, in the on_progressionTimer_timeout()

func _on_progressionTimer_timeout():
    player.increase_progress()  
    save()  
    if player.get_progress() >= 100:
        get_tree().change_scene("res://block-dodge/screens/GameOver.tscn")
Enter fullscreen mode Exit fullscreen mode

A little optimization

Pro Tip #1: If you find yourself writing the same thing more than one time, it might be worth trying to find a way to encapsulate it and reuse it. This is in association with the [DRY principle,https://en.wikipedia.org/wiki/Don%27t_repeat_yourself].

To reduce the chance of an error let's take the "user://save/game_data.save" string that we have written atleast three times now into a constant.

var SAVE_DIR = "user://save/"
var SAVE_FILE = "game_data.save"


# in save():
var dir = Directory.new()
    if !dir.dir_exists(SAVE_DIR):
        dir.make_dir(SAVE_DIR)

    var save_game = File.new()
    save_game.open(str(SAVE_DIR,SAVE_FILE),File.WRITE)  

# ... truncation for brevity 
Enter fullscreen mode Exit fullscreen mode

Saving and loading Player data

Now that we have setup the framework for saving let's go ahead and save the player and then move on to enemies.

Open player.tscn file, tap the root player node. on the right side click on the node tab next to the inspector.

Then click on Groups,

Click on add, and enter "saveable" the name does not really matter but I like to use actionable style naming with "-able". It helps you remember why nodes have certain groups.

Manage Groups - Saveable

Now moving on to player.gd, to accomplish saving we can very easily just use the data dictionary saving all the important variables.

func save():
  return {
      "progress": progress.value,
      "pos_x": position.x,
      "pos_y": position.y,
  }
Enter fullscreen mode Exit fullscreen mode

In this case, to know what you should be saving look at what variables are used on the top of the file. In this case the progress value is being increased over time so we should save that. We should then save the position so we can restore their position on screen.

Next to load the save data and restore player position and progress.

func load_save(data):
  if not data:
    return 

  progress.value = data["progress"]
  position.x = data ["pos_x"]
  position.y = data["pos_y"]
Enter fullscreen mode Exit fullscreen mode

Reusing the method name of load_save from save-and-load-example to keep things consistent and if you need to change anything in the future.

Assuming we are using the dictionary data type we pass that into the function, we verify that it is not null and try to load something that does not exist.

Next just set the value similar to how we retrieved them when saving. Using the data["name"] paradigm to get the associated data.

Now to complete saving the player go back to the save-and-load-example.gd.

In the save() method let's call the save method on the player. Let's replace that todo comment in the save function.

func save():
  #... truncation for brevity 
  var save_game = File.new()
  var save_data = {}

  var find_saveable_nodes = get_tree().get_nodes_in_group("saveable")
  for saveable in find_saveable_nodes:
    if !node.has_method("save"):
      print("node is missing a save() function... skipped.")
      continue

    save_data[node.name] = node.call("save")

  var data_string = var2str(save_data)
  save_game.store_string(data_string)
  save_game.close 

Enter fullscreen mode Exit fullscreen mode

Using the get_nodes_in_group method allows us to grab all nodes that have that group associated.

Then we ensure that the save method is actually on the node, which really protects you in case you need to add save to multiple nodes but have not finished adding the save() method to them.

We then set the save_data dictionary with the name of the node, this is better than having specific name, so if you decide to rename the player or other nodes your architecture scales with you.

Pro Tip #2: when we think about how best to structure our nodes for saving, we should think about if I had a million of these objects how would I handle this versus just a single object. Protect yourself from the "Oh I will fix that later..."

Loading the player

Our load save now just needs to find the player, and send the save data from the file that pertains to the player. We can easily do that by updating save and load as follows.

if save_data.has("player"):
  player.load_save(save_data["player"])
Enter fullscreen mode Exit fullscreen mode

Then our load and save method would end up looking like this:


func load_save():
    var save_game = File.new()
    if !save_game.file_exists(str(SAVE_DIR,SAVE_FILE)):
        print("No save file detected.")
        return

    save_game.open(str(SAVE_DIR,SAVE_FILE),File.READ)
    var data_string = save_game.get_as_text()
    var save_data : Dictionary = str2var(data_string)

    if save_data.has("player"):
       player.load_save(save_data["player"])        


    print(save_data)
    save_game.close()
Enter fullscreen mode Exit fullscreen mode

Clearing Save Data

Now that you have the player saving and loading, you might notice how every time you run the game now it keeps going where you left off. The start over button has no effect.

Let's show how we can fix that.

By Opening the GameOver.gd file you will see two primary signal functions called, func _on_Checkpoint_pressed(): and func _on_Startover_pressed():

To have the Load last save button do something all we have to do is copy the get_tree().change_scene("..") call in _on_startover_pressed() into the _on_checkpoint_pressed() method. Which will now act like loading the save file.

For starting over we need to implement a new function that clears the save data.

func clear_save_data():
  var dir = Directory.new()
  if dir.open("user://save/") == OK:
    dir.list_dir_begin()
    var file_name = dir.get_next()
    while file_name != "":
      dir.remove("user://save/"+file_name)
      file_name = dir.get_next()
Enter fullscreen mode Exit fullscreen mode

This method will use the Directory class to iterate over the files in the opened directory. from there we ask the class to remove the associated file and go on to the next save file.

In our case there is only one file, but if you wanted to handle your save system with multiple files or snapshots this makes it flexible enough for your needs.

Then go ahead and add the clear_save_data() to the start_over method defined.

func _on_Startover_pressed(): 
  clear_save_data()
  get_tree().change_scene("..") 
Enter fullscreen mode Exit fullscreen mode

Now let's move on to saving enemy data

Saving Enemy Data

There are two kinds of enemies, they are in the EnemyLong.tscn file and the EnemyRect.tscn file.

The EnemyRect.tscn represents a square that rotates and flys at you while you are dodging the other blocks.

The EnemyLong represents both the red long blocks that come down in the beginning but the blue ones that come from the left as well.

They are both dynamic and do not exist in the scene tree before the game starts. The EnemyLong has an additional complication that its color and rotation are changed during runtime.

Let's start with EnemyRect

In the EnemyRect.tscn file, update the root node's group to Saveable just like we did for player.

open up EnemyRect.gd file and add the following methods.

For Saving:

   func save(): 
    return { "pos_x" : position.x,
             "pos_y" : position.y,
             "trajectory" : trajectory,
             "attack" : attack,
             "block_rotation" : block_rotation      
    }
Enter fullscreen mode Exit fullscreen mode

For Loading:

func load_save(data):
    if not data: 
        return

    position.x = data["pos_x"]
    position.y = data["pos_y"]
    trajectory = data["trajectory"]
    attack = data["attack"]
    block_rotation = data["block_rotation"]
Enter fullscreen mode Exit fullscreen mode

Pretty straightforward, not special things to worry about. Which allows us to add more enemies and characters to our games without heavy lift in our architecture.

Now for the enemyLong, update it's node group to have Saveable as well, in the enemyLong.tscn file.

Then open up the enemyLong.gd file and update the save and load files like so:

func save(): 
    return {
        "pos_x" : position.x,
        "pos_y" : position.y,
        "block_direction_x" : block_direction.x,
        "block_direction_y" : block_direction.y,
        "block_color_red" : block_color.r,
        "block_color_green" : block_color.g,
        "block_color_blue" : block_color.b,
        "parent_name" : get_parent().name,
    }

Enter fullscreen mode Exit fullscreen mode

In this case, the only odd one out is the reference to parent, we are capturing this so we can identify the proper parent that should be the spawn location for this block. Coming from the top container or the left container.


func load_save(data):
    if not data:
        return 

    position.x = data["pos_x"]
    position.y = data["pos_y"]
    block_direction.x = data["block_direction_x"]
    block_direction.y = data["block_direction_y"]
    block_color = Color(data["block_color_red"],data["block_color_green"], data["block_color_blue"])
    rectOne.color = block_color
    rectTwo.color = block_color


Enter fullscreen mode Exit fullscreen mode

The loading is pretty straightforward, we protect ourselves like always on the data with the if not data statement.

We set the color and direction of the block, and the parent is not used as it is only relevant when we are instancing the enemy.

Updating the load_save in the Example scene.

Now that our save is setup to handle calling save() correctly, whenever we add a Saveable group to a node it will be automatically saved. So no extra code required for saving. Woot!

Updating Load_Save,

we will need to handle our enemies as they are spawned unlike preexisting like the player node.

We can take advantage of the save_data dictionary and use it as our guide of what we need to spawn in the scene. But the player exists in this list and we do not want spawn the player.

so let's erase the player and then start looping through th save data.


func load_save()
 #... truncated
    if save_data.has("player"):
        player.load_save(save_data["player"])       
        save_data.erase("player") # Erase the player from save_data, we are done loading this node. 

for node_name in save_data.keys(): # Let's loop over the nodes we should instance.
Enter fullscreen mode Exit fullscreen mode

now we can just reference the enemyRect and use the spawnEnemyRect methods in this file that already spawn the enemy.

for node_name in save_data.keys():
        if "enemyRect" in node_name:
            var entity = null 
            entity = enemy_rect.instance()
            enemy_container_top.add_child(entity)
            entity.load_save(save_data[node_name])
Enter fullscreen mode Exit fullscreen mode

Not too difficult, and spawning logic guided us on what we should be adding and where.

Next, let's load the two enemyLong enemies.

for node_name in save_data.keys():
        if "enemyRect" in node_name:
            var entity = null 
            entity = enemy_rect.instance()
            enemy_container_top.add_child(entity)
            entity.load_save(save_data[node_name])

        if "enemyLong" in node_name:
            var entity = null 
            entity = enemy_long.instance()
            if !save_data[node_name].has("parent_name"):
                continue

            var enemy_save_data = save_data[node_name] 
            if "Top" in enemy_save_data["parent_name"]:
                enemy_container_top.add_child(entity)
            else:
                enemy_container_left.add_child(entity)

             entity.rotate_degrees_and_color(Vector2.RIGHT,0,0,1)
            entity.load_save(enemy_save_data)
Enter fullscreen mode Exit fullscreen mode

The hardest part is really just accounting for the parent, and usign the spawn logic to determine if it is blue, and in the left or default, and in the top.

The key is to be able to call load_save(node_data) and save() to retrieve the data.

Final thoughts

Thank you! I hope this was helpful and you can focus on making amazing games

If you found a mistake, typo or issue let me know!

As always-- Stay Safe, Stay Awesome

Where to find and follow me

Feel free to subscribe to my YouTube channel (It's game dev tutorials and poking fun at the game dev lifestyle)
Hex Blit University YouTube Channel

I send out a weekly email about Game Development (Quotes, Cool things I discover during the week, I will not ask about your car's extended warranty)
Dev Bytes email group

I am also trying to post more on twitter
Twitter/Hexblit

And of course here :)

Top comments (0)