Save and Load - Checkpoint Godot 4

Hello everyone!

I’m new to game development and recently started developing my first game, a simple 2D game to better understand the platform’s functionalities.

I’m having trouble adjusting the checkpoint saving system in my game. I’ve tried using tutorials and other internet answers, but it’s not working, as if the checkpoint isn’t saving to the “user://” file.

My current code looks like this; I removed some parts as I was almost giving up on implementing save/load.

Can you help me? Thank you in advance for your attention!

title_screen.gd (main scene archive):

extends Control

# Called when the node enters the scene tree for the first time.
func _ready():
	pass # Não é necessário implementar nada aqui


# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
	pass # Não é necessário implementar nada aqui


func _on_start_btn_pressed():
	var checkpoint_position = load_checkpoint()
	if checkpoint_position != null:
		Globals.player.position = checkpoint_position
		# Transição para a cena do checkpoint carregado
		get_tree().change_scene(Globals.current_checkpoint_scene)
	else:
		# Código para iniciar o jogo normalmente caso não haja um checkpoint salvo
		get_tree().change_scene("res://levels/world_01.tscn")


func _on_new_btn_pressed():
	get_tree().change_scene("res://levels/world_01.tscn")


func _on_credits_btn_pressed():
	# Implemente conforme necessário
	pass


func _on_quit_btn_pressed():
	save_checkpoint()
	get_tree().quit()

checkpoint.gd:

extends Area2D

var is_active = false

func _on_body_entered(body):
	if body.name == "player" and !is_active:
		activate_checkpoint()

func activate_checkpoint():
	Globals.current_checkpoint = self
	Globals.current_checkpoint_scene = get_tree().current_scene
	is_active = true

globals.gd (archive global):

extends Node

var player = null
var current_checkpoint = null
var current_checkpoint_scene = ""
var checkpoint_save_path = "user://checkpoint.sav"

func _ready():
	load_checkpoint_from_file()

func respawn_player():
	if current_checkpoint != null:
		player.position = current_checkpoint.global_position

func load_checkpoint():
	if current_checkpoint != null:
		return current_checkpoint.global_position
	return null

func save_checkpoint():
	if current_checkpoint != null:
		var file = File.new()
		file.open(checkpoint_save_path, File.WRITE)
		file.store_string(current_checkpoint_scene + "\n")
		file.store_string(str(current_checkpoint.global_position.x) + "," + str(current_checkpoint.global_position.y) + "\n")
		file.close()

func load_checkpoint_from_file():
	if not File.exists(checkpoint_save_path):
		return
	
	var file = File.new()
	file.open(checkpoint_save_path, File.READ)
	if file.eof_reached:
		file.close()
		return
	
	Globals.current_checkpoint_scene = file.get_line().strip_edges()
	var pos_str = file.get_line().strip_edges()
	file.close()
	
	var pos = pos_str.split(",")
	if pos.size() == 2:
		current_checkpoint = Vector2(float(pos[0]), float(pos[1]))

	file.close()

Thank you for your help! :wink:

I don’t see FileAccess or “user://” anywhere in this script.

I had deleted the logic because it wasn’t working, when converting to Android, closing and opening the game, it returned to the first scene (world_01), my last attempt was as follows:

title_screen (main):

extends Control

# Called when the node enters the scene tree for the first time.
func _ready():
	pass # Não é necessário implementar nada aqui


# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
	pass # Não é necessário implementar nada aqui


func _on_start_btn_pressed():
	var checkpoint_position = load_checkpoint()
	if checkpoint_position != null:
		Globals.player.position = checkpoint_position
		# Transição para a cena do checkpoint carregado
		get_tree().change_scene(Globals.current_checkpoint_scene)
	else:
		# Código para iniciar o jogo normalmente caso não haja um checkpoint salvo
		get_tree().change_scene("res://levels/world_01.tscn")


func _on_new_btn_pressed():
	get_tree().change_scene("res://levels/world_01.tscn")


func _on_credits_btn_pressed():
	# Implemente conforme necessário
	pass


func _on_quit_btn_pressed():
	save_checkpoint()
	get_tree().quit()

checkpoint.gd:

extends Area2D

var is_active = false

func _on_body_entered(body):
	if body.name == "player" and !is_active:
		activate_checkpoint()

func activate_checkpoint():
	Globals.current_checkpoint = self
	Globals.current_checkpoint_scene = get_tree().current_scene
	is_active = true

globals.gd (archive global):

extends Node

var player = null
var current_checkpoint = null
var current_checkpoint_scene = ""
var checkpoint_save_path = "user://checkpoint.sav"

