Incomprehensible behavior from custom level loading system

Godot Version

Godot v4.6.0

Question

Ok so in order to explain ANYTHING about this problem I need to explain the setup I have going on here. (i am writing this at 12am after taking both of my sleep meds for the night so excuse my writing here aaaaaaa)

I’m making a sorta basic walking-sim game where each level is a single room. In order to load between each room, i have created a warp point system. Each warp is an Area2D as the trigger for the player, and a Node3D parented which tells the player where to teleport to. (the player never gets deleted)

The Area2D has a script connected to it called WarpPoint. This keeps track of its own warp ID for future reference, the map it should load after the player enters it, and the next warp ID for the player to be teleported at. It also has a reference to the Node3D used for locating where the player gets teleported to on load.

At the higher level, there is a root node for all of this that i like to call the Director. This keeps track of the next map that needs to be loaded, as well as the next warp ID to teleport the player to. When the player touches a WarpPoint, the warp sets the Director’s next level and next warp ID, then triggers the load function to start.

Each room has several of these WarpPoints located within them, and all of these warp points should eventually get interlinked into a network of paths between all of the various levels. When you touch a warp point, it defers loading to the Director as explained before.

The core idea for the level loading system is that the Director’s load function deletes the current map, loads the next map, fetched all the warp points in the new map using the engine’s group system, then teleports the player to the next warp’s spawn point using the next warp ID as a reference.

Upon initial testing between 3 levels, all of this worked. It was all done in a single day. I programmed this without any clever tricks, just pure “f it we ball” energy.

I have learned this was a bad move.

Here is the chunk of code from the Director responsible for the actual level loading magic, as i feel this is what’s responsible for my current headache.

public void LoadMap()
{
    //delete current map
    if (themap != null) themap.QueueFree();

    //load map
    PackedScene theNewMapPackedScene = (PackedScene)ResourceLoader.Load(nextMap);
    themap = theNewMapPackedScene.Instantiate();
    this.AddChild(themap);

    //get list of all warps in new map
    Node[] nodeWarpList = GetTree().GetNodesInGroup("warps").ToArray();

    //hack job fix to filter out deleted warps (doesnt warp)
    int actualwarpcount = 0;
    for (int i = 0; i < nodeWarpList.Length; i++)
    {
        if (!nodeWarpList[i].IsQueuedForDeletion()) actualwarpcount++;
    }

    warpList = new WarpPoint[actualwarpcount];
    IDList = new int[actualwarpcount];

    GD.Print("There are " + warpList.Length + " new warp points.");

    for (int i = 0; i < nodeWarpList.Length; i++)
    {
        if (!nodeWarpList[i].IsQueuedForDeletion())
        {
            WarpPoint bufferWarp = nodeWarpList[i] as WarpPoint;
            warpList[i] = bufferWarp;
            IDList[i] = bufferWarp.ID;
        }
    }

    //set player at correct warp point
    if (theplayer == null)
    {
        theplayer = playerPrefab.Instantiate<CharacterBody3D>() as Player;
        this.AddChild(theplayer);
    }

    int theWarpToPickFromTheArray = IDList.IndexOf(nextEntranceID); //replace this with a dictionary or figure out how to actually sort the got dang warp array

    GD.Print("Next warp is ID " + warpList[theWarpToPickFromTheArray].ID);
    GD.Print(warpList[theWarpToPickFromTheArray].playerSpawn.GlobalPosition.X + ", " +
             warpList[theWarpToPickFromTheArray].playerSpawn.GlobalPosition.Y + ", " +
             warpList[theWarpToPickFromTheArray].playerSpawn.GlobalPosition.Z);

    theplayer.Position = warpList[theWarpToPickFromTheArray].playerSpawn.GlobalPosition;
    GD.Print(theplayer.Position.X + ", " +
             theplayer.Position.Y + ", " +
             theplayer.Position.Z);
    theplayer.SetFacingDirection(warpList[theWarpToPickFromTheArray].playerSpawn.GlobalRotationDegrees);
    theplayer.SetNewVelocity(Vector3.Zero);
}

Here is the problem I am currently having.

Upon adding a new 4th level, I discovered that this code doesn’t work in every circumstance. When loading maps from the 4th level to the 3rd level, it appears that the warp fetcher grabs all the warps from both maps, then sets the spawn based on the old map’s warps. Furthermore, that small chunk of code meant to account for warps that are queued for deletion just seems to be ignored as it does not solve the problem. This results in the player getting teleported out of bounds. Which is bad.

I also tried fixing this by calling Free() on all the current warps before unloading the current map. This appears to instead delete all of the warps for the new map. But the new warps still appear in the remote scene hierarchy. but the player can’t interact with these warps as they throw a “Can’t interact with objects queued for deletion” error.

