Hot Reloading Tilemap Data in Phaser 3

In a previous article we learned how to load changes to a game’s source code in a running Phaser game using Parcel 2’s Hot Module Replacement. That only applies to hot reloading changes to the game’s logic (i.e. its code), not its data or assets. Once we’ve tasted the productivity-boosting benefits of hot reloading, however, we may no longer tolerate having to cold reload anything. In this article I’ll walk through a solution I’ve found to reload a specific kind of asset: Tilemaps.

Let’s assume that we already have Phaser.Scene in which we create a load a map we’ve created in Tiled and store it in a module-level array like so:

const _maps: Array<Phaser.Tilemaps.Tilemap> = [];

class MyScene extends Phaser.Scene {
  preload() {
    // ...
    this.load.tilemapTiledJSON(MAP_PATH, MAP_PATH);
  }
  create() {
    // ...
    _maps.push(this.make.tilemap({ key: MAP_PATH }));
  }
}

Let’s also assume that we’re listening to a Websocket that will send us a message whenever the map data file changes:

const ws = new WebSocket("ws://localhost:2345");

ws.onmessage = async (event) => {
  const message = JSON.parse(event.data);
  
  console.log("received message", message);
  // => { type: "tilemap-change", path: "assets/tilemaps/example.json" }
}

Then we can iterate over the existing Tilemap instances, updating their tiles whenever any difference is encountered:

const ws = new WebSocket("ws://localhost:2345");

ws.onmessage = async (event) => {

  const message = JSON.parse(event.data);
  
  if(message.type === "tilemap-change") {
  
    for(const map of _maps) {
      //...
    }
    
  }
  
}

But wait, how does one reload things that have already been loaded in Phaser? We’re running in to the downside of using an engine or framework: Things get wonky once you wander just a bit off its golden path. The way that I’ve found is to create a dummy `Phaser.Scene` instance that will allow us to load the new map data and store it in a module-level variable so we can re-use it:

let _reloaderScene: Phaser.Scene | undefined;

const ws = new WebSocket("ws://localhost:2345");

ws.onmessage = async (event) => {

  const message = JSON.parse(event.data);
  
  if(message.type === "tilemap-change") {
  
    _reloaderScene = _reloaderScene || _game!.scene.add("reloader", {})!
    
    for(const map of _maps) {
      //...
    }
  }
}

With that in place, we can now queue up a request for the updated map using the dummy scene, taking care to ensure that the cache key for the updated map is different than that of the original. We also clear the cache at the end so that we get fresh map data next time:

let _reloaderScene: Phaser.Scene | undefined;

const ws = new WebSocket("ws://localhost:2345");
ws.onmessage = async (event) => {

  const message = JSON.parse(event.data);
  
  if(message.type === "tilemap-change") {
    _reloaderScene = _reloaderScene || _game!.scene.add("reloader", {})!
    
    const reloadedMapKey = `${message.path}?reload`;
    
    await loadTilemapTiledJSON(_reloaderScene, reloadedMapKey, message.path);
    
    const reloadedMap = _reloaderScene!.make.tilemap({
      key: reloadedMapKey,
    });

    for(const map of _maps) {
      //...
    }
    
    _reloaderScene!.load.cacheManager.tilemap.remove(reloadedMapKey);
  }
}

function loadTilemapTiledJSON(scene: Phaser.Scene, key: string, path: string) {
  return new Promise((resolve) => {
    scene.load.tilemapTiledJSON(key, path).once("complete", resolve);
    scene.load.start();
  });
}

      

Now we can finally iterate over all the tiles in each layer, comparing them and updating the ones that are different. Note that the getTilesWithin method will return an array of length width * height, putting undefined wherever there isn’t a tile.

let _reloaderScene: Phaser.Scene | undefined;

const ws = new WebSocket("ws://localhost:2345");

ws.onmessage = async (event) => {

  const message = JSON.parse(event.data);
  
  if(message.type === "tilemap-change") {
  
    _reloaderScene = _reloaderScene || _game!.scene.add("reloader", {})!
    
    const reloadedMapKey = `${message.path}?reload`;
    
    await loadTilemapTiledJSON(_reloaderScene, reloadedMapKey, message.path);
    
    const reloadedMap = _reloaderScene!.make.tilemap({
      key: reloadedMapKey,
    });
    
    for(const map of _maps) {
    
      for (const layer of reloadedMap.layers) {
      
        for (const newTile of reloadedMap.getTilesWithin(
          0,
          0,
          Math.max(map.width, reloadedMap.width),
          Math.max(map.height, reloadedMap.height),
          {},
          layer.name
        ) || []) {
        
          if (areMapTilesEqual(newTile, map, reloadedMap)) {
          
            map.putTileAt(newTile, newTile.x, newTile.y, undefined, layer.name);
            
          }
          
        }
      }
    }
    _reloaderScene!.load.cacheManager.tilemap.remove(reloadedMapKey);
  }
}

function areMapTilesEqual(
  tileA: Phaser.Tilemaps.Tile,
  mapA: Phaser.Tilemaps.Tilemap,
  mapB: Phaser.Tilemaps.Tilemap
) {
  const mapAHasTile = mapA.hasTileAt(tileA.x, tileA.y);
  const mapBHasTile = mapB.hasTileAt(tileA.x, tileA.y);
  const tileB = mapB.getTileAt(tileA.x, tileA.y);
  return mapAHasTile === mapBHasTile && tileA?.index === tileB?.index;
}

// omitting loadTilemapTiledJSON

And that’s it!

There’s certainly other ways to accomplish the above. For instance, the map data could be loaded statically and then reloaded using the build system’s HMR API, but that would mean statically loading the map, which would increase the time to the “first meaningful paint.” With dynamic loading like the above, we can load a minimal amount of the game statically, then show a loading progress bar or some other kind of feedback to the player that will make the time spent loading the rest seem to go by faster.

There might also be a more elegant and direct way to reload the map data by digging in to Phaser’s internals, eliminating the need to create a dummy Scene with a cache that needs to be cleared after each reload. This might be possible, but it’s important to consider that if code relies on parts of Phaser that aren’t explicitly part of the public API, then that code is more likely to be broken by future releases of Phaser. To me, that risk doesn’t seem worth it.

Let me know in the comments what you think!

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *