Quick answer: @export is a design-time tool that stores values in .tscn/.tres files for the editor. It is not a save-game system. To persist data between play sessions, use ConfigFile for simple data or ResourceSaver with a custom Resource subclass for complex state.
You add @export var high_score: int = 0 to your player script, set it to 9999 from code while the game runs, quit, reopen the project—and the value is back to zero. Or you set a value in the Inspector, run the game, and your runtime code resets it. The @export annotation seems like it should handle persistence, but it doesn’t do what you might expect.
The Symptom
One of two scenarios describes almost every report of this problem:
- Scenario A: You set an
@exportvariable to a new value at runtime (from game code), then quit and reopen. The value has reset to whatever was in the Inspector. - Scenario B: You set a value in the Inspector, run the game, and script code in
_ready()overwrites it with a hardcoded default. - Scenario C: You call
ResourceSaver.save()on a node, reopen the scene, and the runtime-changed@exportvariable is still showing its old editor value.
All three stem from a misunderstanding of what @export actually does.
What @export Actually Does
@export does two things and only two things: it exposes a variable in the Godot Inspector, and it saves that variable’s value into the .tscn or .tres file when you save the scene in the editor. That’s it. The annotation has nothing to do with runtime persistence or save games.
The value stored in the scene file is the initial value the node starts with when the scene loads. Think of it as a configurable default. If your game code changes the variable during play, that change lives only in memory. When the scene is freed and reloaded—or the game closes and reopens—the variable resets to the value in the scene file.
This is exactly how it is supposed to work. The confusion arises because the term “save” has two very different meanings in Godot: saving a scene file in the editor, and saving game state at runtime.
The Common Mistake: Setting @export Values at Runtime
Here is the pattern that causes Scenario A:
extends Node
@export var high_score: int = 0
func _on_game_over(final_score: int) -> void:
if final_score > high_score:
high_score = final_score # Only saves in memory — lost on quit!
The variable is modified in memory but the .tscn file on disk is never updated. Restarting the game reloads the scene from disk and the value resets. To actually save this data, you need to write it to a file explicitly.
And here is what causes Scenario B—overwriting a designer-set value in _ready():
extends CharacterBody3D
@export var move_speed: float = 5.0
func _ready() -> void:
move_speed = 5.0 # This silently overwrites whatever was set in the Inspector!
If you set move_speed to 8.0 in the Inspector, the _ready() assignment immediately replaces it with 5.0 every time the scene loads. Remove the assignment in _ready()—the @export annotation already handles the default value.
The Fix: Using ConfigFile for Persistent Data
ConfigFile is the simplest way to save and load player data between sessions. It writes an INI-style text file to the user:// path, which maps to the correct OS-specific user data directory on all platforms (AppData on Windows, ~/.local on Linux, ~/Library on macOS).
extends Node
const SAVE_PATH = "user://savegame.cfg"
var high_score: int = 0
var player_name: String = "Player"
var level_unlocked: int = 1
func save_game() -> void:
var config = ConfigFile.new()
config.set_value("player", "high_score", high_score)
config.set_value("player", "name", player_name)
config.set_value("progress", "level_unlocked", level_unlocked)
var err = config.save(SAVE_PATH)
if err != OK:
push_error("Failed to save game: " + str(err))
func load_game() -> void:
var config = ConfigFile.new()
var err = config.load(SAVE_PATH)
if err != OK:
push_warning("No save file found, using defaults.")
return
high_score = config.get_value("player", "high_score", 0)
player_name = config.get_value("player", "name", "Player")
level_unlocked = config.get_value("progress", "level_unlocked", 1)
The third argument to get_value() is the default, returned if the key doesn’t exist yet. This means the first run of the game (no save file) gracefully falls back to sensible defaults.
Using ResourceSaver for Complex Game State
For more structured data—an inventory of items with quantities and modifiers, a world state with many interdependent flags, or configuration that needs to be inspectable in the Godot editor—define a custom Resource subclass and serialize it with ResourceSaver.
# save_data.gd — a Resource subclass
extends Resource
class_name SaveData
@export var high_score: int = 0
@export var player_name: String = "Player"
@export var inventory: Array[String] = []
@export var level_unlocked: int = 1
# In your game manager
const SAVE_PATH = "user://savegame.tres"
var save_data: SaveData
func _ready() -> void:
load_game()
func save_game() -> void:
var err = ResourceSaver.save(save_data, SAVE_PATH)
if err != OK:
push_error("Save failed: " + str(err))
func load_game() -> void:
if ResourceLoader.exists(SAVE_PATH):
save_data = ResourceLoader.load(SAVE_PATH) as SaveData
else:
save_data = SaveData.new() # First run: use fresh defaults
With this pattern, the @export annotations on SaveData control what gets serialized to the .tres file. Changes made at runtime are persisted by calling ResourceSaver.save() explicitly—the serialization happens on demand, not automatically.
The @export_storage Annotation
Godot 4.3+ added @export_storage, which marks a variable for serialization (included in ResourceSaver output) but does not display it in the Inspector. This is useful for internal state that should be part of the saved resource but that designers don’t need to touch:
extends Resource
class_name EnemyState
@export var enemy_type: String = "goblin" # Shown in Inspector
@export var max_health: int = 100 # Shown in Inspector
@export_storage var current_health: int = 100 # Serialized but hidden in Inspector
@export_storage var status_effects: Array = [] # Serialized but hidden in Inspector
Without @export_storage, variables that are not annotated with @export are not serialized at all when ResourceSaver.save() is called. They live only in memory.
Keeping @export for Its Intended Purpose
The right mental model: @export is for design-time configuration. It lets level designers, artists, and other team members tune values in the Inspector without touching code. Move speed, damage values, audio bus assignments, scene references, color thresholds—these are all excellent uses for @export.
Player progress, save game state, high scores, settings that the player changes at runtime—these belong in a dedicated save system using ConfigFile, FileAccess, or ResourceSaver. Keep the two concerns separate and neither will surprise you.
extends CharacterBody3D
# Design-time config — set in Inspector, never changed at runtime
@export var move_speed: float = 5.0
@export var jump_height: float = 4.0
@export var footstep_sound: AudioStream
# Runtime state — managed by save system, not @export
var health: int = 100
var coins_collected: int = 0
Related Issues
For issues with values set in the Inspector affecting multiple instances of the same scene, see Fix: Godot Shader Uniform Not Updating at Runtime—the shared resource problem is the same pattern. If your game state is being reset because signals that should save it aren’t firing, see Fix: Godot Signal.disconnect() Throwing “Invalid Connection” Error.
@export saves your value into the scene file—not into the player’s save file.