Skip to content

L1.5 — Collections & Reference Semantics

What you'll learn

  • Use Array, typed arrays (Array[int]), and Dictionary, with their common operations.
  • Read elements safely — bounds on arrays, .has/.get on 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:

var a := [1, 2, 3]
var b := a
b.append(4)
print(a)

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:

var scores: Array[int] = [10, 20, 30]
scores.append("oops")    # parse-time error: expected int

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 typesint, float, bool, String, StringName, Vector2, Color, Rect2 — are copied on assignment. var y := x gives y its own independent value.
  • Reference typesArray, Dictionary, and every object (Node, Resource, …) — are shared on assignment. var b := a makes b a 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.

  1. 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.
  2. Now insert .duplicate() at the assignment, repeat, and confirm the original is no longer affected.
  3. 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)).
  4. Make a typed array var nums: Array[int] = [], try to append("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.