How to achieve persistence in a metroidvania game?

Godot Version

4.2

Question

Hi, I’m a newcomer from GMS and there is a problem that got me slumped.
I’m making a simple metroidvania / action platformer.
The idea is to have several worlds, each with interconnected levels (rooms). While playing a certain world you can move from room to room, collecting gems and powerups. Whenever you change a room the state of these collectible objects should be retained (i.e. whether they’re picked up or not). So, you collect 3 gems out of 5 in room A, go to room B and take a powerup, return to room A to collect the remaining 2 gems.

In GMS this is extremely easy, just tick those objects as “permanent” and that’s it. Godot does not have this functionality built in and I have a real problem about setting up a system for it. Basically, I don’t know where to start - what is the core approach to adopt? Try to save those states in a singleton? Juggle the nodes in and out of memory somehow? I must say this is really low-level stuff for me for something that is core functionality in 99% of games…

But anyways, so far I managed to get my player scene to change rooms via get_tree().change_scene_to_packed(scene_to_load) and to retain player state (lives, score etc) via a global singleton. I am looking for a way to achieve persistence of objects in those rooms as I go back and forth between them. What is the easiest and simplest way to accomplish this? At the moment I am not looking for permanent saving of these between sessions, just during gameplay. Could anyone point me to a solution?

Mark every nodes that you want to save state to a group.
Then you can use get_tree().get_nodes_in_group("your group name") to get these nodes.
After that, you can use Node.get_path() to get its node path as a unique id.
You can use functions(like load_data(data) save_data() -> data) to load and save datas when you want.
Store these data are easy, just use a Dictionary and it’s easy to convert between it and json.

1 Like

Thank you for your quick reply, but I must admit that I pretty much have no idea what you just said or how I can practically apply it beyond the first two lines.
Is there some kind of tutorial or even better a written guide somewhere you could point me to? I’ve been looking around but there is very little about it, most tutorials pretty much stop at “do a single room game” which is what I already know. It is this permanence and changing rooms which is imo the number one feature missing from Godot. Ok, I really do appreciate the open architecture, and “roll your own” philosophy, but come on… This is some basic stuff. When you make a Space Invaders clone you shouldn’t have to delve deep into the core architecture of the engine just to be able to switch screens.
Don’t get me wrong, I utterly love Godot and I’m staying, the best engine there is at the moment, but man… Simply switching screens and retaining some data between them shouldn’t require an advanced course…

Here is a example:

# the node you want to save
func _ready() -> void:
    add_to_group("persistent")

func load_data(data: Dictionary) -> void:
    # TODO do it by yourself because idk what thing you want to save/load

func save_data() -> void:
    var data: Dictionary = {}
    # TODO
    return data
# game manager node(singleton)
func load_data(data: Dictionary) -> void:
    for node_path in data.keys():
        get_tree().root.get_child(node_path).load_data(data[node_path])

func save_data() -> Dictionary:
    var data: Dictionary = {}
    for node in get_tree().get_nodes_in_group("persistent"):
        data[node.get_path_to(get_tree().root)] = node.save_data()
    return data

func load_data_from_file(file_name: String) -> void:
    var access: FileAccess = FileAccess.open("user://" + file_name, FileAccess.READ)
    var json: String = access.get_as_text()
    var dict: Dictionary = JSON.parse_string(json)
    load_data(dict)
    access.close()

func save_data_to_file(file_name: String) -> void:
    var dict: Dictionary = save_data()
    var json: String = JSON.stringify(dict)
    var access: FileAccess = FileAccess.open("user://" + file_name, FileAccess.WRITE)
    access.store_string(json)
    access.close()
  • If you don’t need save data to file, just use singleton(it will not be reseted when you change scene)
  • You should save different scene in different file.

In this case, you just want to store something without reseting when scene is changed.
Try to find some keyword in document.

2 Likes

I recommend reading through the docs as it explains exactly what you want to do in plenty of details.

1 Like

I have try something.

Here is two gdscript you can add to your game :

Persistent_Object.gd
#Use this as a node in the scene to save its basic properties
#Don't forget to define it's uid by clicking the recreate_uid checkbox
#When cloning persistent node remember to change the UID by checking the box again in the inspector

@tool # made it a tool to allow quick generation of uid
class_name Persistent_Object extends Node2D

# Allow changing the target 
@export_enum("This", "Parent") var persistent_mode = 1;

@export var recreate_uid = false:
	set(value):
		print("UID Changed")
		recreate_uid = false
		uid = Persistent_Object.create_uid();

@export var uid = "";

var target = self
var destroyed = false

