Skip to content

Multi-Resource Dictionary

What you'll learn

  • The Dictionary class — Godot's built-in hash-map — and the typed-Dictionary syntax Dictionary[K, V] (Godot 4.4+) that constrains keys and values to declared types.
  • How to iterate a Dictionary by keys (for key in dict), by values (for v in dict.values()), and by entries (for k in dict: dict[k]).
  • Why one Light autoload per resource does not scale to a game with many resources, and how a single GameState autoload with two Dictionaries (_resources, _income_rates) replaces N autoloads with one.
  • The parameterized signal pattern: resource_changed(name: String, new_value: float) lets one signal serve all resources, with listeners filtering by name.
  • How to set up a Honor resource (used in M6 prestige) without writing new infrastructure — register it in the same Dictionary as Light and the rest of M3's plumbing handles it.

How it applies

  • Late-game scaling. A finished idle game commonly has 4–8 resource types: primary currency, secondary trade goods, prestige currency, ascension currency, soft-cap event currencies. Per-resource autoloads multiply this count by every system that touches resources (UI, save, balance tools, mods). One ledger keeps the count flat.
  • Save-format consistency. With one Dictionary, the save format is one block: {"resources": {...}, "income_rates": {...}}. Adding a new resource is one new key in one Dictionary; the save format extends without migration. With per-resource autoloads, every new resource is a new autoload, a new save section, a new migration when the section name changes.
  • Localization and theming. A display_name lookup keyed on resource id ("light" → "Light", "honor" → "Honor") lives in a single Dictionary[String, String] parallel to _resources. Translators edit one file. With per-resource autoloads, display_name is a constant on each autoload — translators edit N files.
  • QA scriptability across resources. A QA tester debugging the prestige flow opens the Remote tab and runs GameState.add_to_resource("honor", 1000.0). Same syntax for Light, for any resource. The test surface is uniform; the same script that exercises Light exercises Honor with one variable substituted.
  • Modding seam. A mod that introduces a new resource type ("Crystals") calls GameState._resources["crystals"] = 0.0 once. Every UI path that iterates _resources displays Crystals automatically. A mod against per-resource autoloads has to spawn an autoload at runtime, hook the save system, register the icon, and handle ordering — none of which is a one-line change.

A note before you start

M3.3 is the textbook's most invisible chapter — at the end you will press F5 and see a Honor label reading "Honor: 0" that does not change. What you will have built is a multi-resource ledger that every later module relies on. M4's upgrades multiply against rates stored here. M5's buildings deposit into this Dictionary. M6's prestige reads Honor from this Dictionary. M7's save serializer walks this Dictionary. The chapter is plumbing; the payoff is the entire second half of the textbook, beginning with M4.2's first visible upgrade.

The name Honor arrives in this chapter for the first time as a registered (zero-valued) resource. Honor is the prestige currency M6 will rely on — earned by sacrificing run progress through a Crusade pledge, spent (permanently) as a multiplier on every future run's income. M3.3 wires the schema; M6.1 is when Honor first changes value.

Concepts

Dictionary — Godot's hash-map

A Dictionary maps keys to values. Both can be any Variant (any built-in type). The literal syntax uses braces:

var resources: Dictionary = {
    "light": 0.0,
    "honor": 0.0,
}

Lookup is dict[key] (raises if the key is missing) or dict.get(key, default) (returns the default instead). Insert/update is dict[key] = value. Existence check is key in dict. Removal is dict.erase(key). All operations are O(1) amortized — the engine maintains an internal hash table.

Iteration order is insertion order. Iterating for k in dict: walks keys in the order they were first set; iterating for v in dict.values(): walks values in the same order. This is stable across runs as long as the insert sequence is stable, which makes Dictionaries usable as ordered records in addition to lookup tables.

Example

You declare var d: Dictionary = {} and write d["b"] = 1, d["a"] = 2, d["c"] = 3. Iterating keys produces "b", "a", "c" — insertion order, not alphabetical. If you need alphabetical you sort the keys explicitly: var sorted_keys = d.keys(); sorted_keys.sort(); for k in sorted_keys: .... Untyped Dictionaries are flexible at the cost of accepting wrong-type writes silently — d[42] = "string" is legal, and a downstream loop expecting string keys will surprise on 42.

Typed Dictionary Dictionary[K, V]

Godot 4.4 introduced typed Dictionaries: Dictionary[String, float] constrains both key and value types. Wrong-type writes raise at insert time:

var resources: Dictionary[String, float] = {}
resources["light"] = 0.0       # ok
resources[42] = 1.0            # runtime error: key must be String
resources["honor"] = "free"    # runtime error: value must be float

