Save function protocols

Godot Version

4.2.1

Question

`My game’s save system has issues: Changes (like Dialogic signals) don’t persist after loading. How do I keep these changes? Also, for area 2ds that get queue_free, should the save function instead record the scene tree state and delete anything missing during load?
this is the save function:

@tool
class_name SaveGame
extends Node

const SAVE_PATH := "user://savegame.save"
var ENCRYPTION_PASS: String:
	#not important for forum

const MAX_PLAYER_ATTEMPTS := 20
const PLAYER_WAIT_TIME := 0.2

# Signals
signal save_completed(success: bool, source: String, error: String)
signal load_completed(success: bool, error: String)

# Default save data
var content_to_save: Dictionary = {
	"progress": 0.0,
	"current_map": "res://Scenes/gendermaria.tscn",
	"character_position": Vector2.ZERO,
	"character_direction": 1.0,
	"inventory": [],
	"collected_items": {}  # NEW: Track collected items per scene
}

func save_game(source: String = "manual") -> bool:
	_update_save_data()
	print("Saving game...")

	var error := ""
	var success := false
	var file := FileAccess.open_encrypted_with_pass(SAVE_PATH, FileAccess.WRITE, ENCRYPTION_PASS)

	if file:
		file.store_var(content_to_save)
		file.close()
		print("Save successful to: ", SAVE_PATH)
		success = true
	else:
		error = str(FileAccess.get_open_error())
		push_error("Save failed: ", error)

	emit_signal("save_completed", success, source, error)
	return success

func load_game() -> bool:
	if not FileAccess.file_exists(SAVE_PATH):
		push_warning("No save file found")
		emit_signal("load_completed", false, "No save file found")
		return false

	var file := FileAccess.open_encrypted_with_pass(SAVE_PATH, FileAccess.READ, ENCRYPTION_PASS)
	if not file:
		var error := str(FileAccess.get_open_error())
		push_error("Load failed: ", error)
		emit_signal("load_completed", false, error)
		return false

	var data = file.get_var()
	file.close()

	if not data is Dictionary or not data.has("character_position") or data["character_position"] == Vector2.ZERO:
		var error := "Invalid save data format or missing/invalid position"
		push_error(error)
		emit_signal("load_completed", false, error)
		return false

	content_to_save = data
	print("Loaded save data: ", content_to_save)  # Debug
	emit_signal("load_completed", true, "")
	return true

func apply_loaded_data() -> void:
	if content_to_save.has("current_map"):
		await _load_map(content_to_save["current_map"])

	var player = await _get_player()
	if player:
		_restore_player_state(player)
	else:
		push_error("Failed to restore player state: Player not found")

	await _restore_inventory()
	await _restore_collected_items()

func _restore_collected_items() -> void:
	if content_to_save.has("collected_items"):
		var global_state = get_node_or_null("/root/GlobalState")
		if global_state and global_state.has_method("set_collected_items"):
			global_state.set_collected_items(content_to_save["collected_items"])
			print("Restored collected items: ", content_to_save["collected_items"])  # Debug
		else:
			push_warning("GlobalState not found or lacks set_collected_items method")



func _update_save_data() -> void:
	var player = get_tree().get_first_node_in_group("player")
	if player:
		content_to_save["character_position"] = player.position
		print("Saving player position: ", player.position)  # Debug
		# Assume scale is available for Node2D-derived nodes
		if player is Node2D:
			content_to_save["character_direction"] = player.scale.x
			print("Saving player direction: ", player.scale.x)  # Debug
		else:
			push_warning("Player is not a Node2D, skipping direction save")
	else:
		push_warning("Player node not found during save")
	if get_tree().current_scene and get_tree().current_scene.scene_file_path:
		content_to_save["current_map"] = get_tree().current_scene.scene_file_path
		print("Saving current map: ", content_to_save["current_map"])  # Debug
	else:
		push_warning("No current scene found during save")

	if has_node("/root/GlobalState"):
		var global_state = get_node("/root/GlobalState")
		if global_state.has_method("get_collected_items"):
			content_to_save["collected_items"] = global_state.get_collected_items()
			print("Saving collected items: ", content_to_save["collected_items"])  # Debug
		else:
			push_warning("GlobalState lacks get_collected_items method")
	else:
		push_warning("GlobalState singleton not found during save")

	if has_node("/root/ProgressManager"):
		var progress_manager = get_node("/root/ProgressManager")
		if progress_manager.has_method("get_progress"):
			content_to_save["progress"] = progress_manager.get_progress()
			print("Saving progress: ", content_to_save["progress"])  # Debug
		else:
			push_warning("ProgressManager lacks get_progress method")
	else:
		push_warning("ProgressManager singleton not found")

	if has_node("/root/GlobalState"):
		var global_state = get_node("/root/GlobalState")
		if global_state.has_method("get_collected_items"):
			content_to_save["collected_items"] = global_state.get_collected_items()

func _load_map(map_path: String) -> void:
	if not ResourceLoader.exists(map_path):
		var error := "Invalid map path: " + map_path
		push_error(error)
		return

	if get_tree().current_scene and get_tree().current_scene.scene_file_path != map_path:
		var result = get_tree().change_scene_to_file(map_path)
		if result != OK:
			var error := "Failed to load map: " + str(result)
			push_error(error)
			return

		await get_tree().create_timer(0.1).timeout
		print("Loaded map: ", map_path)  # Debug

func _get_player() -> Node:
	var player: Node
	var attempts := 0

	while attempts < MAX_PLAYER_ATTEMPTS:
		player = get_tree().get_first_node_in_group("player")
		if player:
			print("Player found after ", attempts, " attempts")  # Debug
			return player
		attempts += 1
		await get_tree().create_timer(PLAYER_WAIT_TIME).timeout

	var error := "Player node not found after " + str(attempts) + " attempts"
	push_error(error)
	return null

func _restore_player_state(player: Node) -> void:
	if content_to_save.has("character_position"):
		player.position = content_to_save["character_position"]
		print("Restoring player position to: ", player.position)  # Debug
	else:
		push_warning("No character_position in save data")

	if player is Node2D and content_to_save.has("character_direction"):
		player.scale.x = content_to_save["character_direction"]
		print("Restoring player direction to: ", player.scale.x)  # Debug
	else:
		push_warning("Player is not a Node2D or no character_direction in save data")

func _restore_inventory() -> void:
	var global_inv = get_node_or_null("/root/GlobalInventory")
	if not global_inv:
		await get_tree().process_frame  # Yield for one frame
		global_inv = get_node_or_null("/root/GlobalInventory")
	
	if not global_inv:
		push_error("GlobalInventory singleton not found after retry")
		return

	var inventory = get_node("/root/GlobalInventory")
	if inventory and inventory.has_method("set_items"):
		inventory.set_items(content_to_save["inventory"])
		if inventory.has_signal("inventory_updated"):
			inventory.inventory_updated.emit()
		else:
			push_warning("GlobalInventory lacks inventory_updated signal")
	else:
		var error := "GlobalInventory lacks set_items method"
		push_warning(error)

this is the load function which is inside pause script. also shouldnt load, exits scene and then enter it? am I wrong?:

func _on_load_pressed():
print(“Attempting to load game…”)
get_tree().paused = true

if not Saving.load_game():
	print("Error: No save file found or corrupted save!")
	get_tree().paused = false
	return

$load.disabled = true

await Saving.apply_loaded_data()

var saved_position = Saving.content_to_save["character_position"]
var saved_direction = Saving.content_to_save["character_direction"]
var saved_map = Saving.content_to_save["current_map"]

if get_tree().current_scene.scene_file_path != saved_map:
	print("Changing to saved map: ", saved_map)
	get_tree().change_scene_to_file(saved_map)
	
	# Wait for the scene to load
	await get_tree().create_timer(0.5).timeout

# NEW: Refresh items after scene reload
_refresh_items_in_current_scene()

# Restore player position after scene reload
var player = get_tree().get_first_node_in_group("player")
if player:
	player.position = saved_position
	if player is Node2D:
		player.scale.x = saved_direction
	print("Player position restored to: ", player.position)
else:
	push_error("Player node not found after scene reload")
	# Attempt to restore through SaveGame
	await Saving._restore_player_state(await Saving._get_player())

# Refresh skill menu if open
if get_tree().get_nodes_in_group("skill_menu").size() > 0:
	var skill_menu = get_tree().get_first_node_in_group("skill_menu")
	if skill_menu.has_method("update_all_skill_labels"):
		skill_menu.update_all_skill_labels()
		skill_menu.update_level_up_button()

$load.disabled = false
print("Game loaded successfully!")
get_tree().paused = false
hide()

inside the load I have made 1 more function for the items that are inside the level scene that which if a player picks up an item, they get queue free when loaded:

func _refresh_items_in_current_scene():
	var current_scene = get_tree().current_scene
	if not current_scene:
		return
		
	var scene_path = current_scene.scene_file_path
	for item in get_tree().get_nodes_in_group("items"):
		if GlobalState.is_item_collected(scene_path, item.item_id):
			item.queue_free()

My suggestion is going to be implement a new save system. It’ll take a little work, but it will be much easier to debug in the future. It’s also going to result in much simpler code. That advice is to follow the example in the Godot docs, which is to let items track their own save information and not to centralize it.

Anything in the game you want to save, you just add to a Persist (or whatever) global variable. Then for that item, you implement a save_node() and load_node() function. (Since load() is a system function I suggest those names instead.) Then you let the object, which knows how its constructed, save and load the information that is important to itself. The save function then doesn’t have to know any specifics of anything you are saving.

So your code would look something like this:

@tool
class_name SaveGame extends Node

# Signals
# You'll have to tie these in where you want them. They aren't necessary for this implementation.
signal save_completed(success: bool, source: String, error: String)
signal load_completed(success: bool, error: String)

const SAVE_PATH := "user://savegame.save"
#Assuming these would be moved to the Player class
const MAX_PLAYER_ATTEMPTS := 20
const PLAYER_WAIT_TIME := 0.2

var ENCRYPTION_PASS: String:
	#not important for forum
var game_information: Dictionary


## To add a value to be saved, add a node to the "Persist" Global Group on
## the Nodes tab. Then implement save_node() and load_node() functions.
## The first function should return a value to store, and the second should
## use that value to load the information. Make sure the load function has a
## await Signal(self, "ready") line at the top so it doesn't try to load values
## before the node exists. If you need to store multiple values, use a
## dictionary or changes later will result in save/load errors.
func save_game() -> bool:
	var saved_nodes = get_tree().get_nodes_in_group("Persist")
	for node in saved_nodes:
		# Check the node has a save function.
		if not node.has_method("save_node"):
			print("Setting node '%s' is missing a save_node() function, skipped" % node.name)
			continue
		
		game_information[node.name] = node.save_node()
		print("Saving Info for %s: %s" % [node.name, game_information[node.name]])
	return _save_file(game_information, SAVE_PATH)


func load_game() -> void:
	game_information = _load_file(SAVE_PATH)
	if game_information.is_empty():
		return
	var saved_nodes = get_tree().get_nodes_in_group("Persist")
	for node in saved_nodes:
		# Check the node has a load function.
		if not node.has_method("load_node"):
			print("Setting node '%s' is missing a load_node() function, skipped" % node.name)
			continue
		# Check if we have information to load for the value
		if game_information.has(node.name):
			print("Loading Info for %s: %s" % [node.name, game_information[node.name]])
			node.load_node(game_information[node.name])


## Takes data and serializes it for saving.
func _serialize_data(data: Variant) -> String:
	return JSON.stringify(data)


## Takes serialized data and deserializes it for loading.
func _deserialize_data(data: String) -> Variant:
	var json = JSON.new()
	var error = json.parse(data)
	if error == OK:
		return json.data
	else:
		print("JSON Parse Error: ", json.get_error_message(), " in ", data, " at line ", json.get_error_line())
	return null


func _save_file(save_information: Dictionary, path: String) -> bool:
	var file = FileAccess.open(path, FileAccess.WRITE)
	if file == null:
		print("File '%s' could not be opened. File not saved." % path)
		return false
	file.store_var(save_information)
	return true


func _load_file(path: String) -> Variant:
	if not FileAccess.file_exists(path):
		print("File '%s' does not exist. File not loaded." % path)
		var return_value: Dictionary = {}
		return return_value
	var file = FileAccess.open(path, FileAccess.READ)
	return file.get_var()

You can add this to your code to save on quit.

func _notification(what) -> void:
	match what:
		NOTIFICATION_WM_CLOSE_REQUEST: #Called when the application quits.
			save_game()

This will work when someone closes the game window in their OS by pressing the X button in the upper right-hand corner. If you want it to work with your own quit buttons/menus you’ll need them to call something like this.

func quit() -> void:
	get_tree().root.propagate_notification(NOTIFICATION_WM_CLOSE_REQUEST)
	get_tree().quit()

Then you move all the rest of that code out and put it in the things that save stuff. For example all your player save state code goes in the player. Same for your progress manager, dialogic code, etc.

For something simple like an item a player picks up it could be something like this. (This is code from a 2D Metroidvania I made.)

class_name Pickup extends Area2D

@export var pickup_scene: PackedScene
@export var pickup_sound: SoundEffect
@export var display_size: Vector2 = Vector2(0.5, 0.5)

var pickup: Sprite2D
var is_collected = false

func _ready() -> void:
	pickup = pickup_scene.instantiate()
	add_child(pickup)
	pickup.display(Vector2(0,0), display_size)
	body_entered.connect(_collect_item)


# Save that the player has already picked this up.
func save_node() -> bool:
	return is_collected


# This pickup has already bee collected. Remove it.
func load_node(collected: bool) -> void:
	if collected:
		queue_free()


func _collect_item(body: Node2D) -> void:
	if pickup_sound:
		Sound.play_sound_effect(pickup_sound)
	body.add_item(pickup_scene)
	is_collected = true
	Disk.save_game()
	remove_child.call_deferred(pickup)
	queue_free.call_deferred()

All you store for that item is whether or not it’s been collected by the player. Obviously if this is a persistent world, it becomes more complex. You’ll note that anytime something is picked up save_game() is called. You can do the same for dialogic branches. They don’t need to be complex. The save system packs them all into one file regardless of whether you pass it a Dictionary, bool, String, etc.

For something like the player it’s going to be more complex. But again, using my Metroidvania player as an example, this is how you’d pack everything in and pull it back out. You seem capable of adapting this for your own player.

# Save player stats.
func save_node() -> Dictionary:
	var save_data: Dictionary = {
		"health": health,
		"max_health": max_health,
		"max_jumps": max_jumps,
		"current_thrown_weapon": current_thrown_weapon,
		"number_of_potions": number_of_potions,
		"max_number_of_potions": max_number_of_potions,
	}
	
	var weapon_list: Array[String]
	for weapon in thrown_weapons:
		var instance = weapon.instantiate()
		weapon_list.append(instance.name.to_snake_case())
	save_data["weapon_list"] = weapon_list
	
	return save_data


# Load player stats.
func load_node(save_data: Dictionary) -> void:
	if save_data:
		max_health = save_data["max_health"]
		health = save_data["health"]
		max_jumps = save_data["max_jumps"]
		current_thrown_weapon = save_data["current_thrown_weapon"]
		number_of_potions = save_data["number_of_potions"]
		max_number_of_potions = save_data["max_number_of_potions"]
		Game.number_of_healing_potions_changed.emit(number_of_potions)
		
		thrown_weapons.clear()
		for weapon in save_data["weapon_list"]:
			var packed_scene: PackedScene = load(WEAPON_PATH + weapon + ".tscn")
			thrown_weapons.append(packed_scene)
		
		Game.player_new_item_gained.emit(thrown_weapons.size())
		
		_equip_current_thrown_weapon()

This is a refactor. But once you’re done, I believe you’ll find it much easier to update save/load data in the future. Instead of searching through that monolithic code block, you can just find the thing that needs more data and update its functions. It will get rid of the need for signals too I believe. But, if not you can add those in to the example code I gave you easily enough.

In the examples I gave I used the GDScript Style Guide Code Order. The examples were all from one of my games, but the full save/load code is actually a plugin called Disk if you want to use it. It also handles settings and some potential race condition problems.

1 Like

well the stats and inventory are global. HP is this:

extends ProgressBar

@onready var effect = $"../DmgEffect"
@onready var timer = $"../../Timer"
@onready var hp_empty = $"../HpNumberEmpty"
@onready var health_label = $"../../Label"
@onready var hp = $".."
@onready var bar = self  # Direct reference to self, clearer than $"."

# Cached colors for health states
const COLOR_FULL = Color(0, 0.439, 0)
const COLOR_MID = Color(0.949, 0.875, 0.063)
const COLOR_LOW = Color(0.639, 0, 0.027)

func _ready() -> void:
	# Connect health_changed signal if HpMofatesh is available
	if HpMofatesh and not HpMofatesh.is_connected("health_changed", update_health):
		HpMofatesh.connect("health_changed", update_health)
		update_health(HpMofatesh.player_health)
	
	# Initialize visibility based on pause state
	visible = !get_tree().paused
	call_deferred("_connect_pause_signal")
	print("_ready called, initialized visibility. Paused: ", get_tree().paused)

func _connect_pause_signal() -> void:
	if is_instance_valid(get_tree()):
		get_tree().set_meta("pause_signal_connected", true)
		print("Ready to handle pause changes")
	else:
		push_error("SceneTree invalid during signal connection")

func _notification(what: int) -> void:
	if what == NOTIFICATION_PAUSED:
		visible = false
	elif what == NOTIFICATION_UNPAUSED:
		visible = true

func _process(delta: float) -> void:
	# Skip processing if paused
	if get_tree().paused:
		return
	
	# Only check hp validity if necessary (e.g., if dynamic node changes are expected)
	if not is_instance_valid(hp):
		push_error("HUD (hp) node is not valid in _process.")
		set_process(false)  # Disable _process to prevent repeated errors
		return

func update_health(new_health: float) -> void:
	if get_tree().paused or not is_instance_valid(self):
		return

	# Update progress bar value
	value = new_health

	# Handle damage effect
	if effect and effect.is_playing():
		effect.stop()
	if effect:
		effect.show()
		effect.play("default")
		if timer:
			timer.start()

	# Update health label
	if health_label:
		health_label.text = "%03d" % new_health  # Simplified formatting

	# Handle visibility of empty indicator and label
	var is_full_health = abs(new_health - 100.0) < 0.01
	if hp_empty:
		hp_empty.visible = not is_full_health
	if health_label:
		health_label.visible = not is_full_health

	# Update colors and animations
	if hp and bar:
		if new_health >= 70.0:
			hp.play("full")
			bar.modulate = COLOR_FULL
			bar.self_modulate = COLOR_FULL
		elif new_health > 40.0:
			hp.play("mid")
			bar.modulate = COLOR_MID
			bar.self_modulate = COLOR_MID
		else:
			hp.play("low")
			bar.modulate = COLOR_LOW
			bar.self_modulate = COLOR_LOW

func _on_timer_timeout() -> void:
	if effect:
		effect.hide()
		effect.stop()

the game is level based so its not a persistent world.

sometimes I hate coding… when I code myself into a corner for example XD

First, some streamlining:

func _notification(what: int) -> void:
	if what == NOTIFICATION_PAUSED:
		visible = false
	elif what == NOTIFICATION_UNPAUSED:
		visible = true
	set_process(viisble)

func _process(delta: float) -> void:
	# Only check hp validity if necessary (e.g., if dynamic node changes are expected)
	if not is_instance_valid(hp):
		push_error("HUD (hp) node is not valid in _process.")
		set_process(false)  # Disable _process to prevent repeated errors

Based ona quick glance it looks like everything is derived from hp, which is a different node, so the save/load should go there.

As Brené Brown points out, failure stops being a failure and becomes a learning experience when we let it teach us.

I’ve been coding professionally for decades. I still had a lot to learn when I started using Godot. Every day I’m learning new things. Just today I found out in another post that an Area2D has functionality that lets you alter the gravity while someone is inside it with just a few clicks.

Where would I put this streamline? in the save?

well just hp and its hud is there.
map and dialogue signals (for dialogic) are still my big concern.
this is the code for a dialogue:

func _on_monshi_body_entered(body):
	if body.name=="player" and monshi==false:
		here=true
		string="res://Timelines/timeline.dtl"
		Dialogic.signal_event.connect(dia)
		
	elif body.name=="player" and monshi==true:
		here=true
		string="res://Portrait/timeline2.dtl"

true, and without learning life wouldnt be as interesting. im a bit of novice coder and lately I got a little good but save function is entirely a new concept for me and im just stuck in it for weeks now

Yeah I just rewrote those two functions for you. Just replace them. They will work the same.

We can simplify this one too. If your player’s script has a class name, you can trigger off that. Just make sure something like this is at the top of the script.

class_name Player extends CharacterBody2D

Then you can do this. (I also simplified your if/else to be cleaner to read.)

func _on_monshi_body_entered(body):
	if body is Player:
		if monshi:
			string="res://Portrait/timeline2.dtl"
		else:
			string="res://Timelines/timeline.dtl"
			Dialogic.signal_event.connect(dia)
		here = true

I’m not super familiar with Dialogic. I only used it once. But when you get to the end of the dialogue and don’t want it repeated, set a variable on where ever that script is called. Then save it. So make a boolean like…

var converstaion_over := false

func _on_monshi_body_entered(body):
	if converstaion_over:
		return
	if body is Player:
		if monshi:
			string="res://Portrait/timeline2.dtl"
		else:
			string="res://Timelines/timeline.dtl"
			Dialogic.signal_event.connect(dia)
		here = true

func save_node() -> boolean:
	return conversation_over

I just send a signal at the end of the dialogue.


like this. each timeline has a seperate signal obviously. so instead of emitting a signal I just add a boolean or a diffrent variable? should it be in a global script?

I’d recommend you just listen for that signal and value within your script. Then save the value there.

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.