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 nodein 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_dataWhat PlaceableInstance.save() returns
The returned Dictionary starts with restored game-owned fields, then writes
these plugin-owned 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 |
"placement_instance_id" |
Stable UUID-style identifier, when assigned | PlaceableInstance.Names.PLACEMENT_INSTANCE_ID |
Do not change these built-in keys. If you need extra data, merge your own keys after calling save().
The mount record used by the 3D demo is persistence-layer metadata added
after this call; it is not emitted by PlaceableInstance.save() itself.
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_frameStep 2: Rebuild from save data
func restore_placed_objects(data:Array[Dictionary], parent:Node)-> void:
for entryin 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 dataOn 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: callers must merge live game-specific state after calling
save(). Unknown game-owned keys loaded byinstance_from_save()are retained on the restored component and included in its nextsave()call. - Mount metadata: a demo or game persistence layer may add a
mountrecord aftersave()and restore it throughsaved_mount_data; the base component treats that record as opaque.
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.
PlaceableInstance save format
Each PlaceableInstance serializes itself into a dictionary via save(). The dictionary contains:
| Key | Type | Description | 5.x compat? |
|---|---|---|---|
instance_name |
String |
The parent node's name | Yes |
transform |
String |
var_to_str() of the parent's transform |
Yes |
placeable |
Dictionary |
Resource path + optional UID | Yes |
mount |
Dictionary |
Demo-owned cell, edge, face, or mount metadata | Caller-defined |
placement_instance_id |
StringName |
Stable UUID-style identifier (36 chars, 8-4-4-4-12 hex) | No (6.0+) |
5.x to 6.0 migration
Saves from Grid Placement 5.x do not contain placement_instance_id. When a 5.x save is loaded through PlaceableInstance.instance_from_save(), a new UUID is generated automatically during restoration. All other component fields (transform, placeable reference, and custom game-owned fields) are preserved unchanged. Demo-owned mount metadata is preserved opaquely when the save layer supplies it.
The generated ID is written on the next successful save(). The original save file is never mutated by the load operation alone.
Supported 5.x versions: 5.0.x through 5.0.8 6.0 minimum: 6.0.0
Duplicate placement_instance_id values within one load operation are rejected. If two restored objects claim the same identity, the second restoration fails with an actionable error and returns null.
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.