Game Development with Godot & GDScript Module 3 – Arrays, dictionaries and packed data
Dictionaries and typed dictionaries
Store and look up game data in GDScript dictionaries with get, has and in, keep their order, merge them, declare typed dictionaries and copy nested data safely.
What you will learn
- Store and look up game data in dictionaries with get, has and in, and handle a missing key
- Iterate keys and values in insertion order, count with a dictionary and merge dictionaries with and without overwrite
- Declare typed dictionaries such as Dictionary[String, int] (Godot 4.4 and later) and predict which check refuses a value
- Copy nested dictionaries with duplicate(true) so that no inner dictionary or array is shared
Before you start
What you’ll learn · Before you start
What you will learn
- Store and look up game data in dictionaries with get, has and in, and handle a missing key
- Iterate keys and values in insertion order, count with a dictionary and merge dictionaries with and without overwrite
- Declare typed dictionaries such as Dictionary[String, int] (Godot 4.4 and later) and predict which check refuses a value
- Copy nested dictionaries with duplicate(true) so that no inner dictionary or array is shared
Before you start
On this page
A copied hero’s hit points were set to 60, and on Godot 4.7.2 the original hero dropped to 60 hit points as well:
duplicate() had copied the outer dictionary and left the stats inside it shared. Dictionaries hold the named data of a
game, its stats, prices, settings and counts, and they share and copy by the same rules as arrays, with a few of their
own.
Below you look values up by key and deal with keys that are not there, rely on the order entries were added in, count with a dictionary, choose the right way to merge two, give a dictionary key and value types that Godot holds it to, and copy nested data so that no inner dictionary is shared. What Godot 4.7.2 printed for each program is on the page beside its code.
A dictionary finds a value by its key
A dictionary: A collection of entries, each a value stored under a unique key, such as {"hp": 100}. A lookup by key finds the value, and a GDScript Dictionary keeps its entries in the order they were added. stores values under keys: {"hp": 100, "atk": 12, "def": 5} has three
entries, and stats["hp"] follows the key "hp" to its value, 100. Keys are unique, and any value can be one, a
String, an int, a Vector2, though Strings are the usual choice for named data. When a key is a fixed name, stats.hp
is a shorter way to write stats["hp"]. Writing to a key replaces its value, and writing to a new key adds an entry.
Step through the lookups of lookups.gd:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Three entries. stats starts with three entries. Each key is unique and leads to one value; the order of the entries is the order they were written.
- stats["hp"]. stats["hp"] follows the key "hp" to its value and gives 100. stats.hp is a shorter way to write the same lookup for a key that is a fixed name.
- Two writes. Writing to a key that exists replaces its value: stats.atk = 15. Writing to a new key adds an entry at the end: stats["spd"] = 7.
- get("luck", 0). There is no key "luck". get() returns the default you give it, 0, and without a default it returns null. Nothing is added to stats.
- stats["luck"]. Square brackets on a missing key are an error: Godot stopped read_luck() with Invalid access to property or key luck. Use get() or has() when a key may be missing.
- in looks at keys. "hp" in stats is true and 100 in stats is false, although 100 is a value: in and has() only look at the keys.
extends SceneTree
# Looking values up by key, and what happens when the key is not there.
func _initialize() -> void:
var stats := {"hp": 100, "atk": 12, "def": 5}
print("stats[\"hp\"]: ", stats["hp"], " stats.hp: ", stats.hp)
stats["spd"] = 7
stats.atk = 15
print("after two writes: ", stats)
print("get(\"luck\", 0): ", stats.get("luck", 0), " get(\"luck\"): ", stats.get("luck"))
print("has(\"hp\"): ", stats.has("hp"), " \"hp\" in stats: ", "hp" in stats, " 100 in stats: ", 100 in stats)
read_luck(stats)
var slime := {hp = 10, name = "slime"}
print("Lua-style: ", slime, " has(\"hp\"): ", slime.has("hp"))
var actions := {"jump": 1}
print("String key, StringName lookup: ", actions.has(&"jump"))
var ids := {1: "int", 1.0: "float", "1": "string"}
print(ids.size(), " keys: ", ids, " ids[1]: ", ids[1], " ids[1.0]: ", ids[1.0])
var target := {"enemy": null}
print("has(\"enemy\"): ", target.has("enemy"), " get(\"enemy\", \"none\"): ", target.get("enemy", "none"))
quit()
func read_luck(stats: Dictionary) -> void:
print("stats[\"luck\"]: ", stats["luck"]) from session import files, run
files("project.godot", "lookups.gd")
run("""
!godot --headless --no-header --script lookups.gd
""") Output
$ godot --headless --no-header --script lookups.gd
stats["hp"]: 100 stats.hp: 100
after two writes: { "hp": 100, "atk": 15, "def": 5, "spd": 7 }
get("luck", 0): 0 get("luck"): <null>
has("hp"): true "hp" in stats: true 100 in stats: false
SCRIPT ERROR: Invalid access to property or key 'luck' on a base object of type 'Dictionary'.
at: read_luck (res://lookups.gd:27)
GDScript backtrace (most recent call first):
[0] read_luck (res://lookups.gd:27)
[1] _initialize (res://lookups.gd:12)
Lua-style: { &"hp": 10, &"name": "slime" } has("hp"): true
String key, StringName lookup: true
3 keys: { 1: "int", 1.0: "float", "1": "string" } ids[1]: int ids[1.0]: float
has("enemy"): true get("enemy", "none"): <null>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lookups_session.py
A missing key is the case to plan for. stats["luck"] stopped read_luck() with
Invalid access to property or key 'luck', while stats.get("luck", 0) returned the default, 0, and get("luck")
without one returned null. has() and in say whether a key is there, and they look only at keys: 100 in stats
was false although 100 is a value.
Keys are compared by value and by type. {1: "int", 1.0: "float", "1": "string"} held three different keys, so a
lookup with the int 1 never finds an entry stored under the String "1". String and
StringName: A built-in type for names, written &"jump". Each value is stored once, so two StringNames with the same text are the same object and compare very fast. Node names and input actions are StringNames. More in “Strings, StringName and text formatting” keys are the exception: {"jump": 1}.has(&"jump") printed true. The
Writing a dictionary with = and bare names instead of : and quoted keys, as in {hp = 10, atk = 12}. The keys must be names that could be identifiers.
{hp = 10, name = "slime"} saves the quotes; its keys printed as &"hp" and &"name", StringNames, and still answer
to has("hp").
Using get() to tell a missing key from a null value
{"enemy": null}.get("enemy", "none") returned null, not “none”: the key is there, and its value is null. When a
key may hold null, ask has("enemy") first; get() gives the default only when the key is missing.
Entries stay in the order they were added
A loop over a dictionary gets its keys, in the order the entries were added; keys() and values() return them as
arrays in the same order. The class reference promises it: “Dictionaries will preserve the insertion order when adding
new entries.” Erasing a key and writing it again puts it at the end, so atk moved behind def in the run. That order
makes a dictionary a good counter: counts[drop] = counts.get(drop, 0) + 1 adds a key the first time an item drops and
raises its count every time after. Step through six drops:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first gem. counts is empty, so counts.get("gem", 0) gives 0, and 0 + 1 is stored under the new key "gem", the first entry.
- A new key, coin. coin is new too, so it gets the count 1 and becomes the second entry. New keys always go to the end.
- The second gem. gem is there now, so get() returns its 1, and 1 + 1 replaces it. The order of the entries does not change.
- A key. key is new: count 1, third entry.
- Coin again. coin goes up to 2.
- Gem again. gem goes up to 3. The run printed counts as { "gem": 3, "coin": 2, "key": 1 }, in the order each item first dropped.
extends SceneTree
# Entries keep the order they were added in: loops, erase, counting, sorting and ==.
func _initialize() -> void:
var stats := {"hp": 100, "atk": 12, "def": 5}
for stat in stats:
print(stat, " = ", stats[stat])
print("keys(): ", stats.keys(), " values(): ", stats.values())
stats.erase("atk")
stats["atk"] = 15
print("after erase(\"atk\") and stats[\"atk\"] = 15: ", stats)
var drops := ["gem", "coin", "gem", "key", "coin", "gem"]
var counts := {}
for drop in drops:
counts[drop] = counts.get(drop, 0) + 1
print("counts: ", counts)
for item: String in counts:
print(" ", item, " x", counts[item])
var by_name := counts.duplicate()
by_name.sort()
print("sort(): ", by_name)
print("counts == by_name: ", counts == by_name)
print("find_key(2): ", counts.find_key(2), " find_key(9): ", counts.find_key(9))
quit() from session import files, run
files("project.godot", "drop_counts.gd")
run("""
godot --headless --no-header --script drop_counts.gd
""") Output
$ godot --headless --no-header --script drop_counts.gd
hp = 100
atk = 12
def = 5
keys(): ["hp", "atk", "def"] values(): [100, 12, 5]
after erase("atk") and stats["atk"] = 15: { "hp": 100, "def": 5, "atk": 15 }
counts: { "gem": 3, "coin": 2, "key": 1 }
gem x3
coin x2
key x1
sort(): { "coin": 2, "gem": 3, "key": 1 }
counts == by_name: true
find_key(2): coin find_key(9): <null>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_session.py
The counts came out as { "gem": 3, "coin": 2, "key": 1 }, the order in which each item first dropped, and
for item: String in counts gave the loop variable the type of the keys. sort() reorders the entries by key, coin
before gem before key, and counts == by_name was still true: == compares the keys and values and, in the words
of the class reference, “The order of the entries does not matter.” find_key(2) goes the other way, from a value to
the first key that holds it, and returns null when no key does.
Key idea
A dictionary keeps its entries in the order they were added, so a loop, keys() and a printout all show that order,
while == ignores it.
Erasing entries of a dictionary while a loop reads it is not supported; loop over keys(), a separate array, and
erase from the dictionary itself, as conditions and loops showed.
Checkpoint
Check: Keys, get() and missing keys · Order, counting and ==
merge() keeps what is there unless you ask it to overwrite
A hero’s base stats and a sword’s stats have to become one set. merge() copies the entries of another dictionary into
the one it is called on; merged() does the same into a new dictionary and leaves both alone. For a key that both
have, the second argument decides: without it, or with false, the value already there stays, and with true the
value merged in replaces it.
extends SceneTree
# A hero's base stats and a sword's: merged without and with overwrite.
const DEFAULTS := {"volume": 80, "subtitles": false, "language": "en"}
func _initialize() -> void:
var base := {"hp": 100, "atk": 12, "res": {"fire": 10, "ice": 0}}
var sword := {"atk": 20, "crit": 0.1, "res": {"ice": 25}}
print("base.merged(sword): ", base.merged(sword))
print("base.merged(sword, true): ", base.merged(sword, true))
var armed := base.merged(sword, true)
print("is_same(armed.res, sword.res): ", is_same(armed.res, sword.res))
print("base after merged(): ", base)
base.merge(sword)
print("base after base.merge(sword): ", base)
var saved := {"volume": 35}
var settings := DEFAULTS.merged(saved, true)
settings["subtitles"] = true
print("settings: ", settings, " read-only: ", settings.is_read_only())
print("DEFAULTS: ", DEFAULTS, " read-only: ", DEFAULTS.is_read_only())
DEFAULTS.merge(saved, true)
print("DEFAULTS after DEFAULTS.merge(saved, true): ", DEFAULTS)
quit() from session import files, run
files("project.godot", "stats_merge.gd")
run("""
!godot --headless --no-header --script stats_merge.gd
""") Output
$ godot --headless --no-header --script stats_merge.gd
base.merged(sword): { "hp": 100, "atk": 12, "res": { "fire": 10, "ice": 0 }, "crit": 0.1 }
base.merged(sword, true): { "hp": 100, "atk": 20, "res": { "ice": 25 }, "crit": 0.1 }
is_same(armed.res, sword.res): true
base after merged(): { "hp": 100, "atk": 12, "res": { "fire": 10, "ice": 0 } }
base after base.merge(sword): { "hp": 100, "atk": 12, "res": { "fire": 10, "ice": 0 }, "crit": 0.1 }
settings: { "volume": 35, "subtitles": true, "language": "en" } read-only: false
DEFAULTS: { "volume": 80, "subtitles": false, "language": "en" } read-only: true
ERROR: Dictionary is in read-only state.
at: merge (core/variant/dictionary.cpp:333)
GDScript backtrace (most recent call first):
[0] _initialize (res://stats_merge.gd:23)
DEFAULTS after DEFAULTS.merge(saved, true): { "volume": 80, "subtitles": false, "language": "en" }
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 merge_session.py
base.merged(sword) kept the base’s atk of 12 and added crit; with true the sword’s 20 won. Look at res:
merging does not reach inside a nested dictionary. With overwrite the sword’s whole {"ice": 25} replaced the base’s
{"fire": 10, "ice": 0}, so the fire resistance was lost, and is_same(armed.res, sword.res) printed true: the result
holds the sword’s own dictionary, not a copy. Two questions about your case decide which call fits; answer them in the
table:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Call | base afterwards | A key in both | A nested dictionary |
|---|---|---|---|
| merge() Called as: base.merge(sword) | Poor changed in place: crit was added to base | Mixed base keeps its atk 12 | Mixed base keeps its res |
| merge(…, true) Called as: base.merge(sword, true) | Poor changed in place | Mixed the sword's atk 20 replaces 12 | Poor res becomes the sword's ice 25; fire 10 is lost |
| merged() Called as: base.merged(sword) | Good unchanged; a new dictionary comes back | Mixed base's atk 12 stays | Mixed base's res stays, whole |
| merged(…, true) Called as: base.merged(sword, true) | Good unchanged; a new dictionary comes back | Mixed the sword's atk 20 wins | Poor res is the sword's own dictionary, shared |
How the answers decide
- “Must the dictionary you start from stay as it was?” Yes, it is used again: rules out merge() and merge(…, true). merge() changes the dictionary it is called on; merged() leaves it alone and returns a new one.
- “When both have a key, whose value should win?” The one I start from: rules out merge(…, true) and merged(…, true). With overwrite true the value merged in replaces yours, as the sword's atk 20 replaced 12.
- “When both have a key, whose value should win?” The one I merge in: rules out merge() and merged(). Without overwrite a key that is already there keeps its value, so the sword's atk would be ignored.
- “When both have a key, whose value should win?” The one I merge in: adds a caveat to merge(…, true) and merged(…, true). A nested dictionary is replaced whole, not merged, and the result shares it: merge nested data yourself.
Settings show the usual case. The defaults must survive, and a value the player saved must win, so
DEFAULTS.merged(saved, true) gave { "volume": 35, "subtitles": true, "language": "en" } once the player’s change of
subtitles was written in, and DEFAULTS itself stayed as it was. A const dictionary is read-only: the run printed
read-only: true for DEFAULTS, and DEFAULTS.merge(saved, true) failed with Dictionary is in read-only state.
instead of quietly changing the defaults.
Merging nested stats
To add resistances key by key, merge the nested dictionaries yourself: copy the base with duplicate(true), then
armed.res.merge(sword.res, true). The practice task below adds nested values the same way.
Checkpoint
Check: merge(), merged() and overwrite
A typed dictionary checks its keys and values
Since Godot 4.4 a dictionary can name the types of its keys and values: var prices: Dictionary[String, int] is a
typed dictionary: A dictionary declared with a key type and a value type, such as Dictionary[String, int], in Godot 4.4 and later. It refuses keys and values it cannot convert to those types, and its methods still return Variant. that takes only String keys and int values. Both types must be
given, and Variant leaves a side open, as in Dictionary[String, Variant]. A typed dictionary is checked like a
typed array: the parser refuses what it can see before the run, and the dictionary itself refuses wrong keys and values
when the line runs, also through an untyped name, because the types belong to the dictionary. Typed collections came
to GDScript one release at a time, so check the release a project needs:
- 4.0
Typed arrays new syntax
Array[int] and Array[Node] check the values put into them. A dictionary could hold any keys and values.
- 4.2
Typed loop variables new syntax
for item: String in prices gives the loop variable a type, for arrays and for the keys of a dictionary.
- 4.4
Typed dictionaries new syntax
Dictionary[String, int] names a key type and a value type; Variant leaves either side open. The Inspector edits them with the right types.
- 4.7
The release the examples ran on
Godot 4.7.2 ran every example here. Nested typed collections, such as Dictionary[String, Array[String]], were still refused.
The timeline has 4 events, from 4.0 to 4.7.
extends SceneTree
# A typed dictionary of shop prices: what it takes, converts and refuses while the game runs.
var prices: Dictionary[String, int] = {"potion": 25, "arrow": 2}
func _initialize() -> void:
print("prices: ", prices, " is_typed(): ", prices.is_typed())
for item: String in prices:
print(" ", item, " costs ", prices[item])
var loose: Dictionary = prices
print("loose.is_typed(): ", loose.is_typed())
loose["shield"] = 40.9
print("after loose[\"shield\"] = 40.9: ", prices)
print("loose.set(\"bomb\", \"cheap\") returned ", loose.set("bomb", "cheap"))
price_as_text()
print("after price_as_text(): ", prices)
var stock: Dictionary[String, Variant] = {"name": "the corner shop", "open": true, "items": 3}
print("Dictionary[String, Variant]: ", stock)
var sale: Dictionary[String, int] = {}
sale.assign({"potion": 20, "arrow": 1.5})
print("assign() converted: ", sale)
var cost: int = prices.get("bow", 0)
print("cost: int = prices.get(\"bow\", 0): ", cost)
take_plain({"potion": 25})
quit()
func price_as_text() -> void:
var loose: Dictionary = prices
loose["bomb"] = "cheap"
print("never printed")
func take_plain(plain: Dictionary) -> void:
var typed: Dictionary[String, int] = plain
print("never printed: ", typed) extends SceneTree
# Four lines that typed dictionaries refuse before the game runs.
func _initialize() -> void:
var prices: Dictionary[String, int] = {"potion": "cheap"}
prices[7] = 3
prices["bow"] = "dear"
var cost := prices.get("bow", 0)
quit() extends SceneTree
# A typed dictionary whose values are typed arrays: the waves of each level.
var waves: Dictionary[String, Array[String]] = {}
func _initialize() -> void:
quit() from session import files, run
files("project.godot", "typed_prices.gd", "typed_mistakes.gd", "nested_typed.gd")
run("""
!godot --headless --no-header --script typed_prices.gd
!godot --headless --no-header --check-only --script typed_mistakes.gd
!godot --headless --no-header --check-only --script nested_typed.gd
""") Output
$ godot --headless --no-header --script typed_prices.gd
prices: { "potion": 25, "arrow": 2 } is_typed(): true
potion costs 25
arrow costs 2
loose.is_typed(): true
after loose["shield"] = 40.9: { "potion": 25, "arrow": 2, "shield": 40 }
ERROR: Attempted to set a variable of type 'String' into a TypedDictionary.Value of type 'int'.
at: _internal_validate (./core/variant/container_type_validate.h:73)
GDScript backtrace (most recent call first):
[0] _initialize (res://typed_prices.gd:15)
ERROR: Condition "!_p->typed_value.validate(value, "set")" is true. Returning: false
at: set (core/variant/dictionary.cpp:207)
GDScript backtrace (most recent call first):
[0] _initialize (res://typed_prices.gd:15)
loose.set("bomb", "cheap") returned false
ERROR: Attempted to set a variable of type 'String' into a TypedDictionary.Value of type 'int'.
at: _internal_validate (./core/variant/container_type_validate.h:73)
GDScript backtrace (most recent call first):
[0] price_as_text (res://typed_prices.gd:32)
[1] _initialize (res://typed_prices.gd:16)
ERROR: Condition "!_p->typed_value.validate(value, "set")" is true. Returning: false
at: set (core/variant/dictionary.cpp:207)
GDScript backtrace (most recent call first):
[0] price_as_text (res://typed_prices.gd:32)
[1] _initialize (res://typed_prices.gd:16)
SCRIPT ERROR: Invalid assignment of property or key 'bomb' with value of type 'String' on a base object of type 'Dictionary[String, int]'.
at: price_as_text (res://typed_prices.gd:32)
GDScript backtrace (most recent call first):
[0] price_as_text (res://typed_prices.gd:32)
[1] _initialize (res://typed_prices.gd:16)
after price_as_text(): { "potion": 25, "arrow": 2, "shield": 40 }
Dictionary[String, Variant]: { "name": "the corner shop", "open": true, "items": 3 }
assign() converted: { "potion": 20, "arrow": 1 }
cost: int = prices.get("bow", 0): 0
SCRIPT ERROR: Trying to assign a dictionary of type "Dictionary" to a variable of type "Dictionary[String, int]".
at: take_plain (res://typed_prices.gd:37)
GDScript backtrace (most recent call first):
[0] take_plain (res://typed_prices.gd:37)
[1] _initialize (res://typed_prices.gd:26)
$ godot --headless --no-header --check-only --script typed_mistakes.gd
SCRIPT ERROR: Parse Error: Cannot include a value of type "String" as "int".
at: GDScript::reload (res://typed_mistakes.gd:5)
SCRIPT ERROR: Parse Error: Cannot have a value of type "String" in a dictionary of type "Dictionary[String, int]".
at: GDScript::reload (res://typed_mistakes.gd:5)
SCRIPT ERROR: Parse Error: Invalid index type "int" for a base of type "Dictionary[String, int]".
at: GDScript::reload (res://typed_mistakes.gd:6)
SCRIPT ERROR: Parse Error: Cannot assign a value of type "String" as "int".
at: GDScript::reload (res://typed_mistakes.gd:7)
SCRIPT ERROR: Parse Error: Value of type "String" cannot be assigned to a variable of type "int".
at: GDScript::reload (res://typed_mistakes.gd:7)
SCRIPT ERROR: Parse Error: The variable type is being inferred from a Variant value, so it will be typed as Variant. (Warning treated as error.)
at: GDScript::reload (res://typed_mistakes.gd:8)
ERROR: Failed to load script "res://typed_mistakes.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script nested_typed.gd
SCRIPT ERROR: Parse Error: Nested typed collections are not supported.
at: GDScript::reload (res://nested_typed.gd:4)
ERROR: Failed to load script "res://nested_typed.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typed_session.py
What Godot did with each line, from the run above:
| The line | What Godot did |
|---|---|
{"potion": "cheap"} for a Dictionary[String, int] |
refused it before the run: Cannot have a value of type "String" in a dictionary of type "Dictionary[String, int]". |
prices[7] = 3 |
refused it before the run: Invalid index type "int" for a base of type "Dictionary[String, int]". |
var cost := prices.get("bow", 0) |
refused it before the run: The variable type is being inferred from a Variant value, so it will be typed as Variant. |
Dictionary[String, Array[String]] |
refused it before the run: Nested typed collections are not supported. |
loose["bomb"] = "cheap" |
refused it when the line ran and stopped the function; prices kept its entries |
loose["shield"] = 40.9 |
stored it as 40 |
get() still returns a Variant, as the class reference notes for every method of a typed dictionary, so := cannot
take a type from it; write the type yourself, var cost: int = prices.get("bow", 0). loose.set("bomb", "cheap")
returned false instead of stopping the function, which suits code that wants to react to a refusal. An untyped
dictionary cannot be assigned to a typed variable: take_plain() stopped with
Trying to assign a dictionary of type "Dictionary" to a variable of type "Dictionary[String, int]", and assign() is
the way to copy its entries in, converting 1.5 to 1 on the way.
New in Godot 4.4
Typed dictionaries arrived in Godot 4.4, whose release page sums them up as “Keys required”. Projects for Godot 4.3 and earlier have untyped dictionaries only.
Checkpoint
Check: What a typed dictionary refuses
Assignment shares a dictionary, and a deep copy duplicates the ones inside
Dictionaries are passed by reference: Shared, not copied: an assignment or a call gives the same array, dictionary, packed array or object another name, so a change made through any of the names shows through all of them. More in “Arrays and typed arrays”, like arrays: var leader := asha is a
second name for the same dictionary, and a function that changes its parameter changes the caller’s dictionary. A
dictionary inside a dictionary is shared the same way, and that is where copies go wrong. Step through one hero’s data
and two copies of it, one made with duplicate() and one with duplicate(true):
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- One dictionary. asha holds a dictionary whose stats entry is a second, nested dictionary.
- leader := asha. leader is another name for the same dictionary, so leader.level = 5 is seen through asha: the run printed 5.
- level_up(asha). The function gets the same dictionary as its parameter. It raised level to 6 and hp to 110, and both changes stayed after it returned.
- A shallow copy. asha.duplicate() makes a new outer dictionary for ravi, but its stats entry points at the same stats as asha's.
- Ravi changes. ravi.name = "Ravi" changed only the copy. ravi.stats.hp = 60 changed the shared stats, so asha's hp fell to 60 as well.
- A deep copy. asha.duplicate(true) copies the nested stats too, so mira points at stats of her own.
- Mira changes. mira.stats.atk = 30 changed only Mira's stats; asha still had atk 12. is_same(asha.stats, mira.stats) printed false, and for ravi it printed true.
extends SceneTree
# One hero's data under two names, a function that changes it, a shallow copy and a deep one.
func level_up(hero: Dictionary) -> void:
hero.level += 1
hero.stats.hp += 10
func _initialize() -> void:
var asha := {"name": "Asha", "level": 1, "stats": {"hp": 100, "atk": 12}}
var leader := asha
leader.level = 5
print("asha.level after leader.level = 5: ", asha.level)
level_up(asha)
print("after level_up(asha): ", asha)
var ravi := asha.duplicate()
ravi.name = "Ravi"
ravi.stats.hp = 60
print("asha after ravi, a shallow copy, changed: ", asha)
var mira := asha.duplicate(true)
mira.name = "Mira"
mira.stats.atk = 30
print("asha after mira, a deep copy, changed: ", asha)
print("mira: ", mira)
print("is_same(asha.stats, ravi.stats): ", is_same(asha.stats, ravi.stats),
" is_same(asha.stats, mira.stats): ", is_same(asha.stats, mira.stats))
quit() from session import files, run
files("project.godot", "hero_copies.gd")
run("""
godot --headless --no-header --script hero_copies.gd
""") Output
$ godot --headless --no-header --script hero_copies.gd
asha.level after leader.level = 5: 5
after level_up(asha): { "name": "Asha", "level": 6, "stats": { "hp": 110, "atk": 12 } }
asha after ravi, a shallow copy, changed: { "name": "Asha", "level": 6, "stats": { "hp": 60, "atk": 12 } }
asha after mira, a deep copy, changed: { "name": "Asha", "level": 6, "stats": { "hp": 60, "atk": 12 } }
mira: { "name": "Mira", "level": 6, "stats": { "hp": 60, "atk": 30 } }
is_same(asha.stats, ravi.stats): true is_same(asha.stats, mira.stats): false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 copies_session.py
duplicate() is a shallow copy: A copy of an array or dictionary whose inner arrays and dictionaries are still shared with the original. duplicate() makes one. More in “Arrays and typed arrays”: ravi got a new outer dictionary, so his name changed
alone, but his stats were Asha’s, and ravi.stats.hp = 60 took Asha down to 60 too. duplicate(true) is a
deep copy: A copy of an array or dictionary in which every inner array and dictionary is copied too, so nothing is shared with the original. duplicate(true) makes one; Resources inside it stay shared. More in “Arrays and typed arrays”: it copies every nested dictionary and array, and Mira’s attack of 30 stayed hers.
A Resource inside stays shared even then, the class reference notes. Use the deep copy whenever the data nests and the
copy will be changed: a hero made from a template, a save slot copied before editing, the starting state of a level.
Key idea
=, parameters and merged() share nested dictionaries; when a copy will be changed, make it with
duplicate(true), or is_same() will show the inner data is still one object.
Checkpoint
Check: Shared and copied dictionaries
Practise: total the drops and apply the modifiers
loot.gd, below, asks for two functions for a loot screen. total_drops() adds up a fight’s drops into a
Dictionary[String, int], in the order the items first dropped, and apply_modifiers() adds each modifier to a copy
of a hero’s base stats, nested resistances included. Neither may change what it is given.
Copy loot.gd and check_loot.gd into a folder with an empty project.godot, fill in both functions and start
godot --headless --no-header --script check_loot.gd from that folder.
extends Node
# Your task: two functions for a loot screen. Neither may change what it is given.
# Adds up the drops of a fight. Each event is a dictionary such as {"item": "gem", "count": 2}; the result maps each
# item to its total, in the order the items first dropped: two gems, five coins and one gem give {"gem": 3, "coin": 5}.
func total_drops(events: Array[Dictionary]) -> Dictionary[String, int]:
var totals: Dictionary[String, int] = {}
return totals # replace this line
# The base stats with every modifier added in turn. A modifier such as {"atk": 5} adds 5 to "atk" (a stat that is not
# there yet starts at 0). A nested dictionary adds into the nested stats of that name the same way:
# {"res": {"fire": 20}} adds 20 to base["res"]["fire"].
func apply_modifiers(base: Dictionary, mods: Array[Dictionary]) -> Dictionary:
return base # replace this line extends SceneTree
# Checks loot.gd. Run it in the project's folder: godot --headless --no-header --script check_loot.gd
var passed := 0
var total := 0
func check(what: String, ok: bool) -> void:
total += 1
passed += int(ok)
print("%s %s" % ["PASS" if ok else "FAIL", what])
func _initialize() -> void:
var loot: Node = load("res://loot.gd").new()
var no_events: Array[Dictionary] = []
check("no drops give {}", loot.total_drops(no_events) == {})
var fight: Array[Dictionary] = [{"item": "gem", "count": 2}, {"item": "coin", "count": 5}, {"item": "gem", "count": 1}]
var before := fight.duplicate(true)
var totals = loot.total_drops(fight)
var got: Dictionary = totals if totals is Dictionary else {}
check("gems and coins add up", got == {"gem": 3, "coin": 5})
check("items in the order they first dropped", got.keys() == ["gem", "coin"])
check("the result is a Dictionary[String, int]",
got.is_typed() and got.get_typed_key_builtin() == TYPE_STRING and got.get_typed_value_builtin() == TYPE_INT)
check("the events are unchanged", fight == before)
var base := {"hp": 100, "atk": 12, "res": {"fire": 10}}
var base_before := base.duplicate(true)
var mods: Array[Dictionary] = [{"atk": 5}, {"crit": 2}, {"atk": 3, "res": {"fire": 20, "ice": 5}}]
var mods_before := mods.duplicate(true)
var hero: Dictionary = loot.apply_modifiers(base, mods)
check("atk and crit add up", hero.atk == 20 and hero.crit == 2 and hero.hp == 100)
check("nested res adds up", hero.res == {"fire": 30, "ice": 5})
check("base is unchanged, nested res too", base == base_before)
check("the modifiers are unchanged", mods == mods_before)
var solo := {"hp": 50}
var none: Array[Dictionary] = []
var copy: Dictionary = loot.apply_modifiers(solo, none)
copy.hp = 1
check("with no modifiers the result is a copy, not base", solo.hp == 50)
loot.free()
print("%d of %d checks passed" % [passed, total])
quit(0 if passed == total else 1) Here is the checker’s report on a loot.gd that does the job, and on a tempting one:
from session import files, run
# The checker on a loot.gd that solves the task, then on a tempting one with two mistakes.
files("practice/project.godot=right/project.godot", "practice/check_loot.gd=right/check_loot.gd",
"practice/solution/loot.gd=right/loot.gd",
"practice/project.godot=tempting/project.godot", "practice/check_loot.gd=tempting/check_loot.gd",
"practice/tempting/loot.gd=tempting/loot.gd")
run("""
cd right
godot --headless --no-header --script check_loot.gd
cd ../tempting
!godot --headless --no-header --script check_loot.gd
""") Output
$ cd right
$ godot --headless --no-header --script check_loot.gd
PASS no drops give {}
PASS gems and coins add up
PASS items in the order they first dropped
PASS the result is a Dictionary[String, int]
PASS the events are unchanged
PASS atk and crit add up
PASS nested res adds up
PASS base is unchanged, nested res too
PASS the modifiers are unchanged
PASS with no modifiers the result is a copy, not base
10 of 10 checks passed
$ cd ../tempting
$ godot --headless --no-header --script check_loot.gd
PASS no drops give {}
SCRIPT ERROR: Invalid access to property or key 'gem' on a base object of type 'Dictionary[String, int]'.
at: total_drops (res://loot.gd:11)
GDScript backtrace (most recent call first):
[0] total_drops (res://loot.gd:11)
[1] _initialize (res://check_loot.gd:20)
FAIL gems and coins add up
FAIL items in the order they first dropped
PASS the result is a Dictionary[String, int]
PASS the events are unchanged
PASS atk and crit add up
PASS nested res adds up
FAIL base is unchanged, nested res too
PASS the modifiers are unchanged
PASS with no modifiers the result is a copy, not base
7 of 10 checks passed
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
The tempting total_drops() adds to totals[event.item] before that key exists, so the first gem stopped the function
with Invalid access to property or key 'gem'. Its apply_modifiers() starts from base.duplicate(), a shallow copy,
so adding fire resistance changed the base hero’s own res. Your output should end with 10 of 10 checks passed. Then
take the questions on this page’s five ideas; the one you know least comes first, and each answer says what it gets
right or misses:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: how do you read a value from a GDScript dictionary when the key might be missing?
Use get() with a default: stats.get("luck", 0) returns 0 when there is no key "luck" and adds nothing to the
dictionary. Square brackets on a missing key are an error that stops the function, as stats["luck"] did on Godot
4.7.2, and get() without a default returns null. When you need to know whether the key is there, ask
stats.has("luck") or "luck" in stats; both look only at keys. One case needs care: a key whose value is null.
{"enemy": null}.get("enemy", "none") returned null, because the key exists, so test has() when null is a value
your game stores. In a typed dictionary get() still returns a Variant, so write the type yourself:
var cost: int = prices.get("bow", 0).
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A settings menu starts with var settings = defaults and loads the save with settings.merge(saved, true). After a player sets the volume to 35, Reset to defaults (settings = defaults.duplicate()) keeps 35 instead of the default 80. Explain the bug, fix it, and say how you would stop it coming back.
-
Fresher to mid
A game counts kills in a dictionary keyed by enemy id, an int, saves it with JSON.stringify() and loads it back with JSON.parse_string(). After loading, kills.get(enemy_id, 0) returns 0 for every enemy, though the file holds the counts. What do you check, and how do you fix the loader?
Key takeaways
- A dictionary finds a value by its key;
get(key, default)andhas()deal with a missing key, which square brackets turn into an error, and keys of different types, such as 1, 1.0 and “1”, are different keys. - Entries keep the order they were added in, which makes a dictionary a natural counter with
get(key, 0) + 1;sort()orders the keys, and==ignores order. merge()changes the dictionary it is called on andmerged()returns a new one; overwrite decides whose value wins, and a nested dictionary is replaced whole and shared, never merged.- A typed dictionary such as
Dictionary[String, int](Godot 4.4 and later) is checked by the parser and at run time, converts what it can, and its methods still return a Variant. - Assignment, parameters and
duplicate()share nested dictionaries;duplicate(true)copies the dictionaries and arrays inside as well.
Terms in this lesson
- dictionary
- A collection of entries, each a value stored under a unique key, such as {"hp": 100}. A lookup by key finds the value, and a GDScript Dictionary keeps its entries in the order they were added.
- typed dictionary
- A dictionary declared with a key type and a value type, such as Dictionary[String, int], in Godot 4.4 and later. It refuses keys and values it cannot convert to those types, and its methods still return Variant.
References
- Dictionary class reference (Godot Engine)
- GDScript reference: Dictionary and typed dictionaries (Godot Engine)
- Static typing in GDScript: typed dictionaries (Godot Engine)
- Godot 4.4 release page: typed dictionaries (Godot Engine)
- Godot 4.0 sets sail (typed arrays) (Godot Engine)
Report a problem with this lesson
Kept only in this browser. Your Learn progress