Hot Module Replacement with Parcel 2 and Phaser 3

Accelerate game development with an advanced programming technique!

Quick Links:

It’s no secret that developing even a seemingly simple a video game is a ton of work. Fussing over the play experience and making sure it’s just right comprises a large chunk of that work, often requiring manually testing various aspects of the game after making changes to its code. Depending on the development set up, that may require reloading the game, effectively resetting it to its starting state, then playing the game until it’s back in the state required for the test. For example, if we’re developing a platformer game and we want to test whether the player can jump up to a certain platform, we’d have to replay from the start until we get back to the point with the platform in question, for every change. Even if the developer is a world-class speed runner, this can be a rather slow and distraction-prone feedback loop, making the developer’s task much more difficult than it needs to be.

Enter Hot Module Replacement, an advanced technique which allows a developer to load in changes to an app’s code without losing its state. To get this working in an app being built with Parcel is straightforward, as Parcel has explicit out-of-the-box support for HMR, yet figuring out how to leverage it with Phaser can be challenging.

In the rest of this article, we’ll create an extremely minimal platformer in Phaser that opts-in to Parcel’s HMR feature, allowing us to move the player around and make changes to the code (e.g. gravity) without our sprite’s position being reset to its initial position. But first, make sure you’re familiar with the documentation on Parcel’s development server, and modern JavaScript development in general. You can find the complete working code on GitHub.

Let’s code!

package.json

Point Parcel’s development server to our index.html.

{
  "name": "parcel_playground",
  "packageManager": "[email protected]",
  "source": "src/index.html",
  "scripts": {
    "start": "parcel serve --lazy",
  },
  "devDependencies": {
    "parcel": "^2.9.3",
  },
  "dependencies": {
    "phaser": "^3.60.0",
  }
}

Aside: I recommend using the --lazy flag with parcel’s serve command to ensure snappy rebuilds.

index.html

Load our game’s TypeScript and create a DOM container for Phaser.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <title>My Parcel+Phaser HMR Example</title>
    <script type="module" src="./game.ts"></script>
  </head>
  <body>
    <div id="game"></div>
  </body>
</html>

game.ts

The code for a simple platformer style game in Phaser. We can move our character (Phaser’s image placeholder) left and right with the arrow keys, and jump with SPACE or UP. We have some module-level declarations: A gravity constant, so it’s easy to adjust later, and some variables for a Phaser Game instance and for the player’s Sprite.

import * as Phaser from "phaser";
import type { Types as PhaserTypes } from "phaser";

const GRAVITY_Y = 400;

let _game: Phaser.Game | undefined;

let _player: PhaserTypes.Physics.Arcade.SpriteWithDynamicBody;

_game = createGame();

function createGame() {
  return new Phaser.Game({
    parent: "game", // element ID
    type: Phaser.AUTO, // try WebGL, fallback to Canvas
    width: 800,
    height: 640,
    scale: {
      mode: Phaser.Scale.RESIZE,
      autoCenter: Phaser.Scale.CENTER_BOTH,
    },
    scene: MyScene,
    physics: {
      default: "arcade",
      arcade: {
        gravity: { x: 0, y: 0 },
      },
    },
  });
}

class MyScene extends Phaser.Scene {
  #cursors: PhaserTypes.Input.Keyboard.CursorKeys | undefined;
  create() {
    console.log("creating player!");
    _player = this.physics.add.sprite(50, 300, "player");
    _player.setBounce(0.1);
    _player.setCollideWorldBounds(true);
    _player.setGravityY(GRAVITY_Y);

    this.#cursors = this.input.keyboard!.createCursorKeys();
  }
  update() {
    // Control the player with left or right keys
    if (this.#cursors!.left.isDown) {
      _player!.setVelocityX(-200);
    } else if (this.#cursors!.right.isDown) {
      _player!.setVelocityX(200);
    } else {
      // If no keys are pressed, the player keeps still
      _player!.setVelocityX(0);
    }

    // Player can jump while walking any direction by pressing the space bar
    // or the 'UP' arrow
    if (
      (this.#cursors!.space.isDown || this.#cursors!.up.isDown) &&
      _player!.body.onFloor()
    ) {
      _player!.setVelocityY(-350);
    }
    if (_player!.body.velocity.x > 0) {
      _player!.setFlipX(false);
    } else if (_player!.body.velocity.x < 0) {
      // otherwise, make them face the other side
      _player!.setFlipX(true);
    }
  }
}

console.log("finished evaluating module scope");

Note: this code is based on that of StackAbuse’s excellent Phaser Platformer example.

HMR Opt-in

To use Parcel’s HMR support, we must opt in to it using the module.hot API. Add the following just above the call to _createGame and refresh the page.

/* vvv HMR Opt-in vvv */
let _reloadCount = 0;

interface HMRData {
  reloadCount: number;
}

if (module.hot) {
  module.hot.dispose((data: HMRData) => {
    console.log("dispose module (reload #" + _reloadCount + ")");
    data.reloadCount = _reloadCount;
  });
  module.hot.accept(() => {
    _reloadCount = module.hot!.data.reloadCount
    console.log("accept module (reload #" + _reloadCount + ")");
    _reloadCount++;
  });

  setTimeout(() => {
    console.log("next tick after evaluating module");
  }, 0)
}
/* ^^^ HMR Opt-in ^^^ */
_game = createGame();

Note: You may need to install the @types/parcel-env package if the TypeScript compiler doesn’t know about module.hot. (Actually, at the time of writing, these types are incorrect.)

Now, if you make any change to game.ts, the messages in the developer console should indicate the following order of events:

