This guide covers how to persist placed objects and painted terrain across game sessions.
Version note: This guide is for Grid Placement 6.0.0. The plugin gives you object and terrain serialization helpers; it does not replace your game's save-file, profile, cloud-save, or migration system.
Core Concepts
Persistence relies on two plugin-provided object-placement pieces:
PlaceableInstance: A component attached to placed objects. It remembers theScenePlacementEntryreference and transform needed to recreate the object.PlaceableInstancegroup: EveryPlaceableInstancejoins this Godot group on creation. This is the normal way to discover placed objects in a scene.
The plugin does not provide a complete file I/O system, world-state manager, or player-state serializer. Those are game-specific concerns you implement yourself.
Discovering Placed Objects
The plugin tracks placed objects through the PlaceableInstance group.
var placed_nodes: Array[Node] = get_tree().get_nodes_in_group(PlaceableInstance.group_name)
Why groups instead of a central list?
PlaceableInstance._init()callsadd_to_group(group_name)automatically.PlaceableInstance.validate_setup()warns if a node is missing the group.- Godot handles group membership with the node's lifetime.
PlacementStatedoes not maintain a separate canonical placed-object array.
Saving Objects
Iterate the PlaceableInstance group and call save() on each component.
func collect_placed_object_data() -> Array[Dictionary]:
var save_data: Array[Dictionary] = []
for node in get_tree().get_nodes_in_group(PlaceableInstance.group_name):
var parent := node.get_parent()
if parent != null and parent.has_meta("gb_preview"):
continue
var entry: Dictionary = node.save(true)
save_data.append(entry)
return save_data
What PlaceableInstance.save() returns
The returned Dictionary uses these keys from PlaceableInstance.Names:
| Key | Value | Constant |
|---|---|---|
"instance_name" |
The placed object's node name | PlaceableInstance.Names.INSTANCE_NAME |
"transform" |
var_to_str(parent.transform) |
PlaceableInstance.Names.TRANSFORM |
"placeable" |
Load data for the ScenePlacementEntry resource |
PlaceableInstance.Names.PLACEABLE |
Do not change these built-in keys. If you need extra data, merge your own keys after calling save().
Loading Objects
Step 1: Clear existing placed objects
func clear_placed_objects() -> void:
get_tree().call_group(PlaceableInstance.group_name, "queue_free")
await get_tree().process_frame
Step 2: Rebuild from save data
func restore_placed_objects(data: Array[Dictionary], parent: Node) -> void:
for entry in data:
var instance := PlaceableInstance.instance_from_save(entry, parent)
if instance == null:
push_error("Failed to instance object from save entry: %s" % entry)
PlaceableInstance.instance_from_save() loads the saved ScenePlacementEntry, instances its packed_scene, adds it to the parent, restores name/transform, and attaches a fresh PlaceableInstance if the scene did not already include one.
Adding Custom Object Save Data
If your placed objects have game-specific state such as health, inventory, owner id, or tile anchors, merge that data after calling the plugin's save().
func save_with_custom_data(node: PlaceableInstance) -> Dictionary:
var data := node.save(true)
var parent := node.get_parent()
if parent != null and parent.has_method("get_custom_save_data"):
data["custom_data"] = parent.get_custom_save_data()
return data
On load, restore custom data after instance_from_save() returns.
func load_with_custom_data(entry: Dictionary, parent: Node) -> void:
var instance := PlaceableInstance.instance_from_save(entry, parent)
if instance == null:
return
if entry.has("custom_data") and instance.has_method("restore_custom_data"):
instance.restore_custom_data(entry["custom_data"])
Object Save/Load Edge Cases
- Missing resources: If a
placeableresource path no longer exists,ScenePlacementEntry.load_resource()returns null andinstance_from_save()emits an error. Always check for null returns. - Schema changes: The plugin does not provide a migration system. If you change your custom data structure, handle version checking in your own save wrapper.
- Previews and ghosts: Objects marked with
"gb_preview"metadata are transient and should be skipped during save. - Extra state:
PlaceableInstance.save()recreates the object scene and transform. It does not automatically persist your game-specific state.
Demo Reference
The plugin ships with worked save/load examples in the demo scenes. These classes are not part of the addon API; they show one possible way to wire plugin save/load into a complete game.
| Demo Class | Purpose | Location |
|---|---|---|
DemoSaveLoad |
JSON file I/O, folder creation, error handling | demos/shared/save/demo_save_load.gd |
WorldState |
Multi-level world persistence, level switching | demos/shared/world/world_state.gd |
Level |
Per-level save/load, PlaceableInstance group iteration |
demos/shared/world/level.gd |
LevelState |
Resource-based save data container |
demos/shared/save/level_state.gd |
PlayerState |
Player position, inventory, etc. | demos/shared/save/player_state.gd |
Use the demo as a starting point, but remember: PlaceableInstance.save() and PlaceableInstance.instance_from_save() are the object APIs you need. Everything else is your own architecture.
Terrain Save/Load
Painted terrain tiles are not saved automatically with your game state. Use TerrainPersistence to serialize and restore terrain cell data.
Saving terrain
var result := TerrainPersistence.save_terrain(tile_map, "user://terrain_save.res")
if result != OK:
push_error("Failed to save terrain: %d" % result)
Save all terrain layers tracked by the targeting state:
var result := TerrainPersistence.save_all_terrain(
placement_container.get_states().targeting,
"user://all_terrain.res"
)
Loading terrain
var result := TerrainPersistence.load_terrain(tile_map, "user://terrain_save.res")
if result == ERR_FILE_NOT_FOUND:
pass # No save file yet — fine for first launch.
elif result != OK:
push_error("Failed to load terrain: %d" % result)
Load all tracked terrain layers:
var result := TerrainPersistence.load_all_terrain(
placement_container.get_states().targeting,
"user://all_terrain.res"
)
Save data format
TerrainSaveData is a Resource containing an array of cell records:
{
"x": int,
"y": int,
"terrain_set": int,
"terrain": int
}
Terrain indices reference the TileSet that was active at paint time. Make sure the same TileSet is assigned to the TileMapLayer when loading.
Using the host's terrain service
If you have a GridPlacementHost, ask the host for its terrain service:
var terrain_service := host.get_terrain_service()
if terrain_service != null:
terrain_service.save_terrain("user://terrain.res")
terrain_service.load_terrain("user://terrain.res")
Terrain Edge Cases
- Empty layers: Saving a
TileMapLayerwith no terrain-painted cells produces a valid, emptyTerrainSaveDataresource. Loading it is a no-op. - Missing save file:
load_terrain()returnsERR_FILE_NOT_FOUND; handle this gracefully on first launch. - Corrupt file: If the file exists but is not a
TerrainSaveDataresource, an error is returned. - TileSet mismatch: Loading terrain indices onto a
TileMapLayerwith a differentTileSetmay produce no visible result or garbled terrain. - Direct-tile cells: Cells placed via direct tile IDs rather than terrain-connected painting may not be captured by terrain persistence helpers.
Related Guides
Source
docs/v6-0/guides/save-and-load.md
Plugin docs root:gdscript/plugins/grid_placement_dev/docs