Saving inventory items in Godot 4 without saving Resources
Save an inventory in Godot 4 by writing item ids and counts to JSON, then resolve those ids through a catalog when the game loads. The Resource is your authoring data, not your save format.
Do not write a Godot Resource straight into your save file and hope it will still be the same item next month. A Resource is excellent authoring data. It carries an icon, a display name, a stack limit, and whatever fields your editor needs. A save file needs something much narrower: a stable item id and a count. Write that small representation to JSON, then resolve the id through a catalog when the game loads.
This distinction is what keeps a saved inventory usable after you move an item to a different folder, change its icon, or add a new field. The save remembers "iron_potion" and 3. The catalog supplies the current Resource for "iron_potion". If you save the whole Resource-shaped dictionary instead, you have coupled player data to the editor layout and made every authoring change a migration problem.
Give every item a stable id
The first requirement is boring and non-negotiable. Every item needs an id that never changes after players can save it. The id is not the display name. You can rename "Iron Potion" to "Iron Tonic" without breaking old saves, but you cannot silently rename "iron_potion" to "iron_tonic" unless you also ship a migration or an alias.
# ItemData.gd
extends Resource
class_name ItemData
@export var id: String = ""
@export var display_name: String = ""
@export var icon: Texture2D
@export var max_stack: int = 99Keep the id lowercase and explicit in the inspector. Do not derive it from the Resource filename at load time. A filename is convenient for you and fragile for a player. If you have already shipped files with names as ids, keep an alias table when you rename one instead of making the old save point at an empty slot.
Save a list of ids and counts
An inventory save is a list of records. Each record says which item was present and how many. The list does not contain an icon path, a display name, or a copy of the Resource. Those values belong to the catalog and can change without changing the player's progress.
func inventory_to_data(inventory: InventoryLite) -> Dictionary:
var entries: Array = []
for slot in inventory.slots():
if slot.item == null or int(slot.count) <= 0:
continue
entries.append({
"id": String(slot.item.id),
"count": int(slot.count),
})
return {
"version": 1,
"items": entries,
}The call to slots() is safe for reading because Inventory Lite returns a copy of its internal entries. That is exactly what you want while building save data. Do not edit the dictionaries you get back and expect the inventory to change. Mutations still go through add_item and remove_item. The saved representation is also compact enough to inspect by hand when a tester sends you a broken file.
Resolve ids through a catalog
At load time, build one catalog from the Resources your game already knows about. A small autoload or a preloaded array is enough for a fixed game. The important contract is that catalog.get(id) returns the current Resource, or null when the id is unknown.
# ItemCatalog.gd
extends Node
class_name ItemCatalog
var by_id: Dictionary = {}
func register(item: Resource) -> void:
if item == null or not ("id" in item):
return
var id := String(item.id)
if id != "":
by_id[id] = item
func get_item(id: String) -> Resource:
return by_id.get(id, null)Register every item before you restore the inventory. If the catalog is still empty when you load, every record looks missing and you will either get an empty bag or be tempted to create duplicate fallback items. Loading order is part of the save contract, so make it explicit: initialize the catalog, create the InventoryLite node, then restore its records.
Load without losing unknown items
An unknown id is not a reason to abort the entire load. It can mean the player has a DLC item that is not installed, or that a later build renamed an item before you added the alias. Keep the known records, report the unknown ones, and do not write the result back over the original file until you have decided what your game should do with them.
func data_to_inventory(data: Dictionary, inventory: InventoryLite, catalog: ItemCatalog) -> Array[String]:
var unknown: Array[String] = []
var items: Variant = data.get("items", [])
if not (items is Array):
return ["invalid_items_list"]
for record in items:
if not (record is Dictionary):
continue
var item_id := String(record.get("id", ""))
var count := int(record.get("count", 0))
var item := catalog.get_item(item_id)
if item == null:
unknown.append(item_id)
continue
var leftover := inventory.add_item(item, count)
if leftover > 0:
push_warning("Inventory was full while loading %s" % item_id)
return unknownThe leftover return from add_item is the check that will actually save you. A save can contain more items than the current capacity after a balance change, and a careless loader will quietly drop the excess. Decide whether to put those items in a recovery container, refuse the load, or show a warning. For a player-facing release, I would preserve the original file and move the overflow into a visible recovery screen. Silently deleting it is the one option you should rule out.
Version the envelope before you need it
The version field costs one line and gives you a place to put future changes. If version 2 replaces count with a quantity dictionary, migrate the parsed data before calling data_to_inventory. Keep the migration outside the inventory node. The inventory should receive current item Resources and counts, not carry every historical spelling of your save format forever.
func migrate_inventory_data(data: Dictionary) -> Variant:
var version := int(data.get("version", 0))
if version == 0 or version > 1:
return null
return data.duplicate(true)Refusing an unversioned or newer file is safer than guessing. An old file can be backed up and repaired with a deliberate migration. A file from a newer build may contain information this build cannot understand. If you load it anyway and save it back, you can destroy data that the newer build would have recovered correctly.
The boundary between the free core and your game
Inventory Lite gives you the container rules: stacking by item id, capacity, add_item, remove_item, and a readable slots() snapshot. It does not decide your save format or your item catalog, because those are game-specific. That boundary is useful. You can keep the addon small and make the persistence policy match your own content pipeline instead of hiding it inside a black box.
The complete flow is now stable: author item Resources with permanent ids, save only ids and counts, initialize the catalog before loading, check the leftover from every add, and migrate the envelope by version before the inventory sees it. Your editor data can evolve. The player's file stays a small record of what they actually owned.