How to Properly Add, Remove, and Re-Add Scenes

Hi everyone,

I’m working on a PlayCanvas application where the main scene (Dashboard) manages the game state and UI (HTML/CSS-based). From there, the user can load other scenes (like a “Virtual World” or additional mini-games).

I load these extra scenes additively, and when the user exits them, I attempt to remove them completely. The goal is to fully unload a scene, so if the user returns to it later, it behaves exactly like it did the first time — fully fresh, without any lingering data or behavior.

Here’s the problem:
After unloading and reloading a scene (e.g., the Virtual World), I start getting errors, likely due to script instances or references persisting even after I believe the scene has been removed. It seems like not everything is getting deleted — perhaps textures, script components, or other memory objects are still hanging around.

In other engines, removing a scene usually clears everything (scripts, assets, state). But in PlayCanvas, that doesn’t seem to happen automatically.

Key points:

  • I load scenes additively and remove them when not needed.
  • I don’t want to switch scenes entirely, as the main Dashboard scene needs to stay active.
  • On first load, everything works perfectly. On subsequent loads (after unloading), I encounter issues.
  • I suspect it’s due to script instance references like MyScript.instance = this; not getting properly destroyed.

Questions:

  1. How can I ensure that an added scene is completely removed (all entities, script instances, assets)?
  2. What is the best practice in PlayCanvas to re-add a previously removed scene cleanly, as if it were loaded the first time?

I also checked the documentation and integrated the logic based on it, so adding and deleting a scene isnt a problem and seems to work - its like i havent thought about something when trying to add a scene a second time (for example if the user reenters the virtual world or a minigame)

Maybe I’ve missed something important or need to handle cleanup differently. Any best practices or common pitfalls when working with dynamic scene loading/unloading in PlayCanvas would be greatly appreciated.

Thanks in advance!

References:

Scene adding:

SceneManager.prototype._loadSceneHierarchy = function (_scene) {
    // define root entity
    if(_scene == 'virtual-world')
        this.sceneRootEntity = this.rootVW; 
    else{
        this.sceneRootEntity = this.rootSubScene;
    }
    
    // loading 
    if (!this._loadingScene) {
        this._loadingScene = true;
        
        // remove the current scene that is loaded if scene root
        if (this.sceneRootEntity.children.length > 0) {
            this.sceneRootEntity.children[0].destroy();
        }
    
        var self = this;
        var scene = this.app.scenes.find(_scene);
        
        // load scene additional
        this.app.scenes.loadSceneHierarchy(scene, function (err, loadedSceneRootEntity) {
            if (err) {
                console.error(err);
            } else {
                loadedSceneRootEntity.reparent(self.sceneRootEntity);    
                self._loadingScene = false;
            }
        });
 
        // exclude scenes from scene-settings loading
        if(_scene == 'game-quiz')
        return; 

        // load the scene settings
        this.app.scenes.loadSceneSettings(scene, function (err) {
            if (err) {
                console.error(err);
            } else {
                console.log('scene settings loaded');
            }
        });
    }
};

Scene deleting:

SceneManager.prototype.destroyActiveScene = function () {
    if (!this.rootSubScene) {
        console.warn("No rootSubScene found.");
        return;
    }
    const children = this.rootSubScene.children.slice();

    for (let i = 0; i < children.length; i++) {
        children[i].destroy();
        console.log("deleted scene " + children[i].name)
    } 
};

Hi @Question2u ,

The most common pitfall I’ve seen with unloading scenes and then reloading them is failing to unsubscribe from app level events (i.e. this.app.on('event', () => {})).

Specifically because the application is looking for the objects that were unloaded or destroyed, when that app fires again, it will cause an error, so you’ll want to manually unsubscribe when the script/entity is destroyed (i.e. this.app.off('event', () => {})).

Are there any specific errors you’re running into? Could you provide a link to the project for others to see?

I hope this is helpful.

2 Likes

Hi eproasim,

Thanks for your reply!

Unfortunately, I can’t share the project since it’s a client project — but I’d like to summarize how the scene is structured in case that helps identify the issue.

Our main scene contains a stateManager script that handles game states and the main UI, which is built using HTML/CSS. It also includes various logic scripts and interfaces. Because of this, switching to another scene would break those references, so we decided to load additional scenes additively instead.

Below is a shortened version without unnecessary game logic of the bugGameLogic script used for the mini-game:

var BugGameLogic = pc.createScript('bugGameLogic');

// Attributes
BugGameLogic.attributes.add('camera', { type: 'entity' });
BugGameLogic.attributes.add('soundComponent', { type: 'entity' });
BugGameLogic.attributes.add('obstacleArray', { type: 'entity', array: true });
BugGameLogic.attributes.add('blend', { type: 'entity' });
BugGameLogic.attributes.add('blendText', { type: 'entity' });
BugGameLogic.attributes.add('jsonCubeMapAsset', { type: 'asset', assetType: 'json' });

// Singleton reference
BugGameLogic.prototype.initialize = function () {
    BugGameLogic.instance = this;

    this.container = document.querySelector('.find-the-bug-container');
    this.closeBtnContainer = this.container?.querySelector('.close-btn');

    this.app.on('closeFindTheBugGame', this.closeGame, this);
    this.app.fire('setActiveContainer', 'game-find-the-bug');

    this.loadCubeMapData();
    this.externalCubemaps = {};

    this.on('destroy', this.onDestroy, this);
};

