L1.5 — Collections & Reference Semantics¶
What you'll learn
- Use
Array, typed arrays (Array[int]), andDictionary, with their common operations. - Read elements safely — bounds on arrays,
.has/.geton dictionaries — without runtime errors. - State the rule that separates value types from reference types, and why it matters most here.
- Copy a collection deliberately with
.duplicate()instead of aliasing it by assignment.
How it applies
- Aliasing is silent corruption. Assigning one array to another name does not copy it — both names point at the same array. "Editing the copy" then edits the original, which is how a shared default loadout gets mutated by one entity and every entity inherits the change. This single misunderstanding is behind a large share of "the data changed and I never touched it" reports.
- Out-of-bounds and missing keys crash at runtime. Reading
arr[5]on a 3-element array, or a dictionary key that was never set, errors when that line runs — often a code path QA missed. Guarding with a size check or.get(key, default)converts a crash into defined behavior. - Typed arrays catch the wrong element early.
Array[int]rejects a non-int at parse time, the same parse-time-contract benefit as typed variables, applied to collection contents. - Save/load lives or dies on copy semantics. A save system that stores a reference to live game state, instead of a snapshot, writes whatever the state happens to be at save time — and a later edit can change what you thought you already saved. The fix is a deliberate copy, the exact tool this chapter introduces.
Try it first
Predict the output before reading on:
Does a print [1, 2, 3] or [1, 2, 3, 4]? Commit to an answer. The Concepts section explains
which it is and why — and the answer is the heart of the chapter.
Concepts¶
Arrays¶
An Array is an ordered, index-from-zero list:
var items := ["sword", "shield", "potion"]
print(items[0]) # sword
print(items.size()) # 3
items.append("torch") # add to the end
items.remove_at(1) # remove index 1 ("shield")
print("potion" in items) # true — `in` tests membership
Reading past the end (items[99]) is a runtime error, not a silent null. Guard with size() or the
in operator before indexing when the index could be out of range.
A typed array constrains the element type:
Type your arrays for the same reason you type variables — the wrong element is rejected before the game runs. The game books use typed arrays for collections of a known type (an array of affixes, an array of state nodes).
Dictionaries¶
A Dictionary maps keys to values:
var stats := {"hp": 30, "armor": 5}
print(stats["hp"]) # 30
stats["mana"] = 10 # add or overwrite a key
print(stats.has("armor")) # true
print(stats.get("luck", 0)) # 0 — key absent, so the supplied default is returned
Reading a key that does not exist with [] is an error; .get(key, default) is the safe read that
returns a fallback instead of crashing. .has(key) tests presence. Keys can be any type, but strings
and ints are the common cases.
Packed arrays (a brief note)¶
For large homogeneous numeric or string data, Godot offers packed arrays — PackedInt32Array,
PackedFloat32Array, PackedStringArray, PackedVector2Array — stored more compactly than a general
Array. You will meet them in specific engine APIs (vertex data, some signals). Treat them as
"Array, but tightly packed for one element type"; reach for a plain typed Array until an API hands
you a packed one.
The rule: value types are copied, reference types are shared¶
This is the most important semantics rule in GDScript, and collections are where it bites.
- Value types —
int,float,bool,String,StringName,Vector2,Color,Rect2— are copied on assignment.var y := xgivesyits own independent value. - Reference types —
Array,Dictionary, and every object (Node,Resource, …) — are shared on assignment.var b := amakesba second name for the same array.
So the "Try it first" answer is [1, 2, 3, 4]. b := a did not copy the array; it gave the one array
a second name, and b.append(4) appended to the array both names point at.
Example
The contrast in four lines:
var x := 10 # value type
var y := x # y is an independent copy
y += 5
print(x) # 10 — x is untouched
var a := [1, 2] # reference type
var b := a # b is the SAME array as a
b.append(3)
print(a) # [1, 2, 3] — a sees the change
Same syntax (:=), opposite outcome, because int is a value type and Array is a reference
type. This is also why passing an array into a function (L1.4) lets the function mutate the
caller's array — the parameter is the same shared handle.
Copying on purpose: .duplicate()¶
When you actually want an independent copy of a collection, ask for one:
var a := [1, 2, 3]
var b := a.duplicate() # b is a new array with the same contents
b.append(4)
print(a) # [1, 2, 3] — unaffected
.duplicate() is shallow: it copies the array, but if the elements are themselves reference types
(arrays inside arrays, dictionaries inside arrays), the inner objects are still shared. For a fully
independent copy all the way down, use .duplicate(true) (deep). Dictionaries have the same
.duplicate() / .duplicate(true) pair. Choosing shallow versus deep is a real decision when the
data nests — and it is exactly the decision a save system makes when snapshotting state.
Walkthrough¶
Use your L1.1 scene and script.
- Reproduce the "Try it first" result: make an array, assign it to a second name, append to the second, print the first. Confirm both reflect the change.
- Now insert
.duplicate()at the assignment, repeat, and confirm the original is no longer affected. - Trigger two runtime errors deliberately, then fix each: read an out-of-range array index (fix with
a
size()guard), and read an absent dictionary key with[](fix with.get(key, default)). - Make a typed array
var nums: Array[int] = [], try toappend("x"), and read the parse-time error. Change it to append an int.
Optional sanity check
Build a nested structure: var party := [[1, 2], [3, 4]]. Do var shallow := party.duplicate()
and then shallow[0].append(99). Print party. The inner change is visible in the original,
because shallow duplication shared the inner arrays. Repeat with party.duplicate(true) and confirm
the original is now protected. That is the shallow-versus-deep decision, felt directly.
Self-check quiz¶
Q1 — After var a := {\"k\": 1} and var b := a, then b[\"k\"] = 9, what is a[\"k\"]?
A. 1, because b is a copy.
B. 9, because b and a are the same dictionary.
C. Error — you cannot reassign a dictionary key.
D. null, because the key was overwritten.
Reveal answer
B. Dictionary is a reference type, so b := a makes b a second name for the same
dictionary; writing through b is visible through a. A assumes value semantics, which apply
to int/float/etc., not dictionaries. C and D invent behaviors — key assignment is normal and
does not null anything.
Q2 — Which read does NOT risk a runtime error when the key/index may be absent?
A. my_array[index]
B. my_dict[key]
C. my_dict.get(key, default)
D. my_array.front() on a possibly-empty array
Reveal answer
C. .get(key, default) returns the default when the key is absent, so it never errors on a
missing key. A errors if index is out of range; B errors if key is absent; D errors (or
returns an unhelpful value) on an empty array. The safe patterns are .get for dictionaries and
a size()/in guard for arrays.
Q3 — You need an independent copy of var loadout := [\"a\", \"b\"] so edits don't touch the original. Which line?
A. var copy := loadout
B. var copy = loadout (no colon)
C. var copy := loadout.duplicate()
D. var copy := Array(loadout)
Reveal answer
C. .duplicate() produces a new array with the same contents; edits to it do not affect
loadout. A and B both alias — := versus = changes typing, not copy semantics, so both make
copy a second name for the same array. D is not how you copy an array in GDScript (Array(...)
is not a copy constructor here).
Integration question¶
Q4 — open
A save system does this at save time: saved_state = player.inventory (where inventory is an
Array). It writes saved_state to disk later, at the end of the frame. A tester reports that
items picked up after pressing Save still end up in the save file. Explain the bug using this
chapter's value-versus-reference rule, and give the one-line fix — then say why the choice between
.duplicate() and .duplicate(true) might still matter here.
Reveal expected answer
inventory is an Array, a reference type, so saved_state = player.inventory does not
snapshot the inventory — it makes saved_state a second name for the live array. Items the
player picks up after Save mutate that same array, so when the bytes are actually written the
"saved" state reflects the later pickups. The fix is to snapshot at save time:
saved_state = player.inventory.duplicate(), which gives an independent array frozen at that
moment. Shallow versus deep then matters based on the elements: if the inventory holds plain
values (strings, ints), shallow .duplicate() is enough; if it holds objects or nested
containers that could also change after Save, those inner references are still shared under a
shallow copy, and you need .duplicate(true) to freeze the whole structure. The general lesson:
a save is a copy of state, and copying a reference type is something you must do on purpose.