Same data structure, same operations, same performance. The typed variant is preferred for any Dictionary that stores state (lives across function calls) or crosses a function boundary (parameter or return). It catches the same class of bug typed-arrays catch.

Example

You write var rates: Dictionary[String, float] = {} and a path elsewhere does rates["honor"] = 5 (literal integer, not float). The runtime raises "Invalid type in dictionary, expected float, got int." The fix is rates["honor"] = 5.0. The typed-Dictionary requirement makes implicit type confusion fail loudly. With untyped, the value would be stored as int and a downstream var r: float = rates["honor"] would either succeed via implicit conversion or fail later in math, depending on the operation.

Iteration patterns

Three loops in common use:

for key in resources:                    # keys, in insertion order
    print(key)

for value in resources.values():         # values, in same order
    print(value)

for key in _income_rates:                # keys + indexed value access
    var rate: float = _income_rates[key]
    if rate != 0.0:
        ...

The third pattern is canonical for "operate on each entry": use the key to look up auxiliary data (in this case, write to _resources[key]). It is preferred over for entry in dict.values() for any case where you need both the key and the value, because the lookup is the same O(1) cost either way.

Dictionary.keys() and .values() return typed Arrays of the same key/value type as the parent Dictionary. Dictionary.size() is the entry count.

Example

You want "all resources whose income rate is nonzero." The idiom is:

for name in _income_rates:
    if _income_rates[name] != 0.0:
        active_count += 1
Could equivalently use _income_rates.values() if you only need the rates. Use the keyed form when downstream code needs to know which resource each rate belongs to.

One GameState instead of N autoloads

Light was an autoload-per-resource. For the project's current scope (one primary resource) that worked. As soon as Honor (M6 prestige currency) and any other secondary resources arrive, the per-resource autoload pattern means N autoloads, N save sections, N signals, N Labels each connected to its own publisher. Every new resource is N edits across N autoloads.

A GameState autoload replaces this with one ledger. Two Dictionary[String, float]s: _resources (the current values) and _income_rates (the per-second rates). One parameterized signal: resource_changed(name, value). Listeners that care about Light filter name == "light"; listeners that care about Honor filter name == "honor"; the Light Label and the Honor Label are the same code with one parameter different.

Light (the M2.2 autoload) does not vanish — for primary-currency-style code it can stay as an ergonomic shortcut, or it can become a thin façade over GameState.get_resource("light"). The textbook keeps the existing Light for now and adds GameState alongside; M4 and M5 will route through GameState for new resources, and a future cleanup pass can fold Light into the ledger if the redundancy bothers anyone.

A note on Light vs GameState. You now have two ways to access the player's Light total: Light.value (M2.2) and GameState.get_resource("light") (M3.3). They both work. They both update the same display, but through different signal paths: Light.value emits Light.value_changed; GameState.add_to_resource("light", ...) emits GameState.resource_changed("light", ...). Subscribers connected to one but not the other will miss writes that go through the other path.

This is deliberate technical debt — a real architectural ambiguity left in the codebase for two reasons. First, refactoring Light away costs time that goes against the curriculum's pacing. Second, seeing a redundancy you carry forward through later modules is a real engineering experience the curriculum wants you to have. M6.2 will exploit this seam to make a deliberate write-path change you can observe and reason about.

The discipline, going forward: prefer GameState.add_to_resource("light", amount) in code paths that touch multiple resources (M4+); use Light.value only when the surrounding code is single-resource and the named accessor is clearer than the dictionary lookup. A future M9-onwards refactor pass would fold Light into a thin façade over GameState (the property-with-getter/setter pattern from M2.2 still applies; only the storage migrates). For now, accept the redundancy and pick the access pattern that reads cleanest at each call site.

Example

The project later adds three new resources: Mana (a click-time consumable), Souls (an enemy-kill drop), and Insight (a research currency). With per-resource autoloads, that is three new .gd files, three autoload registrations, three save migrations, three Label listeners with three different signal connections. With GameState, it is three lines: _resources["mana"] = 0.0, _resources["souls"] = 0.0, _resources["insight"] = 0.0. Every existing UI loop, save path, and tick handler picks them up automatically.

Parameterized signal vs per-resource signal

A parameterized signal carries enough information for the listener to decide whether to act. resource_changed(name: String, new_value: float) fires once per resource change; the Light Label's handler reads the name parameter, compares to "light", and either updates or ignores.

func _on_resource_changed(name: String, new_value: float) -> void:
    if name == "light":
        counter_label.text = "Light: %d" % int(new_value)