func _ready():
	load_checkpoint_from_file()

func respawn_player():
	if current_checkpoint != null:
		player.position = current_checkpoint.global_position

func load_checkpoint():
	if current_checkpoint != null:
		return current_checkpoint.global_position
	return null

func save_checkpoint():
	if current_checkpoint != null:
		var file = File.new()
		file.open(checkpoint_save_path, File.WRITE)
		file.store_string(current_checkpoint_scene + "\n")
		file.store_string(str(current_checkpoint.global_position.x) + "," + str(current_checkpoint.global_position.y) + "\n")
		file.close()

func load_checkpoint_from_file():
	if not File.exists(checkpoint_save_path):
		return
	
	var file = File.new()
	file.open(checkpoint_save_path, File.READ)
	if file.eof_reached:
		file.close()
		return
	
	Globals.current_checkpoint_scene = file.get_line().strip_edges()
	var pos_str = file.get_line().strip_edges()
	file.close()
	
	var pos = pos_str.split(",")
	if pos.size() == 2:
		current_checkpoint = Vector2(float(pos[0]), float(pos[1]))

	file.close()

You’ve selected godot-4 as a tag, but File was renamed to FileAccess in 4.x

What errors are you getting?

Instead of storing position as a string you can use store_var and get_var on a Vector2

The error retorned is:

Static function "exists()" not found in base "GDScriptNativeClass".
Linha 17:Identifier "File" not declared in the current scope.
Linha 18:Identifier "File" not declared in the current scope.
Linha 28:Static function "exists()" not found in base "GDScriptNativeClass".

Should it be correct or is it still wrong?

extends Node

var player = null
var current_checkpoint = null
var current_checkpoint_scene = ""
var checkpoint_save_path = "user://checkpoint.sav"

func _ready():
    load_checkpoint_from_file()

func respawn_player():
    if current_checkpoint != null:
        player.position = current_checkpoint.global_position

func load_checkpoint():
    if current_checkpoint != null:
        return {
            "position": current_checkpoint.global_position,
            "scene": current_checkpoint_scene
        }
    return null

func save_checkpoint():
    if current_checkpoint != null:
        var file = FileAccess.open(checkpoint_save_path, FileAccess.WRITE)
        file.store_line(current_checkpoint_scene)
        file.store_line(str(current_checkpoint.global_position.x) + "," + str(current_checkpoint.global_position.y))
        file.close()

func load_checkpoint_from_file():
    if not FileAccess.file_exists(checkpoint_save_path):
        return
    
    var file = FileAccess.open(checkpoint_save_path, FileAccess.READ)
    if file.eof_reached():
        file.close()
        return
    
    Globals.current_checkpoint_scene = file.get_line().strip_edges()
    var pos_str = file.get_line().strip_edges()
    file.close()
    
    var pos = pos_str.split(",")
    if pos.size() == 2:
        current_checkpoint = Vector2(pos[0].to_float(), pos[1].to_float())

This looks correct now.

It didn’t work. I updated some parts of the code, but for some reason, when starting the game, my character isn’t spawned at the specified location in the 2D environment, but rather in a different location, or not displayed at all. So, I couldn’t even test if the save and load system is working correctly.

Error Invalid set index 'global_position' (on base: 'ChacterBody2D (player.gd)')with value of type 'Nil'

The current code:

title_screen.gd (main):

extends Control

func _ready():
    pass # Não é necessário implementar nada aqui

func _on_start_btn_pressed():
    var checkpoint_data = Globals.load_checkpoint()
    if checkpoint_data != null:
        Globals.player.position = checkpoint_data.position
        get_tree().change_scene_to_file(checkpoint_data.scene)
    else:
        get_tree().change_scene_to_file("res://levels/world_01.tscn")

func _on_new_btn_pressed():
    get_tree().change_scene_to_file("res://levels/world_01.tscn")

func _on_credits_btn_pressed():
    # Implemente conforme necessário
    pass

func _on_quit_btn_pressed():
    Globals.save_checkpoint()
    get_tree().quit()

checkpoint.gd:

extends Area2D

var is_active = false

func _on_body_entered(body):
    if body.name == "player" and not is_active:
        activate_checkpoint()

func activate_checkpoint():
    Globals.current_checkpoint = self
    Globals.current_checkpoint_scene = get_tree().current_scene.get_path()
    is_active = true
    Globals.save_checkpoint()

globals.gd (global):

extends Node