BugGameLogic.prototype.loadCubeMapData = function () {
    if (this.jsonCubeMapAsset) {
        this.cubeMapData = this.jsonCubeMapAsset.resource.cubemaps;
    }
};

BugGameLogic.prototype.changeCubemap = function (index, callback) {
    const urls = this.cubeMapData[index]?.faces;
    if (!urls) return callback?.();

    for (let key in this.externalCubemaps) {
        if (parseInt(key) !== index) {
            const old = this.externalCubemaps[key];
            old?.destroy?.();
            delete this.externalCubemaps[key];
        }
    }

    if (this.externalCubemaps[index]) {
        this.setSkybox(this.externalCubemaps[index]);
        return callback?.();
    }

    this.loadExternalCubemap(Object.values(urls), index, callback);
};

BugGameLogic.prototype.setSkybox = function (cubemap) {
    this.app.scene.skybox = cubemap;
    this.app.scene.skyboxMip = 1;
};

BugGameLogic.prototype.loadExternalCubemap = function (urls, index, callback) {
    const assets = [];
    let loaded = 0;

    const onLoaded = () => {
        if (++loaded === 6) {
            const tex = new pc.Texture(this.app.graphicsDevice, {
                cubemap: true,
                width: assets[0].resource.width,
                height: assets[0].resource.height,
                format: pc.PIXELFORMAT_R8_G8_B8_A8
            });
            tex.setSource(assets.map(a => a.resource.getSource()));
            tex.upload();
            this.externalCubemaps[index] = tex;
            callback?.(tex);
        }
    };

    urls.forEach((url, i) => {
        let asset = this.app.assets.find(`face${index}_${i}`, 'texture') ||
                    new pc.Asset(`face${index}_${i}`, 'texture', { url });

        this.app.assets.add(asset);
        asset.once('load', () => { assets[i] = asset; onLoaded(); });
        if (!asset.resource) this.app.assets.load(asset);
    });
};

// BLEND FUNCTIONS
BugGameLogic.prototype.showBlend = function (spriteAsset, text, delay = 0.8) {
    this.blend?.element?.enable();
    this.blendText?.element?.enable();

    this.blend.element.spriteAsset = spriteAsset; // <-- This is where error likely happens
    this.blendText.element.text = text;

    this.blend.enabled = true;
    this.blendText.enabled = true;

    if (this.blendTween) this.blendTween.stop();
    this.blendTween = this.blend?.tween(this.blend.getLocalScale())
        .to(new pc.Vec3(0, 0, 0), 0.8, pc.SineOut)
        .delay(delay)
        .on('complete', () => {
            this.blend.enabled = false;
            this.blendText.enabled = false;
        })
        .start();
};

// CLEANUP
BugGameLogic.prototype.onDestroy = function () {
    this.app.off('closeFindTheBugGame', this.closeGame, this);
    this.destroyAllTexturesAndCubemaps();

    this.blend = null;
    this.blendText = null;
    this.container = null;
    BugGameLogic.instance = null;
};

BugGameLogic.prototype.destroyAllTexturesAndCubemaps = function () {
    for (let key in this.externalCubemaps) {
        this.externalCubemaps[key]?.destroy?.();
    }
    this.externalCubemaps = {};
    this.app.scene.skybox = null;
};

BugGameLogic.prototype.closeGame = function () {
    if (this.container) this.container.style.display = 'none';
};



We remove the scene by calling destroyActiveScene() through the sceneManager (which I shared earlier). This function clears the rootSubScene entity, which holds everything loaded via _loadSceneHierarchy().

In this specific mini-game, we load cubemap textures from a server and apply them to the scene.

Like I mentioned earlier, everything works fine the first time the scene is loaded. But when exiting the game and re-entering it, I start getting reference errors — for example, attributes assigned via the Editor (like entities, or the sound component) become null or are missing entirely.

This raises a question:
Are these scene entities actually deleted, and when re-adding the scene, doesn’t it behave like a template with all original entities and scripts reinstated?

It also seems like the scripts are not initializing correctly the second time. I expected that initialize() would run again and that everything would behave as if it were being added fresh — but that doesn’t seem to be happening. It seems variables are still set from the first adding and completing scene states.

This is the game scene hierarchy:
Screenshot 2025-07-30 104618

Maybe I’ve misunderstood how to properly handle scene loading and removal in PlayCanvas and if destroying the scene root entity when leaving the game or virtual world would be enough. (seems not to be the case). Furthermore when adding a scene i usually also loading the scene settings too, except for a quiz game which is fully css / html overlay.

In the destroy function, I’m also detaching the events you mentioned (at least I believe I am). Also for helper scripts (camera controller, obstacle controller, and so on)

Does this help clarify things?

Thanks a lot!

Perhaps you could try and fork this project: Changing Scenes | PlayCanvas Developer Site

and see if you can reproduce your problem by adding a script you have that does not initialize properly and similar.

Hello everyone,

A quick update on this: it seems the issue was caused by one event listener not being removed properly, which kept the script reference alive.

The scene entities were completely removed, but the script references weren’t, which led to the null exception error.

So @eproasim you where right :slight_smile:

Thank you all for your feedback!

Right now it seems to work

3 Likes