The cost is filter-overhead: every Label receives every resource's change, even resources it doesn't display. For a project with five resources and ten Labels, each tick fires five emits to ten listeners — fifty filter checks per tick. At 10 Hz, five hundred per second. Still cheap (a string equality check is nanoseconds), but real.

The alternative — one signal per resource — eliminates the filter at the cost of N signal declarations. The textbook prefers the parameterized form because it scales to "any number of resources" without code edits to the publisher; new resources do not require new signal declarations.

Example

A late-game player has fifteen resources and twenty Labels. The parameterized form fires one emit per resource change; each emit reaches all twenty listeners; each listener does one string-compare. That is three hundred string-compares per resource change, all in a microsecond range. The per-resource-signal form fires one emit per change to a single listener — two hundred fewer emits, but at the cost of fifteen signal declarations and fifteen connect calls in every Label that needs to display all resources. The textbook's choice is the maintenance ergonomics, not the microbenchmark.

Walkthrough

You will perform these in your own Godot editor. Coming in, Light and Tick are autoloads; the click and tick paths both flow through Light.value's setter into counter_label.text.

  1. In the FileSystem dock, right-click scripts/New → Script. Name it game_state.gd. Click Create.
  2. Replace the body. The full code-fragment ceiling for this chapter — split across three logical pieces:
    extends Node
    
    signal resource_changed(resource_name: String, new_value: float)
    
    var _resources: Dictionary[String, float] = {}
    var _income_rates: Dictionary[String, float] = {}
    

As with Tick (M3.1), there is deliberately no class_name GameState: this script is registered as the GameState autoload in step 5, and in Godot 4.6 a class_name matching an autoload's name raises "Class 'GameState' hides an autoload singleton." The autoload registration is the global accessor (GameState.add_to_resource(...)); class_name is reserved for scripts referenced as a type (the BuildingData/UpgradeData Resources), never autoloads.

