Autoloads, Global State, and Saves

Module 3 ยท Lesson 2 ยท ~35 min ยท Godot 4.x

Every game needs some global state โ€” player progress, run seed, settings, audio volumes, save files. Godot's mechanism for this is the autoload, which is a singleton that lives at the top of the scene tree for the lifetime of the game. Used well, it's a clean dependency boundary. Used badly, it's the God object.

What an autoload is

An autoload is a scene or script that Godot instantiates automatically when the game starts and makes available via a global name.

  1. Create scripts/global.gd:
extends Node

var run_seed: int = 0
var player_deck: Array[CardData] = []
var gold: int = 0

func reset_run() -> void:
    run_seed = randi()
    gold = 0
    seed(run_seed)
    print("[Global] new run, seed=", run_seed)
  1. Project Settings โ†’ Globals (formerly "Autoload"). Path: res://scripts/global.gd. Name: Global. Add.
  2. Now from anywhere: Global.gold += 10, Global.reset_run(), etc.

That's it. The autoload node is called Global (or whatever you named it) and lives at /root/Global.

If you've used React Context, Svelte stores, or a Dependency Injection container, autoloads are the Godot flavor of that. A single source of truth for truly-global things. The DI container is the scene tree itself.

What belongs in an autoload

Yes:

No:

The global event bus pattern

Sometimes two unrelated systems need to know about an event. Coupling them via direct references is ugly. An event bus autoload fixes this:

# scripts/events.gd โ€” register as autoload "Events"
extends Node

signal card_played(card: Card, word: String)
signal duel_won(reward: int)
signal run_ended(score: int)
signal setting_changed(key: String, value: Variant)

Anywhere in the game:

# emit
Events.card_played.emit(card, "LEXICON")

# listen
func _ready() -> void:
    Events.duel_won.connect(_on_duel_won)

This gives you pub/sub decoupling across the whole game. Use it for broadcast-style events. For point-to-point communication (Hand tells DuelController about a play), just wire signals directly โ€” don't route through the bus.

Trap Don't put every signal in the event bus. Only those that cross system boundaries. If the emitter and listener are in the same scene, use a direct signal connection.

Saving and loading

For a turn-based word/card game you have two kinds of save data:

  1. Meta-progression โ€” unlocks, total runs, achievements. Saves on events. Persists forever.
  2. Current run โ€” seed, current floor, deck, HP. Saves on state transitions. Wiped on run end.

Godot ships with two save mechanisms. Know both.

1. JSON + user://

func save_game() -> void:
    var data := {
        "gold": Global.gold,
        "unlocks": Global.unlocks,
        "version": 1,
    }
    var f := FileAccess.open("user://save.json", FileAccess.WRITE)
    f.store_string(JSON.stringify(data, "  "))
    f.close()

func load_game() -> void:
    if not FileAccess.file_exists("user://save.json"):
        return
    var f := FileAccess.open("user://save.json", FileAccess.READ)
    var text := f.get_as_text()
    f.close()
    var parsed = JSON.parse_string(text)
    if parsed is Dictionary:
        Global.gold = parsed.get("gold", 0)
        Global.unlocks = parsed.get("unlocks", [])

Pros: readable, portable, easy to version. Cons: you hand-write the serialization. Best for simple metadata.

user:// is the per-app writable location โ€” on Android it's the app's private data directory, on desktop it's ~/.local/share/godot/app_userdata/ProjectName/. You never need to know the physical path; just use the user:// prefix.

2. ResourceSaver + ResourceLoader

# scripts/save_data.gd
class_name SaveData
extends Resource

@export var gold: int = 0
@export var unlocks: Array[String] = []
@export var current_run: RunData  # nested Resource
@export var version: int = 1
# Saving
var save := SaveData.new()
save.gold = Global.gold
save.unlocks = Global.unlocks
ResourceSaver.save(save, "user://save.tres")

# Loading
var save: SaveData = load("user://save.tres")
if save:
    Global.gold = save.gold
    Global.unlocks = save.unlocks

Pros: typed, uses @export, handles nested Resources automatically. Cons: Godot-specific format. Slightly harder to debug-edit by hand.

For Lexicon Duel, I'd use ResourceSaver for the current run (it's complex, has many typed Resources nested) and JSON for meta-progression (small, human-editable for debugging).

Versioning saves

You will change the save schema mid-development. Always include a version field:

func load_game() -> void:
    var data = _read_save()
    if not data: return
    var v = data.get("version", 0)
    if v < 1:
        _migrate_v0_to_v1(data)
    if v < 2:
        _migrate_v1_to_v2(data)
    # apply to Global

On the first production bug, you'll be grateful. The alternative โ€” "please delete save.json and start over" in a patch note โ€” is what ships indie games to Steam with 40% refund rates.

Mobile save paths

user:// on Android is wiped if the user clears app data but survives updates. That's usually what you want. Settings and unlocks go here. If you want true cloud sync (Google Play Games Services, iCloud), that's a Module 7 conversation โ€” we'll point you at the plugins.

Do this now

  1. Create scripts/global.gd with a gold property. Register as autoload.
  2. Create scripts/events.gd with a duel_won(reward: int) signal. Register as autoload "Events".
  3. In your DuelController, emit Events.duel_won.emit(25) when you set state to VICTORY (just for testing).
  4. In global.gd, connect to Events.duel_won in _ready and add to gold.
  5. Force a VICTORY state in the DuelController and print Global.gold after. You've just wired your first cross-system event.