---
description: Implement save and load — settings, game state, slots, migration
---
# /godot-save — Sauvegarde : settings, save/load, persistence

> **Synonymes** : save, load, sauvegarde, chargement, settings, config, persistence, configfile, json

Tu es un expert en persistence de données pour Godot 4.x. Ton rôle : implémenter des systèmes de sauvegarde robustes — settings via ConfigFile, game saves via JSON, slots, encryption, migration.

---

## Sommaire

1. Chemins de données
2. SettingsManager (ConfigFile)
3. SaveSystem — Architecture
4. Sérialisation JSON
5. Save Slots
6. Encryption & Sécurité
7. Migration de sauvegardes
8. Checklist Review
9. Pièges courants

---

## Philosophie

- **Séparation settings / game saves** — deux systèmes distincts, deux fichiers
- **Defaults explicites** — chaque setting a une valeur par défaut en code
- **Fail-safe** — si le fichier est corrompu, fallback sur les defaults
- **Simple d'abord** — ConfigFile pour les settings, JSON pour les saves

---

## 1. Chemins de données

### res:// vs user://

| Chemin | Usage | Write en export |
|--------|-------|----------------|
| `res://` | Assets du projet | ❌ Read-only |
| `user://` | Saves, settings, logs | ✅ Read-write |

### Chemins par OS

| OS | user:// réel |
|------|-------------|
| Windows | `%APPDATA%/Godot/app_userdata/<project_name>/` |
| macOS | `~/Library/Application Support/Godot/app_userdata/<project_name>/` |
| Linux | `~/.local/share/godot/app_userdata/<project_name>/` |

### Debug path

```gdscript
func _ready():
    print("user:// path: ", OS.get_user_data_dir())
```

**Note importante** : en export, le chemin utilise le nom du projet défini dans Project Settings → Application → Config → Name.

---

## 2. SettingsManager (ConfigFile)

### Pourquoi ConfigFile

- Format INI-like, human-readable
- Éditable à la main pour debug
- API simple : sections + clés
- Fail-safe natif

### Code complet — Autoload settings_manager.gd

```gdscript
extends Node

const SETTINGS_PATH = "user://settings.cfg"

var _config := ConfigFile.new()
var _defaults := {
    "audio/master_volume": 1.0,
    "audio/music_volume": 0.8,
    "audio/sfx_volume": 1.0,
    "audio/mute": false,
    "graphics/fullscreen": false,
    "graphics/vsync": true,
    "graphics/quality": "medium",
    "controls/mouse_sensitivity": 0.5,
}

func _ready():
    load_settings()

func get_setting(key: String) -> Variant:
    var parts = key.split("/")
    var section = parts[0]
    var name = parts[1]
    return _config.get_value(section, name, _defaults.get(key))

func set_setting(key: String, value: Variant):
    var parts = key.split("/")
    _config.set_value(parts[0], parts[1], value)
    save_settings()

func save_settings():
    _config.save(SETTINGS_PATH)

func load_settings():
    var err = _config.load(SETTINGS_PATH)
    if err != OK:
        # First launch or corrupted file — use defaults
        for key in _defaults:
            var parts = key.split("/")
            _config.set_value(parts[0], parts[1], _defaults[key])
        save_settings()

func reset_to_defaults():
    _config = ConfigFile.new()
    for key in _defaults:
        var parts = key.split("/")
        _config.set_value(parts[0], parts[1], _defaults[key])
    save_settings()
```

### Usage

```gdscript
# Set
SettingsManager.set_setting("audio/master_volume", 0.5)

# Get
var volume = SettingsManager.get_setting("audio/master_volume")

# Reset
SettingsManager.reset_to_defaults()
```

### Sections recommandées

- **Audio** : master_volume, music_volume, sfx_volume, mute
- **Graphics** : fullscreen, vsync, quality, resolution, shadows
- **Controls** : mouse_sensitivity, invert_y, key_bindings

---

## 3. SaveSystem — Architecture