func _ready():
	# verify possible error
	if (persistent_mode == 1):
		# Get parent if mode is set to parent
		target = get_parent()
		# If parent is not a Node, switch back to 'this' mode
		# this can be change, it ensure you can save data below.
		if !(get_parent() is Node2D):
			printerr("Parent Node is not a Node2D, the persistent_mode is switch back to 'this'")
			persistent_mode = 0
			target = self

	# Check if data already exist and charge it if needed
	if PersistentManager.has(uid):
		var data = PersistentManager.get_object(uid)
		if (data.destroyed): # data say that this node is destroyed
			return target.call_deferred("queue_free")
		else:
			load_data(data)
	else: # Recreate a data in the manager
		resave()
	
	# Connect changed properties to the save function if you need to save thing related to it
	# target.item_rect_changed.connect(resave)
	# target.visibility_changed.connect(resave)
	target.tree_exiting.connect(resave);


#You can change properties saved here;
func save_data(destroyed = false):
	return {
		destroyed = destroyed, # you need to keep this line.
		# Exemple :
		# position = target.position,
		# scale = obj.scale,
		# rotation = obj.rotation,
		# visible = obj.visible,
	}

func load_data(data):
	# Exemple :
	# target.position = data.position;
	# target.scale = data.scale;
	# target.rotation = data.rotation;
	# target.visible = data.visible;
	pass



# Contact with the manager
func resave():
	PersistentManager.save(uid, save_data(destroyed))

# Call this function to queue_free the target despite of the original queue_free function
func destroy():
	destroyed = true;
	target.call_deferred("queue_free")


####################################################################
# UID GENERATOR FROM https://github.com/binogure-studio/godot-uuid #
####################################################################

# MIT License

# Copyright (c) 2023 Xavier Sellier

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

const BYTE_MASK: int = 0b11111111


static func create_uid() -> String:
	var b = uuidbin();
	return '%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x' % [
	# low
	b[0], b[1], b[2], b[3],

	# mid
	b[4], b[5],

	# hi
	b[6], b[7],

	# clock
	b[8], b[9],

	# clock
	b[10], b[11], b[12], b[13], b[14], b[15]
  ]
static func uuidbin():
	# 16 random bytes with the bytes on index 6 and 8 modified
	return [
	  randi() & BYTE_MASK, randi() & BYTE_MASK, randi() & BYTE_MASK, randi() & BYTE_MASK,
	  randi() & BYTE_MASK, randi() & BYTE_MASK, ((randi() & BYTE_MASK) & 0x0f) | 0x40, randi() & BYTE_MASK,
	  ((randi() & BYTE_MASK) & 0x3f) | 0x80, randi() & BYTE_MASK, randi() & BYTE_MASK, randi() & BYTE_MASK,
	  randi() & BYTE_MASK, randi() & BYTE_MASK, randi() & BYTE_MASK, randi() & BYTE_MASK,
	]

PersistentManager.gd

# This node need to be set in the autoloadtab as "PersistentManager";
# it keep track of all persistent_object across your game and keep data
#
# Remember to save it's data with your game save
extends Node

var persistent_object : Dictionary = {};

# Return if a data with specifie UID exist
func has(uid : String) -> bool:
	return persistent_object.has(uid);

# Return data from a specifie UID. Throw an error if it doesn't exist
func get_object(uid : String):
	assert(has(uid), "No persistent object with specified uid exist : " + uid);
	return persistent_object[uid]

# Save data for the specifie UID.
func save(uid : String, data : Dictionary) -> void:
	persistent_object[uid] = data



# Use this function to load and save data to your game file
# You can use it as it is or change it to include data in your own save system
# Load data before loading a scene containing a persistent object as it load data on ready.

func load_data(file_name: String) -> void:
	var access: FileAccess = FileAccess.open("user://" + file_name, FileAccess.READ)
	var json: String = access.get_as_text()
	persistent_object = JSON.parse_string(json)
	access.close()

func save_data(file_name: String) -> void:
	var json: String = JSON.stringify(persistent_object)
	var access: FileAccess = FileAccess.open("user://" + file_name, FileAccess.WRITE)
	access.store_string(json)
	access.close()

To use this, you need to add PersistentManager as an autoload.
when you want a node to be persistent just add a Persistent_Object node as a child and generate a UID for it from the inspector by clicking the checkbox.
Now, when you want to remove a node when you collect it, instead of calling queue_free() on that node, call destroy() on the Persistent_Object.
It will save the information and automatically delete the node the next time you enter the scene. You can save other data if you want, just look at the comment in the script.

You probably will need to change the save system to incorporate it to your game save data.

Hope this can help

Great! Thank you guys for the pointers! I’ve got enough now to get unstuck from this snag. Thanks!