I’m having this really weird issue where all my game code works as intended when run from the editor, but when I export the game, it stops working, and chunks go missing. I can’t seem to find any resources online that can help me diagnose the issue, and I can’t do much myself since it only appears when the game is exported. I’ve included some examples below as well as my relevant code.
What it looks like within the editor:
What it looks like when exported:
I went through the scripts with Claude to add comments and make them more readable.
Chunk Manager script:
# ===========================================================================
# SAVE-SLOT HELPERS
# Derives the active slot number from SaveLoad.save_location, which looks like:
# user://saves/1.0_SaveLoad2.json → slot = 2
# Chunk saves live at:
# user://saves/{version}_SaveLoad{slot}/Chunks/
# ===========================================================================
func _slot_from_save_location() -> int:
var path: String = SaveLoad.save_location
# Grab the filename without extension, e.g. "1.0_SaveLoad2"
var file_base := path.get_file().get_basename()
# Everything after "SaveLoad"
var after := file_base.get_slice("SaveLoad", 1)
var slot := after.to_int()
return slot if slot > 0 else 1
func _chunk_save_folder() -> String:
var slot := _slot_from_save_location()
return "user://saves/" + GlobalData.version \
+ "_SaveLoad" + str(slot) + "/Chunks/"
func _chunk_save_path(coords: Vector2i) -> String:
return _chunk_save_folder() + "chunk_%d_%d.tscn" % [coords.x, coords.y]
# ---------------------------------------------------------------------------
func world_to_chunk(world_pos: Vector2) -> Vector2i:
var moved_x = ceil(world_pos.x - _chunk_previous_x)
if abs(moved_x) >= increment_x:
var steps_x := floori(moved_x / increment_x)
_chunk_x += steps_x
_chunk_previous_x += steps_x * increment_x
var moved_y = ceil(world_pos.y - _chunk_previous_y)
if abs(moved_y) >= increment_y:
var steps_y := floori(moved_y / increment_y)
_chunk_y += steps_y
_chunk_previous_y += steps_y * increment_y
return Vector2i(_chunk_x, _chunk_y)
# ---------------------------------------------------------------------------
func load_single_chunk(coord: String) -> void:
var coords := string_to_vec2i(coord)
var save_path := _chunk_save_path(coords) # computed NOW, from current slot
var scene_path := "res://scenes/chunks/" \
+ str(coords.y) \
+ "/chunk(" + str(coords.x) + "," + str(coords.y) + ").tscn"
var fallback := get_chunk_scene(scene_path)
if fallback == null:
fallback = load("res://scenes/chunks/chunk(DEFAULT).tscn")
var chunk_instance := NodeSerializer.load_or_instantiate(
save_path,
fallback,
func(n): if n.has_method("mark_loaded_from_save"): n.mark_loaded_from_save()
)
if chunk_instance == null:
return
$Chunks.add_child(chunk_instance)
chunk_instance.name = "Chunk" + str(Vector2i(coords.x, coords.y))
chunk_instance.global_position = Vector2(coords.x * increment_x, coords.y * increment_y)
# ← Store the path so saving never re-derives it from the (possibly changed) slot
chunk_instance.set_meta("chunk_save_path", save_path)
NodeSerializer.finalize_load(chunk_instance)
if $Chunks.get_child_count() == 9:
$"../NavigationRegion2D".bake_navigation_polygon()
emit_signal("finished_loading")
# ---------------------------------------------------------------------------
func load_chunks(chunk_coords: Array) -> void:
for coord in chunk_coords:
if coord in chunk_load_queue:
continue
chunk_load_queue.append(coord)
if chunk_load_queue.size() > 0:
loading_chunks = true
# ---------------------------------------------------------------------------
func get_chunk_scene(path: String) -> PackedScene:
if not chunk_scene_cache.has(path):
if ResourceLoader.exists(path):
chunk_scene_cache[path] = load(path)
else:
chunk_scene_cache[path] = null
return chunk_scene_cache[path]
# ---------------------------------------------------------------------------
func save_node_to_disk(node: Node) -> void:
if "DEFAULT" in node.name:
return
# Use the path baked in at load time; fall back to computing it only for
# chunks that were never loaded from disk (i.e. fresh this session).
var save_path: String
if node.has_meta("chunk_save_path"):
save_path = node.get_meta("chunk_save_path")
else:
DirAccess.make_dir_recursive_absolute(_chunk_save_folder())
var coords := _coords_from_chunk_name(node.name)
save_path = _chunk_save_path(coords)
node.set_meta("chunk_save_path", save_path) # cache it for any future saves
DirAccess.make_dir_recursive_absolute(save_path.get_base_dir())
NodeSerializer.serialize_to_tscn(node, save_path)
# ---------------------------------------------------------------------------
func _coords_from_chunk_name(chunk_name: String) -> Vector2i:
if "(" in chunk_name and "," in chunk_name:
var inside := chunk_name.get_slice("(", 1).get_slice(")", 0)
var parts := inside.split(",")
return Vector2i(int(parts[0].strip_escapes()), int(parts[1].strip_escapes()))
return Vector2i.ZERO
# ---------------------------------------------------------------------------
func string_to_vec2i(input_string: String) -> Vector2i:
var cleaned := input_string.replace("(", "").replace(")", "")
var components := cleaned.split(",")
if components.size() == 2:
return Vector2i(
int(components[0].strip_escapes()),
int(components[1].strip_escapes())
)
push_warning("Invalid string format for Vector2i: " + input_string)
return Vector2i.ZERO
Chunk Script:
# ===========================================================================
# SERIALIZE / DESERIALIZE — logic lives in NodeSerializer
# ===========================================================================
func serialize_to_tscn(save_path: String) -> void:
NodeSerializer.serialize_to_tscn(self, save_path)
func mark_loaded_from_save() -> void:
_loaded_from_save = true
func restore_animation_players() -> void:
NodeSerializer.restore_animation_players(self)
Node Serializer:
class_name NodeSerializer
const ANIM_META_KEY := "_saved_anim_state"
# ---------------------------------------------------------------------------
# SERIALIZE
# Snapshots AnimationPlayer states, detaches runtime nodes, packs, saves,
# then re-attaches runtime nodes so the live node keeps working.
# Works on any Node subtree.
# ---------------------------------------------------------------------------
static func serialize_to_tscn(node: Node, save_path: String) -> void:
DirAccess.make_dir_recursive_absolute(save_path.get_base_dir())
_snapshot_animation_players(node)
var runtime_nodes: Array[Node] = []
_collect_runtime_nodes(node, runtime_nodes)
var exempt_nodes: Array[Node] = []
_collect_exempt_nodes(node, exempt_nodes)
var detached := runtime_nodes + exempt_nodes
for n in detached:
n.get_parent().remove_child(n)
_set_owner_recursive(node, node)
var packed := PackedScene.new()
var pack_result := packed.pack(node)
if pack_result == OK:
var save_result := ResourceSaver.save(packed, save_path)
if save_result != OK:
push_error("NodeSerializer: ResourceSaver.save failed (err %d) path=%s"
% [save_result, save_path])
else:
push_error("NodeSerializer: PackedScene.pack failed (err %d) path=%s"
% [pack_result, save_path])
for n in detached:
node.add_child(n)
# ---------------------------------------------------------------------------
# DESERIALIZE — call AFTER add_child() so AnimationPlayers are in the tree
# ---------------------------------------------------------------------------
static func restore_animation_players(node: Node) -> void:
_restore_animation_players(node)
# ---------------------------------------------------------------------------
# PRIVATE HELPERS
# ---------------------------------------------------------------------------
static func _snapshot_animation_players(node: Node) -> void:
for child in node.get_children():
if child is AnimationPlayer:
var ap := child as AnimationPlayer
ap.set_meta(ANIM_META_KEY, {
"animation": ap.current_animation,
"position": ap.current_animation_position,
"is_playing": ap.is_playing()
})
_snapshot_animation_players(child)
static func _restore_animation_players(node: Node) -> void:
for child in node.get_children():
if child is AnimationPlayer:
var ap := child as AnimationPlayer
if ap.has_meta(ANIM_META_KEY):
var state: Dictionary = ap.get_meta(ANIM_META_KEY)
var anim_name: String = state.get("animation", "")
var anim_pos: float = state.get("position", 0.0)
var was_playing: bool = state.get("is_playing", false)
if anim_name != "" and ap.has_animation(anim_name):
ap.play(anim_name)
ap.seek(anim_pos, true)
if not was_playing:
ap.pause()
ap.remove_meta(ANIM_META_KEY)
_restore_animation_players(child)
static func _set_owner_recursive(node: Node, owner_node: Node) -> void:
for child in node.get_children():
child.owner = owner_node
_set_owner_recursive(child, owner_node)
static func _collect_runtime_nodes(node: Node, out: Array[Node]) -> void:
for child in node.get_children():
if child.has_meta("runtime_spawned"):
out.append(child)
else:
_collect_runtime_nodes(child, out)
# Collects nodes in the SaveExempt group. Stops descending into a branch once
# a SaveExempt node is found, since its children will be detached with it.
static func _collect_exempt_nodes(node: Node, out: Array[Node]) -> void:
for child in node.get_children():
if child.is_in_group("SaveExempt"):
out.append(child)
else:
_collect_exempt_nodes(child, out)
# ---------------------------------------------------------------------------
# Load a node from a saved .tscn if it exists, otherwise instantiate from
# fallback_scene. Returns null on failure.
# Call mark_fn (if provided) BEFORE add_child, restore AFTER.
# ---------------------------------------------------------------------------
static func load_or_instantiate(
save_path: String,
fallback_scene: PackedScene,
before_add_fn: Callable = Callable(), # called on instance before add_child
) -> Node:
var instance: Node = null
if FileAccess.file_exists(save_path):
var packed := ResourceLoader.load(
save_path, "", ResourceLoader.CACHE_MODE_IGNORE
) as PackedScene
if packed == null:
push_error("NodeSerializer: failed to load packed scene: " + save_path)
return null
instance = packed.instantiate()
if before_add_fn.is_valid():
before_add_fn.call(instance)
else:
if fallback_scene == null:
push_error("NodeSerializer: no save found and no fallback scene provided.")
return null
instance = fallback_scene.instantiate()
return instance
# ---------------------------------------------------------------------------
# Call this AFTER add_child() to restore AnimationPlayer states.
# Wraps restore_animation_players so callers don't need to import NodeSerializer.
# ---------------------------------------------------------------------------
static func finalize_load(instance: Node) -> void:
restore_animation_players(instance)
Thank you all for your help, and I am happy to provide any other scripts/ node trees that you need!