The signal's first parameter is named resource_name, not name. Node.name is a built-in property on every Node (the autoload script extends Node); naming a signal parameter or method parameter name shadows that built-in and triggers SHADOWED_VARIABLE_BASE_CLASS warnings in Godot 4.6. Using resource_name keeps the editor's Errors panel clean. Three logical lines (signal, two Dictionaries). The typed-Dictionary syntax Dictionary[String, float] requires Godot 4.4 or newer; older 4.x reports "Unexpected token" on the [ and you fall back to untyped Dictionary with manual type checks. 3. Below the declarations, add the read accessor and the mutation method:

func get_resource(resource_name: String) -> float:
    return _resources.get(resource_name, 0.0)

func add_to_resource(resource_name: String, delta: float) -> void:
    var new_value: float = maxf(_resources.get(resource_name, 0.0) + delta, 0.0)
    _resources[resource_name] = new_value
    resource_changed.emit(resource_name, new_value)
get_resource returns 0.0 for unknown resources rather than raising — the same defensive default the M2 setter used. add_to_resource reads, clamps, writes, and emits in that order. The clamp keeps every resource non-negative; the emit fires regardless of whether the value actually changed (same caveat as M2.2's setter — emit-on-no-change is your problem to gate if you care). 4. Add the rate setter and the tick handler:
func set_rate(resource_name: String, rate: float) -> void:
    _income_rates[resource_name] = rate

func _ready() -> void:
    Tick.tick.connect(_on_tick)

func _on_tick(tick_delta: float) -> void:
    for resource_name in _income_rates:
        var rate: float = _income_rates[resource_name]
        if rate != 0.0:
            add_to_resource(resource_name, rate * tick_delta)
The _on_tick body iterates _income_rates (insertion-order stable), skips zero-rate entries, and routes every nonzero contribution through add_to_resource — same setter discipline as M2.2 carried into a multi-resource world. Save (Ctrl+S). 5. Register GameState as an autoload. Project → Project Settings → Globals → Autoload. Path: res://scripts/game_state.gd. Node Name: GameState. Click Add. Order should be: Tick (top), Light, GameState (bottom) — GameState's _ready() connects to Tick.tick, so Tick must autoload first. Drag rows in the list if needed. Close the dialog. 6. Initialize Honor as a registered (but zero) resource. The textbook's pattern for "this resource exists, just at zero": call add_to_resource("honor", 0.0) once on startup. Add to GameState._ready():
func _ready() -> void:
    Tick.tick.connect(_on_tick)
    # Silent initialization — runs before any UI scripts' _ready, so no
    # listeners are connected yet. Direct dict writes here bypass
    # add_to_resource's emit, which would have no subscribers anyway.
    _resources["honor"] = 0.0
    _income_rates["honor"] = 0.0
Two Dictionary writes, no add_to_resource call (we don't need to emit on initial registration; nothing is listening yet — autoloads' _ready runs before any scene-attached script's _ready, so no subscribers exist when this line runs). The keys exist; the values are zero; the iteration in _on_tick will skip them while the rate is zero. Discipline exception, not policy: every later mutation of _resources["honor"] must go through add_to_resource("honor", ...) so the emit fires; this initial direct write is the one-time exception that wires the schema into existence. 7. Add a Honor Label to the top bar. In the Scene dock, right-click TopBarAdd Child Node → search Label → create. Rename to HonorLabel. In the Inspector, set Text to Honor: 0. 8. Open scripts/top_bar.gd (the M2.3 script). Add an @onready reference for the new Label and a connection to GameState.resource_changed. The script's _ready() becomes:
func _ready() -> void:
    train_button.pressed.connect(_on_train_pressed)
    Light.value_changed.connect(_on_light_changed)
    GameState.resource_changed.connect(_on_resource_changed)
And add the parameterized handler:
func _on_resource_changed(resource_name: String, new_value: float) -> void:
    if resource_name == "honor":
        honor_label.text = "Honor: %d" % int(new_value)
The handler filters on resource_name. A future Mana resource would add another if/elif. Save (Ctrl+S). 9. Press F5. The top bar shows three children now: TrainButton, CounterLabel (Light: 0), HonorLabel (Honor: 0). Clicking the button increments Light. Honor stays at 0. In the Remote tab, find /root/GameState. Call add_to_resource("honor", 5.0) from the REPL (or, simpler, edit _resources["honor"] in the Inspector to 5.0 — but that bypasses the emit, so the Label won't update; the REPL form respects the discipline). The Honor Label updates to Honor: 5. 10. To prove the income path: still in the running game, call GameState.set_rate("honor", 1.0). The next tick adds 1.0 × 0.1 = 0.1 Honor; over the next ten seconds Honor grows from 5 to 6. Stop the game.

Optional sanity check. In GameState._ready(), set _income_rates["honor"] = 0.5. Save, launch. The Honor Label ticks slowly upward — 0.5 × 0.1 = 0.05 per tick, half a Honor per second, one whole Honor every two seconds. The display ticks one integer every two seconds. Reset _income_rates["honor"] = 0.0 afterwards. The first production write to a non-zero Honor rate lives in M6.1, when pledging the Crusade grants Honor. M3.3's job is to make sure the ledger is ready for it.

Self-check quiz

Q1 — _income_rates is a Dictionary[String, float] initialized as empty. The tick handler does for name in _income_rates: var rate: float = _income_rates[name]. The Dictionary has no entries. What runs in the tick handler body, and what is the runtime cost?

A. The body runs zero times. Cost is one Dictionary-empty check. B. The body raises "Iteration over empty Dictionary not supported." C. The body runs once with name == "", the default for a missing key. D. The body runs once with name undefined; downstream _income_rates[name] raises.

Reveal answer

A — empty iteration is a no-op. A for loop over an empty Dictionary executes the body zero times. The cost is whatever the engine spends checking "are there any entries?" — a single comparison against zero size. No body run, no key lookups, no risk of undefined behavior. This is one reason iteration-by-key is the canonical pattern: the loop self-skips on empty data, no if dict.is_empty(): guard needed. B fabricates an error path. C and D imagine default-key semantics that GDScript does not have — Dictionaries do not produce phantom keys for empty iteration.

Q2 — You write _resources['honor'] = 'five' (string instead of float) and _resources is Dictionary[String, float]. When does the error surface?

A. At parse time — the editor refuses to load the script. B. At the line _resources["honor"] = "five" — runtime raises "Invalid type, expected float, got String." C. Silently — the Dictionary stores "five" and downstream math _resources["honor"] + 1.0 raises a different error. D. Only when iterating — for k in _resources raises because the value type drifted.

Reveal answer

B — typed-Dictionary writes are checked at insert time. The runtime validates the assignment as the line executes; the wrong-type write raises immediately. Stack trace points at the assignment line, which is where the bug is — not at the downstream consumer that would have reported a more confusing arithmetic error. A is wrong: GDScript's editor catches type errors only when the constraint is statically visible (literal var x: float = "string"); Dictionary write-through is a runtime check. C describes untyped-Dictionary behavior; the typed variant intercepts. D fabricates an iteration check; iteration does not re-validate every value.

Q3 — You connect a single Label to GameState.resource_changed with the handler if name == 'light': counter_label.text = .... The game has fifteen resources and ten Labels each filtering for one resource. Each tick emits five resource_changed calls (only five resources have nonzero rates). How many string compares run per tick?

A. 5 (one per emit, the publisher does the filtering). B. 50 (each of the five emits reaches all ten Labels; each Label compares once). C. 150 (each of fifteen resources' worth of state is re-checked every tick). D. 0 — the engine routes signals by argument value automatically.

Reveal answer

B — 5 emits × 10 listeners = 50 compares per tick. Each emit fans out to every connected listener; each listener runs the handler body, including the if name == "..." check. Listener-side filtering is the cost of the parameterized-signal pattern. At 10 Hz, that is five hundred string compares per second across the project — cheap (microseconds), but a finite cost. A imagines publisher-side filtering, which the engine does not do. C overcounts: only the five rates with nonzero values produce emits, the other ten silent rates contribute nothing. D fabricates argument-routing — Godot signals fan to all connections regardless of argument values.

Integration question

Q4 — open

Across M3 you built three pieces: a fixed-cadence Tick autoload (M3.1), a recompute-on-change income discipline for Light (M3.2), and a multi-resource GameState ledger keyed by string (M3.3). Suppose M5's BuildingData arrives — a Resource with id: String, output_resource: String (which resource it produces), and base_output: float. Trace how one purchase of one building reaches one visible Label update. How many of the three M3 systems participate, and which one is the integration point that ties them together?

Reveal expected answer

The purchase calls GameState.set_rate(building.output_resource, current_rate + building.base_output) (or, equivalently, calls a _recalculate_income() method on GameState that re-walks all owned buildings and rebuilds _income_rates — the M3.2 discipline applied to the multi-resource ledger). That writes one entry in _income_rates. No signal fires from the rate change itself; rates are derived state from inputs (M3.2's lesson). The next Tick.tick emit (within TICK_INTERVAL) reaches GameState._on_tick, which iterates _income_rates, finds the new nonzero rate for whatever resource the building produces, calls add_to_resource(name, rate * tick_delta), which clamps, writes _resources[name], emits resource_changed(name, value). The Label subscribed to resource_changed filters on name, finds a match, updates text. So all three M3 systems participate: M3.1's Tick provides the cadence, M3.2's recompute discipline provides the cached _income_rates write path, M3.3's parameterized ledger carries the change to the listener. The integration point is GameState: it owns the recompute (will need a method like _recalculate_income() mirroring M3.2's pattern), it owns the cached rates, it emits the parameterized signal, it routes the tick increment through the same setter used by clicks, save-loads, and debug commands. The deeper architectural property: M5's BuildingData does not need to know about Tick, signals, or Labels. It just declares "this resource, this rate." GameState absorbs everything else.

What's next

The GameState ledger is empty but ready. M4 is the first chapter where the player buys something — multiplier upgrades that touch click_value and tick_multiplier. M5 is where buildings begin to write per-tick rates into _income_rates, finally giving the Honor and Light labels something to track.

Glossary

Glossary

Dictionary
Godot's built-in hash-map type. Maps keys to values; both can be any Variant. Untyped form is Dictionary; typed form (Godot 4.4+) is Dictionary[K, V]. Lookup is O(1) amortized; iteration order is insertion order.
typed Dictionary Dictionary[K, V]
GDScript's typed-dictionary syntax. Constrains both key and value types; wrong-type insert raises at runtime. Required for Dictionaries that store state across function calls or cross function boundaries.
Variant
Godot's union type. Can hold any built-in value: int, float, String, Vector2, Array, Dictionary, etc. Untyped variables, untyped Dictionary keys/values, and signal arguments default to Variant.
parameterized signal
A signal whose arguments include enough information for listeners to filter — e.g., resource_changed(name: String, new_value: float) lets a Label match name == "light" and ignore changes for other resources. Lets one signal serve N publishers' worth of data without N signals.
GameState
The autoload introduced in M3.3 that holds all run-time resources in two parallel Dictionaries: _resources: Dictionary[String, float] for current values, _income_rates: Dictionary[String, float] for per-second rates. Emits resource_changed(name, value) so listeners filter by id.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Show me the iteration-order property of GDScript Dictionaries with a concrete test — insert keys A, C, B, D and print the iteration order. Confirm it's insertion order.` - `Compare GameState's parameterized resource_changed signal with a per-resource signal pattern. What's the maintenance cost of each at five resources, fifteen, fifty?` - `Re-explain typed Dictionary using a Python typing.Dict[str, float] analogy — what's similar, what's different.`