doc 2 of 4

Part 01 — What an item is

You will build: an ItemData resource type, three actual items authored as .tres files, and a demonstration of the shared-resource bug that this design exists to prevent.

Why a Resource and not a Dictionary#

You could describe a sword as {"name": "Sword", "damage": 12}. People do. Here's what you give up:

  • No autocompletion. item["dmage"] is a typo the editor can't catch; it returns null and crashes somewhere else entirely.
  • No Inspector. Balancing damage numbers means editing code, so designers can't, and neither can you without a restart.
  • No type checking. Nothing stops damage being the string "12".

A custom Resource fixes all three. It is a class with typed exported fields that Godot can save to a file, load, and edit in the Inspector — and it's the engine's own recommendation for game content over JSON or CSV.

Dictionaries still have their place: transient runtime structures and save payloads. Just not for authoring content.

The definition#

item_data.gd:

class_name ItemData
extends Resource

## A single item type. One .tres file per item; never mutated at runtime.

@export var id: StringName = &""
@export var display_name: String = ""
@export_multiline var description: String = ""
@export var icon: Texture2D

@export_group("Stacking")
## 1 means "never stacks" — swords, unique quest items.
@export_range(1, 999) var max_stack: int = 99

@export_group("Gameplay")
@export var sell_price: int = 0
@export var consumable: bool = false

@export_group collapses fields into folding sections in the Inspector — free organisation once you have more than about six fields.

Note id is a StringName, not a String. StringName values are interned, so comparisons are pointer comparisons rather than character-by-character. Item ids get compared constantly (every stack check, every save load), so this is one of the few places the distinction earns its keep. Write them as &"health_potion".

Authoring three items#

In the FileSystem dock, right-click a folder → New Resource… → search ItemData → save as health_potion.tres. Fill the fields in the Inspector. Repeat for iron_sword.tres (max_stack 1) and wheat.tres.

That's the payoff: adding the fourth item is a file, not a code change. No enum to extend, no switch statement to update, no recompile of anything.

Predict before you continue: you set max_stack to 1 on the sword. Nothing in item_data.gd enforces stacking rules. So where does the number actually get used, and what happens right now if code ignores it? (Answer: nothing uses it yet — part 02 does. A definition describes; it doesn't police.)

The trap: resources are shared#

This is the most important paragraph on this shelf.

When you load("res://items/health_potion.tres") twice, Godot does not give you two objects. It gives you the same one, from its cache. Ten enemies holding the same EnemyStats.tres are holding one object between them.

So this innocent-looking line:

func drink(item: ItemData) -> void:
	item.uses_left -= 1   # ← catastrophic

...decrements the definition, and every health potion in the game — in chests, in shops, in unspawned levels — now has one fewer use. Save the game and you've persisted the corruption.

Prove it to yourself. Add a temporary @export var uses_left: int = 3 to ItemData, load the same .tres from two different scripts, decrement it in one, print it from the other. Watch them move together. Then delete the field — because the fix is not to be careful, it's to make the mistake impossible:

Definitions are immutable. Anything that changes belongs somewhere else.

That "somewhere else" is part 02's stack objects. ItemData answers what is a health potion; it never answers how many are left in yours.

When you genuinely need a mutable copy#

Occasionally you do want a per-instance copy — a randomly-rolled weapon, say. Godot gives you two methods, and which one you want depends on how deep the thing you're about to change is:

var shallow: ItemData = template.duplicate()          # nested arrays, dicts and resources SHARED
var deep: ItemData = template.duplicate(true)         # nested arrays/dicts copied recursively
var deepest: ItemData = template.duplicate_deep()     # explicit control over sub-resources

duplicate() with no argument copies the top level only — nested Array, Dictionary and Resource properties still point at the originals, so mutating one puts you back in the same trap one level down. duplicate(true) recursively copies nested arrays and dictionaries, and duplicates sub-resources that are marked scene-local.

duplicate_deep() is the newer, explicit version, taking a mode: DEEP_DUPLICATE_NONE, DEEP_DUPLICATE_INTERNAL (the default, same as duplicate(true)), or DEEP_DUPLICATE_ALL, which copies every sub-resource it finds regardless of path. Reach for it when you need the strongest guarantee.

A warning about older tutorials on this exact topic. Before Godot 4.5, duplicate(true) did not copy resources stored inside Array or Dictionary properties — which is precisely the shape an inventory has. Engine PR #100673 overhauled duplication and fixed it. So material written against 4.0–4.4 that tells you to hand-roll your own recursive copy because duplicate(true) can't be trusted is describing an engine that no longer exists. On 4.5 and later, use the built-ins.

Holding a list of them#

Typed arrays of a custom resource work exactly as you'd hope, and give you a proper drag-and-drop list in the Inspector:

@export var starting_items: Array[ItemData] = []

If the Inspector shows a generic untyped array instead of an ItemData-filtered one, the usual cause is a missing or misspelled class_name in item_data.gd.

One security note#

A .tres file stores the path of the script it uses, and loading it brings that script along — so loading a resource file means running code you didn't necessarily write. That's fine and normal for your own project files. It is a genuine vulnerability for anything a player supplies: downloaded mods, saves traded between friends, workshop content. Use JSON for those, because JSON can only ever be data.

Worth knowing where this advice comes from: Godot's docs warn about it explicitly for FileAccess.store_var/get_var with objects enabled — deserialized objects can contain code which gets executed — but they do not carry the same warning on the resource-loading pages. The extension to .tres files is community wisdom rather than documented policy. It's sound, and now you know it isn't something you can point a colleague at a docs page for. It comes up again in the save/load shelf.

Checkpoint — definition of done#

  • Three .tres item files exist and are editable in the Inspector
  • @export_group visibly folds the Stacking and Gameplay sections
  • You reproduced the shared-mutation bug, then removed the field that allowed it
  • An Array[ItemData] export shows a typed list in the Inspector
  • You can explain: why is id a StringName rather than a String?

Stretch (no instructions)#

Add an EquipmentData type that extends ItemData with an equipment slot and stat bonuses. Then decide — and justify in one sentence — whether "a health potion" and "an iron sword" should really be the same class with different values, or two classes sharing a base. Both are defensible; the reasoning is what matters.

If you get stuck#

  • ItemData doesn't appear in the New Resource dialog → the script needs class_name ItemData on its first line, and the file must be saved. Godot rescans on save.
  • Inspector fields don't appear → @export requires an explicit type or a default value it can infer from. @export var icon alone won't work; @export var icon: Texture2D will.
  • Changes to a .tres don't show in game → you're likely holding a preload()ed copy from before the edit. preload resolves at parse time; restart the scene.

Next: Part 02 — the container, where items get counts, stacks, and a signal that makes the UI free.