  1. The dispose callback is called
  2. The module level scope is evaluated
  3. The accept callback is called
  4. The setTimeout callback is called

If you’re familiar with how HMR works, then steps 1-3 should not be surprising. You may not know, however, that the accept callback is called synchronously with the module-level execution. You may also be unaware that setTimeout can be used to call a function on the next “tick.” To give a brief explanation, setTimeout queues an asynchronous, future event. JavaScript doesn’t have any control over exactly when asynchronous events occur, all it can do is request a callback at a certain time and the host system (traditionally, a Web browser) does its best to honor that request. A setTimeout call with zero for the second argument asks the host system to wait the smallest possible amount of time before calling the callback, during which it might handle some of the side effects of the JavaScript executed so far. This smallest possible unit of time is colloquially known as a “tick.”

We now have a model for how HMR can be applied to a Phaser Game, all that’s left is to fill in this model with the details that will accomplish replacing this module at runtime.

let _reloadCount = 0;

interface HMRData {
  reloadCount: number;
  /* vvv module-specific data vvv */
  game: Phaser.Game;
  player: PhaserTypes.Physics.Arcade.SpriteWithDynamicBody;
  /* ^^^ module-specific data ^^^ */
}

if (module.hot) {
  module.hot.dispose((data: HMRData) => {
    console.log("dispose module (reload #" + _reloadCount + ")");
    data.reloadCount = _reloadCount;
    /* vvv module-specific data vvv */
    data.game = _game;
    data.player = _player;
    /* ^^^ module-specific data ^^^ */
  });
  module.hot.accept(() => {
    _reloadCount = module.hot!.data.reloadCount
    console.log("accept module (reload #" + _reloadCount + ")");
    _reloadCount++;
    /* vvv module-specific logic vvv */
    _game = module.hot!.data.game;
    _player = module.hot!.data.player;
    _player.setGravityY(GRAVITY_Y);
    /* ^^^ module-specific logic ^^^ */
  });

  setTimeout(() => {
    console.log("next tick after evaluating module");
    /* vvv module-specific logic vvv */
    if(!_game) {
      _game = createGame();
    }
    /* ^^^ module-specific logic ^^^ */
  }, 0)
 /* vvv module-specific logic vvv */
 } else {
   _game = createGame();
 /* ^^^ module-specific logic ^^^ */
}

Above, we save the Game instance and player Sprite before disposing of the old version of the module. Then, when we accept the new version of the module, we restore these objects to their respective module-level variables. We also set the player’s gravity, in case it’s changed. We use the setTimeout callback to conditionally create the game after the accept callback so that we instantiate the Game when the page first loads, but we aren’t instantiating it when there’s already an instance from the previous module version. Finally, we add an else clause to handle the case when HMR is not enabled, such as in production. Note that we do not destroy and recreate anything, as that isn’t necessary to simply change the player’s gravity, and would cause us to lose state and make our feedback loop slower. If you do need to re-create the game, however, make sure you call destroy(true) on the old instance, or the old instance will still be there, causing mischief.

If all goes well, we should now be able to move our player, change the gravity constant, and immediately see that our player experiences more or less gravity than before! Since our Game and player Sprite instances are being persisted, all other state, such as the its position and facing direction should also be persisted. Hopefully it’s now clear how to extend this example to allow changing any other aspect of a game besides the gravity constant. Now the only limit is our knowledge of Phaser’s APIs. Give it a try and let me know how it goes!

Next steps and further reading:

Comments

Leave a Reply

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