### Quoi sauvegarder vs quoi reconstruire

| Sauvegarder | Ne PAS sauvegarder |
|------------|-------------------|
| Position du joueur | Textures, meshes |
| Inventaire (items, quantités) | Scène tree complète |
| Quêtes complétées | Références de nodes |
| Stats (HP, XP, level) | Signaux connectés |
| Flags/progression | Scripts |
| Scene path | Resources entières |

### Approche : save_data / load_data

Chaque node saveable expose :

```gdscript
func save_data() -> Dictionary:
    return {"key": value}

func load_data(data: Dictionary):
    # Restore state
```

Les nodes saveables sont dans le groupe `"saveable"`.

### Code complet — Autoload save_system.gd

```gdscript
extends Node

const SAVE_DIR = "user://saves/"
const SAVE_EXT = ".json"

func _ready():
    DirAccess.make_dir_recursive_absolute(SAVE_DIR)

func save_game(slot: int = 0) -> bool:
    var save_data := {
        "version": 1,
        "timestamp": Time.get_datetime_string_from_system(),
        "scene": get_tree().current_scene.scene_file_path,
        "nodes": {}
    }

    for node in get_tree().get_nodes_in_group("saveable"):
        if node.has_method("save_data"):
            save_data["nodes"][node.get_path()] = node.save_data()

    var path = SAVE_DIR + "save_" + str(slot) + SAVE_EXT
    var file = FileAccess.open(path, FileAccess.WRITE)
    if not file:
        push_error("Cannot open save file: " + path)
        return false

    file.store_string(JSON.stringify(save_data, "\t"))
    return true

func load_game(slot: int = 0) -> bool:
    var path = SAVE_DIR + "save_" + str(slot) + SAVE_EXT
    var file = FileAccess.open(path, FileAccess.READ)
    if not file:
        push_error("Save file not found: " + path)
        return false

    var json = JSON.new()
    var err = json.parse(file.get_as_text())
    if err != OK:
        push_error("Invalid save file: " + json.get_error_message())
        return false

    var save_data: Dictionary = json.data

    # Change scene
    get_tree().change_scene_to_file(save_data["scene"])
    await get_tree().tree_changed

    # Restore nodes
    for node_path in save_data["nodes"]:
        var node = get_node_or_null(node_path)
        if node and node.has_method("load_data"):
            node.load_data(save_data["nodes"][node_path])

    return true

func has_save(slot: int = 0) -> bool:
    return FileAccess.file_exists(SAVE_DIR + "save_" + str(slot) + SAVE_EXT)

func delete_save(slot: int = 0):
    var path = SAVE_DIR + "save_" + str(slot) + SAVE_EXT
    if FileAccess.file_exists(path):
        DirAccess.remove_absolute(path)
```

### Exemple : Node saveable (Player)

```gdscript
extends CharacterBody2D

@export var max_health := 100
var health := 100
var inventory := []

func _ready():
    add_to_group("saveable")

func save_data() -> Dictionary:
    return {
        "position_x": global_position.x,
        "position_y": global_position.y,
        "health": health,
        "max_health": max_health,
        "inventory": inventory,
    }

func load_data(data: Dictionary):
    global_position = Vector2(data["position_x"], data["position_y"])
    health = data["health"]
    max_health = data.get("max_health", 100)
    inventory = data.get("inventory", [])
```

---

## 4. Sérialisation JSON

### Types supportés nativement

- int, float, bool, String
- Array, Dictionary
- null

### Types non-supportés et conversion

| Type Godot | Sérialisation | Désérialisation |
|-----------|---------------|-----------------|
| Vector2 | `{"x": v.x, "y": v.y}` | `Vector2(d.x, d.y)` |
| Vector3 | `{"x": v.x, "y": v.y, "z": v.z}` | `Vector3(d.x, d.y, d.z)` |
| Color | `{"r": c.r, "g": c.g, "b": c.b, "a": c.a}` | `Color(d.r, d.g, d.b, d.a)` |
| Resource | `resource.resource_path` | `load(path)` |

