Chunk Loading failing when game is exported

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!

That’s a lot of code, and Claude cannot fix poor variable naming choices. Next time, instead of using an LLM, I recommend you run your code through a formatter like the one in Godot GDScript Toolkit. It won’t fix short non-descriptive variable names, but it will deal with odd spacing issues and not put annoying comment breaks with no info before each function.

Your problem is most likely that you are using DirAccess. When you export, all those filenames are changed. I recommend taking a look at the solution here in this thread: Image and Audio Files not Loading on Export

That should be enough for you and Claude to figure it out.

EDIT: Also, you posted this in the General section. It should be in Help.

2 Likes

Thank you! I’ll be sure to use the toolkit next time and check out that thread. Should I repost it in Help? I meant to put it there but must’ve misclicked.

@wchc can probably move the thread. No need to double post.

2 Likes

Are you referring to this part?

DirAccess.make_dir_recursive_absolute(save_path.get_base_dir())

Because I’m not quite sure what I need to change to fix it. Looking at your solution, I can’t really see how my creation of the file/file path is significantly different.

That’s probably part of it. Why are you using an absolute path? How come you’re not using a relative path to the User directory?

1 Like

Everything I could find online said that that was the way to create a file if it wasn’t there already. Is there a simpler way?

Accessing the file system is never easy. But with Godot it is much harder if you are not using the User Directory when the game is running. Hence my question. And I am inferring the answer is no, because using an absolute path with the User directory would guarantee it breaks on every operating system except the one you hardcoded it for.

so what should i do instead?

Use the relative path.

1 Like

Since this is intended to store persistent data, I still need it saved on the user’s device. Will this mess with that?

No. The user directory is where you want to store it. Did you read the link I posted on it?

1 Like

I did, but you mentioned that the issue was all of the file names being changed on export, but I don’t see a noticeable difference between how you’re accessing the sound files and how I’m accessing the packed scenes. Another notable thing is that this is happening before the player unloads (and saves) these chunks, which means it should be defaulting to the original chunk scene. So I’m not sure if the error actually lies with this loading code.

This could be your problem if you’re on Windows. Windows cannot tell the difference between capital and lowercase. It’s recommended to use snake_case for all resources. That could be your problem. You’re saving them in a directory that the loader cannot see.

1 Like

i’ll try that!

1 Like

Sorry, I took so long to try this. I just implemented it, and it didn’t fix the issue. do you have any other ideas?

And you changed all folder names too?

Yeah, I renamed every instance of “SaveLoad” to not include capital letters. It didn’t fix it on my Mac, but my friend with a Windows hasn’t gotten a chance to test it yet, so I don’t know if it fixed it there.

1 Like

Ok, I have another idea. (The filenames were likely to be an issue either way.) Basically it’s that your chunk manager is trying to load files that don’t exist on export because they’ve been renamed with file extensions. They’re still there, but you cannot see them. Checkout this thread where I had a similar problem: Image and Audio Files not Loading on Export

See if that solution works for you.

1 Like

When exported what does your game print/error? It seems like you have a push_error call for most failure conditions, it would help greatly to track down your error.

I’d agree it’s probably a remapped file as @dragonforge-dev points out, load should find remapped files fine but anything FileAccess may not, so patterns like this are using two different file handling techniques. It’s probably better to load and check for failure, or if ResourceLoader.exists(save_path):.


But this is advice for res:// resources, it really sounds like you should be saving data to user:// and there is certainly mixed usage confusing me while reading

Mentioning user://saves in a comment (LLM generated so I must ask if this is actually your intention or just garbage?) and the function returns a "user://" path

This function load_single_chunk uses a res:// path

2 Likes