var player = null
var current_checkpoint = null
var current_checkpoint_scene = ""
var checkpoint_save_path = "user://checkpoint.sav"

func _ready():
    load_checkpoint_from_file()

func respawn_player():
    if current_checkpoint != null:
        player.position = current_checkpoint.global_position

func load_checkpoint():
    if current_checkpoint != null:
        return {
            "position": current_checkpoint.global_position,
            "scene": current_checkpoint_scene
        }
    return null

func save_checkpoint():
    if current_checkpoint != null:
        var file = FileAccess.open(checkpoint_save_path, FileAccess.WRITE)
        file.store_line(current_checkpoint_scene)
        file.store_line(str(current_checkpoint.global_position.x) + "," + str(current_checkpoint.global_position.y))
        file.close()

func load_checkpoint_from_file():
    if not FileAccess.file_exists(checkpoint_save_path):
        return
    
    var file = FileAccess.open(checkpoint_save_path, FileAccess.READ)
    if file.eof_reached():
        file.close()
        return
    
    Globals.current_checkpoint_scene = file.get_line().strip_edges()
    var pos_str = file.get_line().strip_edges()
    file.close()
    
    var pos = pos_str.split(",")
    if pos.size() == 2:
        current_checkpoint = Vector2(pos[0].to_float(), pos[1].to_float())

goal.gd:

extends Area2D

@onready var transition = $../transition
@export var next_level: String = ""

func _on_body_entered(body):
    if body.name == "player" and next_level != "":
        transition.change_scene_to_file(next_level)
    else:
        print("No Scene Loaded")

That error says the loaded value ends up as ‘Nil’ or nothing, you could print out the pos_str or pos[0] and pos[1] but I think you should use store_var and get_var as I mentioned before for global_position.

Can you update the part of the code you mentioned? I don’t have much knowledge about it; I’m getting a good part of the code from the internet.

Replace store_line with more appropriate, but binary functions.

func save_checkpoint():
    if current_checkpoint != null:
        var file = FileAccess.open(checkpoint_save_path, FileAccess.WRITE)
        file.store_pascal_string(current_checkpoint_scene)
        file.store_var(current_checkpoint.global_position)
        file.close()

func load_checkpoint_from_file():
    if not FileAccess.file_exists(checkpoint_save_path):
        return
    
    var file = FileAccess.open(checkpoint_save_path, FileAccess.READ)
    if file.eof_reached():
        file.close()
        return
    
    Globals.current_checkpoint_scene = file.get_pascal_string()
    var pos := file.get_var() as Vector2
    file.close()
    
    current_checkpoint = pos

Hello bro! I made the corrections and performed the tests, but again it didn’t work, it didn’t return any errors. I added some new lines of code with “print”, but it returns “Checkpoint file does not exist”. I will provide here all the relevant files of the game.

checkpoint.gd:

extends Area2D

var is_active = false

func _on_body_entered(body):
	if body.name == "player" and not is_active:
		activate_checkpoint()

func activate_checkpoint():
	Globals.current_checkpoint = self
	Globals.current_checkpoint_scene = get_tree().current_scene.get_path()
	is_active = true
	Globals.save_checkpoint()

globals.gd (Global):

extends Node

var player = null
var current_checkpoint = null
var current_checkpoint_scene = ""
var checkpoint_save_path = "user://checkpoint.sav"

func _ready():
	print("Globals script ready")  # Adicione uma mensagem para depuração
	load_checkpoint_from_file()

func _exit_tree():
	print("Tree exiting, saving checkpoint")  # Adicione uma mensagem para depuração
	save_checkpoint()

func respawn_player():
	if current_checkpoint != null:
		player.position = current_checkpoint.global_position

func load_checkpoint():
	if current_checkpoint != null:
		return {
			"position": current_checkpoint.global_position,
			"scene": current_checkpoint_scene
		}
	return null

func save_checkpoint():
	if current_checkpoint != null:
		var file = FileAccess.open(checkpoint_save_path, FileAccess.WRITE)
		if file:
			file.store_pascal_string(current_checkpoint_scene)
			file.store_var(current_checkpoint.global_position)
			file.close()
			print("Checkpoint saved: ", current_checkpoint_scene, current_checkpoint.global_position)
		else:
			print("Error opening file for writing")

func load_checkpoint_from_file():
	if not FileAccess.file_exists(checkpoint_save_path):
		print("Checkpoint file does not exist")
		return
	
	var file = FileAccess.open(checkpoint_save_path, FileAccess.READ)
	if file:
		current_checkpoint_scene = file.get_pascal_string()
		var pos := file.get_var() as Vector2
		file.close()
		
		current_checkpoint = pos
		print("Checkpoint loaded: ", current_checkpoint_scene, pos)
	else:
		print("Error opening file for reading")