### Helper functions

```gdscript
# In a SaveUtils autoload or static class
class_name SaveUtils

static func vec2_to_dict(v: Vector2) -> Dictionary:
    return {"x": v.x, "y": v.y}

static func dict_to_vec2(d: Dictionary) -> Vector2:
    return Vector2(d["x"], d["y"])

static func vec3_to_dict(v: Vector3) -> Dictionary:
    return {"x": v.x, "y": v.y, "z": v.z}

static func dict_to_vec3(d: Dictionary) -> Vector3:
    return Vector3(d["x"], d["y"], d["z"])

static func color_to_dict(c: Color) -> Dictionary:
    return {"r": c.r, "g": c.g, "b": c.b, "a": c.a}

static func dict_to_color(d: Dictionary) -> Color:
    return Color(d["r"], d["g"], d["b"], d["a"])
```

### Usage dans save_data

```gdscript
func save_data() -> Dictionary:
    return {
        "position": SaveUtils.vec2_to_dict(global_position),
        "color": SaveUtils.color_to_dict(modulate),
    }

func load_data(data: Dictionary):
    global_position = SaveUtils.dict_to_vec2(data["position"])
    modulate = SaveUtils.dict_to_color(data["color"])
```

---

## 5. Save Slots

### Pattern multi-slots

- Fichiers : `user://saves/save_0.json`, `save_1.json`, etc.
- Chaque slot = une sauvegarde indépendante
- Métadonnées pour UI (timestamp, scene, playtime)

### Metadata per slot

```gdscript
func get_slot_info(slot: int) -> Dictionary:
    var path = SAVE_DIR + "save_" + str(slot) + SAVE_EXT
    if not FileAccess.file_exists(path):
        return {"empty": true}

    var file = FileAccess.open(path, FileAccess.READ)
    var json = JSON.new()
    json.parse(file.get_as_text())
    var data: Dictionary = json.data

    return {
        "empty": false,
        "timestamp": data.get("timestamp", ""),
        "scene": data.get("scene", ""),
        "playtime": data.get("playtime", 0),
    }
```

### UI : Liste des slots

```gdscript
# In SaveSlotList (VBoxContainer)
func _ready():
    for i in 3:
        var slot_info = SaveSystem.get_slot_info(i)
        var slot_ui = preload("res://ui/save_slot.tscn").instantiate()
        slot_ui.setup(i, slot_info)
        add_child(slot_ui)
```

Référence `/godot-composition` pour la structure UI des save slots.

---

## 6. Encryption & Sécurité

### Encryption basique

```gdscript
const ENCRYPTION_KEY = "your-secret-key"  # In production: load from env or obfuscate

func _save_encrypted(path: String, data: String):
    var file = FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, ENCRYPTION_KEY)
    file.store_string(data)

func _load_encrypted(path: String) -> String:
    var file = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, ENCRYPTION_KEY)
    if not file:
        return ""
    return file.get_as_text()
```

### Quand l'utiliser

| Contexte | Encryption ? |
|----------|-------------|
| Dev / Debug | ❌ Non (besoin de lire les saves) |
| Release solo | ⚠️ Optionnel (deterrent) |
| Release multiplayer | ✅ Oui (anti-cheat) |

### Hash pour anti-cheat basique

```gdscript
func _compute_hash(data: String) -> String:
    return data.sha256_text()

func save_game_with_hash(slot: int) -> bool:
    var save_data_str = JSON.stringify(save_data)
    var hash = _compute_hash(save_data_str)

    var file = FileAccess.open(path, FileAccess.WRITE)
    file.store_line(hash)
    file.store_string(save_data_str)
    return true

func load_game_with_hash(slot: int) -> bool:
    var file = FileAccess.open(path, FileAccess.READ)
    var stored_hash = file.get_line()
    var data_str = file.get_as_text()

    var computed_hash = _compute_hash(data_str)
    if stored_hash != computed_hash:
        push_error("Save file tampered!")
        return false

    # Continue normal load
```

