Development⚠️ GridPlacement 6.0 documentation is in active development. APIs and content may change, and the site may be temporarily unstable.
Placement Persistence
Overview
Placement Persistence allows you to save and reload all objects placed by the building system, recreating the exact state of your game world even if those objects don’t exist in the base starting scene. This enables complete save/load systems, level editors, and persistent world states.
Purpose: When players build structures in your game, those placements need to persist across game sessions. The placement persistence system tracks which objects were placed, stores their configuration, and recreates them identically when loading a saved game.
What is Placement Persistence?
When players place objects using the building system, those objects need to be:
Tracked - Identified as “placed by player” vs “part of original scene”
Saved - Serialized with enough data to recreate them later
Loaded - Recreated from save data when loading a level
The metadata-based approach stores placement information directly on each placed node using Godot’s metadata system.
How It Works
Automatic Marking
When you place an object through BuildingSystem, it’s automatically marked with metadata:
1
2
3
# This happens automatically when you place an objectvar placed_node = building_system.place_buildable(placeable)
# placed_node now has metadata: { "gb_placement": { "placeable_path": "res://..." } }
The Metadata Key
Every placed object gets a metadata entry:
1
2
3
4
5
6
7
# Metadata key"gb_placement"# Metadata value (Dictionary){
"placeable_path": "res://placeables/tower.tres"}
This tells the system:
✅ This object was placed by the building system
✅ It can be recreated from this placeable resource
✅ It should be included in save/load operations
Viewing Placement Metadata
In Godot Editor
You can view placement metadata using Godot’s inspector:
Install a metadata viewer plugin (search Asset Library for “metadata inspector”)
Select a placed object in the scene tree
View the Inspector - look for “Metadata” section
Find gb_placement key with the placeable path
In Code
1
2
3
4
# Check if object is marked as placedif node.has_meta("gb_placement"):
var placement_data = node.get_meta("gb_placement")
print("Placed from: ", placement_data["placeable_path"])
Using the GBPlacementPersistence API
The GBPlacementPersistence class provides all placement persistence functionality through static methods.
Checking Placement Status
1
2
3
4
5
6
7
8
9
# Check if a node was placed by the building systemif GBPlacementPersistence.is_placed(node):
print("This was placed by the player")
else:
print("This is part of the original scene")
# Check if a node is a preview (not a real placement)if GBPlacementPersistence.is_preview(node):
print("This is just a preview, don't save it")
Manual Marking (Advanced)
You can manually mark objects as placed:
1
2
3
4
5
6
7
8
9
# Mark an object as placedvar placeable = load("res://placeables/tower.tres") as Placeable
GBPlacementPersistence.mark_as_placed(my_node, placeable)
# Mark as preview (temporary, don't save)GBPlacementPersistence.mark_as_preview(preview_node)
# Unmark a nodeGBPlacementPersistence.unmark(node)
Getting Placeable from Node
1
2
3
4
# Get the original placeable resourcevar placeable: Placeable = GBPlacementPersistence.get_placeable(placed_node)
if placeable:
print("This was placed from: ", placeable.display_name)
Saving Placed Objects
Save Single Object
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
funcsave_object(node: Node) ->Dictionary:
ifnot GBPlacementPersistence.is_placed(node):
return {} # Not a placed object, skip# Get save data for this objectvar save_data: Dictionary= GBPlacementPersistence.save_placement_data(node)
# save_data contains:# {# "instance_name": "Tower_001",# "transform": "Transform2D(...)",# "placeable": "res://placeables/tower.tres"# }return save_data
Save All Placed Objects
1
2
3
4
5
6
7
8
9
10
11
12
13
funcsave_level() ->Dictionary:
# Get all placed objects under a root nodevar placed_objects: Array[Dictionary] = GBPlacementPersistence.save_all_placements(level_root)
# placed_objects is an array of save data dictionaries# Preview objects are automatically excludedvar level_save = {
"version": "1.0",
"placed_objects": placed_objects
}
return level_save
class_nameLevelextendsNode2D@exportvar objects_parent: Node2D# Where placed objects livefuncsave_to_file(filepath: String) ->void:
# Get all placement datavar placements: Array[Dictionary] = GBPlacementPersistence.save_all_placements(objects_parent)
# Create save data structurevar save_data = {
"version": "1.0",
"level_name": name,
"placements": placements
}
# Write to filevar file =FileAccess.open(filepath, FileAccess.WRITE)
if file:
file.store_var(save_data)
file.close()
print("Level saved with %d placed objects"% placements.size())
Loading Placed Objects
Load Single Object
1
2
3
4
5
6
7
8
9
10
funcload_object(save_data: Dictionary, parent: Node) ->Node:
# Instantiate from save datavar instance: Node= GBPlacementPersistence.instance_from_save(save_data, parent)
if instance:
print("Loaded: ", instance.name)
return instance
else:
push_error("Failed to load object from save data")
returnnull
Load All Placed Objects
1
2
3
4
5
funcload_level(save_data_array: Array[Dictionary], parent: Node) ->void:
# Load all placements at once GBPlacementPersistence.load_all_placements(save_data_array, parent)
print("Loaded %d placed objects"% save_data_array.size())
class_nameLevelextendsNode2D@exportvar objects_parent: Node2D# Where to spawn loaded objectsfuncload_from_file(filepath: String) ->void:
# Read save filevar file =FileAccess.open(filepath, FileAccess.READ)
ifnot file:
push_error("Could not open save file: "+ filepath)
returnvar save_data = file.get_var()
file.close()
# Clear existing placed objects_clear_placed_objects()
# Load placementsvar placements: Array[Dictionary] = save_data.get("placements", [])
GBPlacementPersistence.load_all_placements(placements, objects_parent)
print("Level loaded: %d objects restored"% placements.size())
func_clear_placed_objects() ->void:
# Remove all placed objects (but not original scene objects)var placed = GBPlacementPersistence.get_placed_objects(objects_parent)
for node in placed:
node.queue_free()
Advanced Usage
Filtering Placed Objects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Get all placed objects under a rootvar all_placed: Array[Node] = GBPlacementPersistence.get_placed_objects(root_node)
# Exclude preview objects (happens automatically in save_all_placements)var real_placed: Array[Node] = []
for node in all_placed:
ifnot GBPlacementPersistence.is_preview(node):
real_placed.append(node)
# Filter by placeable typevar towers: Array[Node] = []
for node in all_placed:
var placeable = GBPlacementPersistence.get_placeable(node)
if placeable and placeable.display_name =="Tower":
towers.append(node)
funcsafe_save_placements(root: Node) ->Array[Dictionary]:
var valid_saves: Array[Dictionary] = []
var placed = GBPlacementPersistence.get_placed_objects(root)
for node in placed:
if GBPlacementPersistence.is_preview(node):
continue# Skip previewsvar save_data = GBPlacementPersistence.save_placement_data(node)
# Validate save dataif save_data.is_empty():
push_warning("Skipping node with empty save data: "+ node.name)
continueifnot save_data.has("placeable"):
push_warning("Skipping node without placeable reference: "+ node.name)
continue valid_saves.append(save_data)
return valid_saves
funcsafe_load_placements(save_data_array: Array[Dictionary], parent: Node) ->int:
var loaded_count =0for save_data in save_data_array:
var instance = GBPlacementPersistence.instance_from_save(save_data, parent)
if instance:
loaded_count +=1else:
push_warning("Failed to load placement: "+ str(save_data.get("instance_name", "unknown")))
return loaded_count
Save Data Format
The save data format is intentionally simple for easy serialization:
1
2
3
4
5
6
7
8
9
10
{
# The instance name in the scene tree"instance_name": "Tower_001",
# Transform as string (for easy serialization)"transform": "Transform2D(1, 0, 0, 1, 100, 200)",
# Resource path to the placeable (simple string, not dictionary)"placeable": "res://placeables/tower.tres"}
Why Simple Strings?
✅ Easy to serialize - Works with JSON, ConfigFile, or any text format
✅ Human readable - Can inspect and edit save files manually
✅ Version safe - Simple format is less likely to break between versions
✅ Lightweight - Minimal data overhead
Best Practices
1. Always Exclude Previews
1
2
3
4
5
6
7
8
# ✅ Correct - Automatically excludes previewsvar placements = GBPlacementPersistence.save_all_placements(root)
# ✅ Also correct - Manual filteringvar placed = GBPlacementPersistence.get_placed_objects(root)
for node in placed:
ifnot GBPlacementPersistence.is_preview(node):
var save_data = GBPlacementPersistence.save_placement_data(node)
2. Clear Before Loading
1
2
3
4
5
6
7
8
9
10
11
12
# ✅ Clear existing placements before loading new onesfuncload_level(save_data: Array[Dictionary]) ->void:
# Remove old placed objectsvar old_placed = GBPlacementPersistence.get_placed_objects(objects_parent)
for node in old_placed:
node.queue_free()
# Wait for nodes to be removedawaitget_tree().process_frame
# Load new placements GBPlacementPersistence.load_all_placements(save_data, objects_parent)
3. Validate Placeable Resources
1
2
3
4
5
6
7
8
9
10
11
12
13
# ✅ Ensure placeable resources exist before savingfuncvalidate_placements(root: Node) ->Array[String]:
var issues: Array[String] = []
var placed = GBPlacementPersistence.get_placed_objects(root)
for node in placed:
var placeable = GBPlacementPersistence.get_placeable(node)
ifnot placeable:
issues.append("Node '%s' has invalid placeable reference"% node.name)
elifnotResourceLoader.exists(placeable.resource_path):
issues.append("Placeable resource missing: "+ placeable.resource_path)
return issues
4. Version Your Save Format
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const SAVE_VERSION ="1.0"funcsave_level() ->Dictionary:
return {
"version": SAVE_VERSION,
"placements": GBPlacementPersistence.save_all_placements(level_root)
}
funcload_level(save_data: Dictionary) ->void:
var version = save_data.get("version", "unknown")
if version != SAVE_VERSION:
push_warning("Save format version mismatch: "+ version)
# Handle migration if neededvar placements = save_data.get("placements", [])
GBPlacementPersistence.load_all_placements(placements, level_root)
Common Patterns
Auto-Save on Placement
1
2
3
4
5
6
func_ready() ->void:
building_system.placed.connect(_on_object_placed)
func_on_object_placed(placed_node: Node) ->void:
# Auto-save when player places somethingsave_level()
var placement_history: Array[Dictionary] = []
var history_index: int=-1funcon_place_object(node: Node) ->void:
var save_state = GBPlacementPersistence.save_all_placements(level_root)
# Add to history history_index +=1 placement_history.resize(history_index +1)
placement_history[history_index] = {"placements": save_state}
funcundo() ->void:
if history_index >0:
history_index -=1_restore_state(placement_history[history_index])
funcredo() ->void:
if history_index < placement_history.size() -1:
history_index +=1_restore_state(placement_history[history_index])
func_restore_state(state: Dictionary) ->void:
var placed = GBPlacementPersistence.get_placed_objects(level_root)
for node in placed:
node.queue_free()
awaitget_tree().process_frame
GBPlacementPersistence.load_all_placements(state["placements"], level_root)
Network Sync
1
2
3
4
5
6
7
8
9
# Server: Send placement updates to clientsfuncon_player_place_object(placed_node: Node) ->void:
var save_data = GBPlacementPersistence.save_placement_data(placed_node)
rpc("sync_placement", save_data)
@rpc("authority", "call_remote", "reliable")
funcsync_placement(save_data: Dictionary) ->void:
# Client: Recreate the placed object GBPlacementPersistence.instance_from_save(save_data, objects_parent)
Troubleshooting
“Save data is empty”
Check if the node is actually marked as placed:
1
2
ifnot GBPlacementPersistence.is_placed(node):
push_error("Node is not marked as placed")
“Failed to load placement”
Verify the placeable resource path exists:
1
2
3
var placeable_path = save_data["placeable"]
ifnotResourceLoader.exists(placeable_path):
push_error("Placeable resource not found: "+ placeable_path)
“Objects not saving”
Ensure you’re calling save on the correct root node:
1
2
3
4
5
# ✅ Correct - Pass the parent that contains placed objectsvar placements = GBPlacementPersistence.save_all_placements(objects_parent)
# ❌ Wrong - Passing the wrong node won't find placed objectsvar placements = GBPlacementPersistence.save_all_placements(self)
“Preview objects being saved”
The API automatically excludes previews, but verify:
1
2
3
4
var placed = GBPlacementPersistence.get_placed_objects(root)
for node in placed:
if GBPlacementPersistence.is_preview(node):
push_error("Preview leaked into placed objects list")
Limitations and Advanced Considerations
Resource Path Dependencies
The placement persistence system uses resource paths (res://placeables/tower.tres) to reference Placeable resources. This is standard Godot practice, but has implications:
What happens if a Placeable is moved or renamed:
❌ Save files with old paths will fail to load
❌ ResourceLoader.exists() returns false
❌ Placed objects cannot be recreated
What happens if a Placeable is deleted:
❌ Save files become invalid
❌ Load operations fail silently or with errors
❌ No automatic recovery mechanism
Industry-Standard Recovery Strategies
1. Resource Mapping/Migration System
1
2
3
4
5
6
7
8
9
10
11
# Map old paths to new pathsconst RESOURCE_MIGRATIONS = {
"res://old/tower.tres": "res://new/tower.tres",
"res://deleted/wall.tres": "res://replacement/wall.tres"}
funcload_with_migration(save_data: Dictionary) ->Node:
var path = save_data["placeable"]
if path in RESOURCE_MIGRATIONS:
save_data["placeable"] = RESOURCE_MIGRATIONS[path]
return GBPlacementPersistence.instance_from_save(save_data, parent)
2. Custom UUID System (Beyond Plugin Scope)
For production games requiring robust asset tracking:
Assign custom UUIDs to each Placeable (separate from Godot’s editor UIDs)
Maintain a UUID → resource path registry
Store UUIDs in save files instead of paths
Rebuild registry on startup to handle moved files
1
2
3
4
5
6
7
8
9
10
11
# Example pattern (implement separately)class_namePlaceableRegistryextendsNodevar _uuid_to_path: Dictionary= {}
funcregister_placeable(uuid: String, path: String) ->void:
_uuid_to_path[uuid] = path
funcload_by_uuid(uuid: String) -> Placeable:
var path = _uuid_to_path.get(uuid)
return load(path) if path elsenull
3. Validation on Save
Check resource validity before saving:
1
2
3
4
5
6
7
8
9
10
11
12
funcvalidate_before_save(root: Node) ->Array[String]:
var warnings: Array[String] = []
var placed = GBPlacementPersistence.get_placed_objects(root)
for node in placed:
var placeable = GBPlacementPersistence.get_placeable(node)
ifnot placeable:
warnings.append("Invalid placeable reference: "+ node.name)
elifnotResourceLoader.exists(placeable.resource_path):
warnings.append("Missing resource: "+ placeable.resource_path)
return warnings