goal.gd:

extends Area2D

@onready var transition = $"../transition"
@export var next_level : String = ""

func _on_body_entered(body):
	if body.name == "player" and !next_level == "":
		transition.change_scene(next_level)
	else:
		print("No Scene Loaded")

player.gd:

extends CharacterBody2D


const SPEED = 130.0
const JUMP_VELOCITY = -300.0
var direction
# Get the gravity from the project settings to be synced with RigidBody nodes.
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
var is_jumping := false

signal player_has_died()

@onready var animation := $anim as AnimatedSprite2D
@onready var remote_transform := $remote as RemoteTransform2D
@onready var rip_effect = $rip_effect as AudioStreamPlayer
@onready var timer = $Timer

func _physics_process(delta):
	# Add the gravity.
	if not is_on_floor():
		velocity.y += gravity * delta

	# Handle jump.
	if Input.is_action_just_pressed("ui_up") and is_on_floor():
		velocity.y = JUMP_VELOCITY
		is_jumping = true
	elif is_on_floor():
		is_jumping = false

	# Get the input direction and handle the movement/deceleration.
	# As good practice, you should replace UI actions with custom gameplay actions.
	direction = Input.get_axis("ui_left", "ui_right")
	if direction != 0:
		velocity.x = direction * SPEED
		animation.scale.x = direction
	else:
		velocity.x = move_toward(velocity.x, 0, SPEED)

	_set_state()
	move_and_slide()


func _on_hurtbox_body_entered(body):
	if body.is_in_group("enemies"):
		timer.wait_time = 0.5
		timer.one_shot = true
		rip_effect.play()
		timer.start()
		# Aguarda o Timer completar
		await timer.timeout
		emit_signal("player_has_died")
		get_tree().reload_current_scene()
		
		
func follow_camera(camera):
	var camera_path = camera.get_path()
	remote_transform.remote_path = camera_path
	
func _set_state():
	var state = "idle"
	
	if !is_on_floor():
		state = "jump"
	elif direction != 0:
		state = "run"
	
	if animation.name != state:
		animation.play(state)
		
func _input(event):
	if event is InputEventScreenTouch:
		if Input.is_action_just_pressed("ui_up") and is_on_floor():
			velocity.y = JUMP_VELOCITY
			is_jumping = true
		elif is_on_floor():
			is_jumping = false

title_screen.gd (Main Scene):

extends Control

func _ready():
	pass # Não é necessário implementar nada aqui

func _on_start_btn_pressed():
	var checkpoint_data = Globals.load_checkpoint()
	if checkpoint_data != null:
		Globals.player.position = checkpoint_data.position
		get_tree().change_scene_to_file(checkpoint_data.scene)
	else:
		get_tree().change_scene_to_file("res://levels/world_01.tscn")

func _on_new_btn_pressed():
	get_tree().change_scene_to_file("res://levels/world_01.tscn")

func _on_credits_btn_pressed():
	# Implemente conforme necessário
	pass

func _on_quit_btn_pressed():
	Globals.save_checkpoint()
	get_tree().quit()

transition.gd:

extends CanvasLayer

@onready var color_rect = $color_rect

func _ready():
	show_new_scene()

func change_scene(path: String, delay: float = 1.5):
	var scene_transition = get_tree().create_tween()
	scene_transition.tween_property(color_rect, "threshold", 1.0, 0.5).set_delay(delay)
	await scene_transition.finished
	assert(get_tree().change_scene_to_file(path) == OK)
	
func show_new_scene():
	var show_transition = get_tree().create_tween()
	show_transition.tween_property(color_rect, "threshold", 0.0, 0.5).from(1.0)

world.gd (Base/General):

extends Node2D

@onready var player := $player as CharacterBody2D
@onready var player_scene = preload("res://levels/player.tscn")

@onready var camera := $camera as Camera2D
# Called when the node enters the scene tree for the first time.
func _ready():
	Globals.player = player
	Globals.player.follow_camera(camera)
	Globals.player.player_has_died.connect(reload_game)


# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
	pass

func reload_game():
	await get_tree().create_timer(1.0).timeout
	var player = player_scene.instantiate()
	add_child(player)
	Globals.player = player
	Globals.player.follow_camera(camera)
	Globals.player.player_has_died.connect(reload_game)
	Globals.respawn_player()
	#get_tree().reload_current_scene()

image