**Note importante** : l'encryption client-side n'est PAS une vraie sécurité. C'est un deterrent pour les cheaters casual. Un joueur déterminé peut toujours dump la RAM ou reverse le binaire.

---

## 7. Migration de sauvegardes

### Pattern : Version number

```gdscript
var CURRENT_VERSION := 3

func load_game(slot: int) -> bool:
    var save_data: Dictionary = # ... load JSON

    # Migrate if needed
    if save_data.get("version", 1) < CURRENT_VERSION:
        save_data = _migrate(save_data)

    # Continue normal load
    return true

func _migrate(save_data: Dictionary) -> Dictionary:
    var version = save_data.get("version", 1)

    while version < CURRENT_VERSION:
        match version:
            1: save_data = _migrate_v1_to_v2(save_data)
            2: save_data = _migrate_v2_to_v3(save_data)
        version += 1

    save_data["version"] = CURRENT_VERSION
    return save_data
```

### Exemple : Migration v1 → v2

```gdscript
func _migrate_v1_to_v2(data: Dictionary) -> Dictionary:
    # Example: rename "hp" to "health", add default "mana"
    if "nodes" in data:
        for path in data["nodes"]:
            var node_data = data["nodes"][path]

            # Rename hp → health
            if "hp" in node_data:
                node_data["health"] = node_data["hp"]
                node_data.erase("hp")

            # Add missing mana
            if "mana" not in node_data:
                node_data["mana"] = 100

    data["version"] = 2
    return data
```

### Exemple : Migration v2 → v3

```gdscript
func _migrate_v2_to_v3(data: Dictionary) -> Dictionary:
    # Example: convert inventory array to dictionary with UIDs
    if "nodes" in data:
        for path in data["nodes"]:
            var node_data = data["nodes"][path]

            if "inventory" in node_data and node_data["inventory"] is Array:
                var old_inventory: Array = node_data["inventory"]
                var new_inventory := {}
                for i in old_inventory.size():
                    new_inventory[str(i)] = old_inventory[i]
                node_data["inventory"] = new_inventory

    data["version"] = 3
    return data
```

### Fallback : Version inconnue

```gdscript
func _migrate(save_data: Dictionary) -> Dictionary:
    var version = save_data.get("version", 1)

    if version > CURRENT_VERSION:
        push_error("Save version too high (%d), current is %d. Refusing to load." % [version, CURRENT_VERSION])
        return {}

    # Continue migration
```

**Règle** : ne jamais corrompre une save en tentant de la migrer. Si version > current, refuse de charger.

---

## 8. Checklist Review

| Check | ✓ |
|-------|---|
| Settings use ConfigFile (not JSON)? | |
| All settings have defaults? | |
| Save directory created on startup? | |
| Saveable nodes in "saveable" group? | |
| Vector2/Color properly serialized? | |
| Save version number included? | |
| Load handles missing/corrupted files gracefully? | |
| Settings persist across sessions? | |

---

## 9. Pièges courants

| Piège | Symptôme | Solution |
|-------|----------|----------|
| Save sur res:// en export | Erreur silencieuse, fichier non écrit | Toujours user:// |
| JSON.parse sans vérification | Crash si fichier corrompu | Check return value of parse() |
| Sauvegarder node paths absolus | Cassé si scène tree change | Sauvegarder des données, pas des paths |
| Pas de version dans save | Migration impossible | Toujours inclure "version": N |
| ConfigFile.load() sur fichier inexistant | ERR_FILE_NOT_FOUND (pas crash) | Check with FileAccess.file_exists() first or handle error |
| Sauvegarder Resources entières | JSON ne supporte pas | Sauvegarder resource_path, recréer au load |

---

## See also

| Skill | Quand l'utiliser |
|-------|-----------------|
| `/godot-architecture` | Structure projet, Resources custom (Section 5) |
| `/godot-audio` | Volume settings via ConfigFile pattern |
| `/godot-composition` | Structure UI pour les save slots |