I ALSO tried fixing this by just calling Free() on the old map instead of QueueFree() to try getting rid of everything immediately. This crashes the game entirely.

I do not know what is going on here. I have never seen behavior like this from godot before. My usual debugging tactics only seem to make the behavior worse and I am kind of scared to touch this anymore. I may be doing things horribly wrong. Any suggestions on what to do are highly appreciated.

(I am aware that fetching nodes based on tags fetches them in a somewhat random order, but I don’t know how to sort them. I am also aware I could be using a Dictionary in there somewhere, but I was too tired to properly restructure the code for that since I rarely use Dictionaries.)

I should preface this by saying I’m not an experienced programmer, so I may not give perfect advice.

Godot tends to work best if you let scenes handle their own deletion. You’re trying to use the LoadMap() function to both delete the map and load the next, which may explain why you’re having difficulties.

I advise you move your deletion code to the node you’re trying to delete; have the warp areas send a signal to their scene root, and have that delete itself. Also, QueueFree() can take a couple frames, which may be worsening your issues, so you may want to defer the call to the next available frame. in GDscript you do this with call_deferred("queue_free()"); I’m not as familiar with C#, but it should be similar.

This next one’s down to personal taste, but I’d personally do away with the warp point array/dictionary entirely, and just have the areas emit a signal containing their warp ID before emitting the room-deleting signal. That way you don’t need to juggle a list of them, you just load in the one warp point that you’re going to use as you need it. Unless you’re doing something fancy with the array/dictionary, I don’t see the point of moving the IDs from the areas to a list, then referencing the list, when you already have the info accessible in the area2Ds.

Well I slept on it and ended up figuring out what’s going on this morning.

Yes, deleting and loading both maps in the same function was the core of my problems. I chose to fix this at the top of the LoadMap() function by setting the node for the old map to a different variable, then calling QueueFree() on that old map variable.

This gives distinction between the old and new map within the code, allowing me to use a combo of FindChildren(“*”) and IsInGroup(“warps“) on the new map to specifically pick out the new warps. I also switched to using a dictionary for the warps, which gets rid of a bunch of stuff that failed to identify and sort all the new warps from just calling GetTree().GetNodesInGroup("warps").

The following code from Director now identifies the new warps correctly, and sets the player to the correct spawn point based on the new warp.

public void LoadMap()
{
    //set new map to old map and delete it
    //partially suggested by https://forum.godotengine.org/t/incomprehensible-behavior-from-custom-level-loading-system/137637/2
    if (currentmap != null)
    {
        oldmap = currentmap;
        oldmap.QueueFree();
    }

    //load new map
    PackedScene thenewmappackedScene = (PackedScene)ResourceLoader.Load(nextMap);
    currentmap = thenewmappackedScene.Instantiate();
    this.AddChild(currentmap);

    //get list of all warps in new map
    //cannot explicitly use GetTree().GetNodesInGroup("warps").ToArray() because it will also fetch nodes queued for deletion, causing a lot of headache
    //solution suggested by comments in docs here at https://github.com/godotengine/godot-docs-user-notes/discussions/85#discussioncomment-10455935
    warpDict = new Dictionary<int, WarpPoint>();

    foreach (Node n in currentmap.FindChildren("*"))
    {
        if (n.IsInGroup("warps"))
        {
            WarpPoint w = n as WarpPoint;
            warpDict.Add(w.ID, w);
        }
    }
    GD.Print("There are " + warpDict.Count + " new warp points.");

    //set player at correct warp point
    if (theplayer == null)
    {
        theplayer = playerPrefab.Instantiate<CharacterBody3D>() as Player;
        this.AddChild(theplayer);
    }
    GD.Print("Next warp is ID " + warpDict[nextEntranceID].ID);
    GD.Print(warpDict[nextEntranceID].playerSpawn.GlobalPosition.X + ", " +
             warpDict[nextEntranceID].playerSpawn.GlobalPosition.Y + ", " +
             warpDict[nextEntranceID].playerSpawn.GlobalPosition.Z);

    theplayer.GlobalPosition = warpDict[nextEntranceID].playerSpawn.GlobalPosition;
    GD.Print(theplayer.Position.X + ", " +
             theplayer.Position.Y + ", " +
             theplayer.Position.Z);
    theplayer.SetFacingDirection(warpDict[nextEntranceID].playerSpawn.GlobalRotationDegrees);
    theplayer.SetNewVelocity(Vector3.Zero);
}

At the end of this, i figure out that the warp was telling the Director to set the nextEntranceID to the warp’s own ID, not the actual next entrance. Which is probably what started this debugging session.