andrewzigler.com

Using Phaser 3 with Nuxt

If you're not familiar with Phaser, it's an open source framework for creating browser games in Canvas and WebGL. It comes with a lot of functionality out of the box, allowing a developer to easily implement sprites, sounds, animations, particles, cameras, physics, user input, and other game logic. It's primarily used to create web games, but it's a rendering engine with built-in state logic so it can be used for a wide array of applications that need to marry visuals with user interaction.

It's a lightweight engine that's used by companies and hobbyists alike to create first-class web games. Nowadays, creating a web game allows for easy portability to other platforms, like apps for iOS and Android. The web is becoming something like a lingua franca for the many diverse technology platforms that we encounter in our lives.

I started playing around with Phaser simply out of fun. Last year, I worked with the TIC-80 fantasy console and ended up writing an entire blog post about using TIC-80 in JavaScript. I used JavaScript at the time simply because I had no interest in Lua. The challenge of using TIC-80 was embracing the restraints of a fantasy console, in a celebration of how things used to be coded. However, I quickly found myself fighting against those boundaries!

Now with Phaser, I'm working to leverage as much performance as possible from the browser. When I work with Nuxt, I'm similarly doing everything I can to put the browser to work. For both types of frameworks, I'm trying to deliver a user experience that's engaging and memorable. Nuxt is incredibly good at maintaining state and displaying a responsive website, and Phaser is quite excellent at rendering visuals and understanding user input. I quickly saw a lot of potential in using them together, so I started integrating Phaser into this website — which is a Nuxt app!

By default, Nuxt will convert small images into a data URI, which allows the image to be inserted inline with the actual HTML and eliminates the need for an HTTP request. This can result in faster loading times and is a common practice on modern websites. It uses Base64 to encode binary data, and the resulting text has nothing but letters, numbers and the symbols "+", "/" and "=". It's a convenient way to transmit binary data because those 64 characters are common in many character sets and there's a low chance that your data will be corrupted on the other end of the wire. Base64 maps 3 bytes (8 x 3 = 24 bits) in 4 characters that span 6 bits (6 x 4 = 24 bits). The result looks something like "AAAGAAAABgCAYAAADimHc4AAA" and the image size actually becomes 4/3 = 1.3333333 times the original as a result of the encoding. For many web developers, eliminating the HTTP request outweighs the image bloat, especially when used only for small images. That's why Nuxt will automatically convert your small images into data URIs during its default webpack build process.

Base64 images can affect SEO because the encoded image will not be indexed by Google, so they won't show up in a Google image search, or any other search engine. There are many unimportant images on a page that will not affect you or your traffic if they weren't indexed, like social icons. All those little Twitter, Google+, Facebook, etc. images are really small and have no need to be indexed. In fact when you really look at the images on your page you may find many images that fall into this category. Images like those are just not important to your SEO but are still slowing down your pages. A page could have 8 or 12 unimportant images and each of those is an additional HTTP request that can be avoided with Base64.

So if you use Phaser on a Nuxt app with this default configuration, your small image resources will be compiled into data URIs. When your application attempts to load those assets, your console is going to throw this error:

Local data URIs are not supported

Why does this happen? Like mentioned above, data URIs aren't necessarily more performant just because they eliminate an HTTP request. In the age of HTTP/2, we can send multiple requests in parallel over a single TCP connection in the browser. By encoding your images, you're bloating them and putting more strain on the end user's processor to run the application. This is going to throttle performance because your images are larger (due to the compression bloat) and then they have to be converted from Base64 back into a binary format, and then the image data must be extracted by Phaser. By converting an image into an data URI, you add an extra step to the process — and your users have to pick up the tab!

It's easy to extend the default webpack build in Nuxt, but in this case we can't just push a new build rule for images in nuxt.config.js, because the default loader configuration already handles images. Adding a new build rule wouldn't prevent the first one from converting your small images into data URI. Here's what that default configuration looks like:

[
  {
    test: /\.(png|jpe?g|gif|svg|webp)$/,
    loader: 'url-loader',
    query: {
      limit: 1000, // 1kB
      name: 'img/[name].[hash:7].[ext]'
    }
  },
  {
    test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
    loader: 'url-loader',
    query: {
      limit: 1000, // 1kB
      name: 'fonts/[name].[hash:7].[ext]'
    }
  }
]

For both loaders, everything below 1 KB will be inlined as a Base64 data URI. The second loader configuration is for fonts and we can leave it alone. But the first loader configuration is for images. We need to exclude our Phaser assets directory from that first loader with a new rule in nuxt.config.js. While we're at it, we'll add a rule to load those Phaser assets with file-loader:

build: {
  extend(config, ctx) {
    // exclude Phaser image assets from url-loader
    config.module.rules.forEach(rule => {
      if (rule.test.toString().includes('png')) {
        rule.exclude = /phaser/
      }
    })
    // load Phaser image assets
    config.module.rules.push({
      test: /\.(png|jpe?g|gif|svg|webp)$/i,
      loader: 'file-loader',
      options: {
        include: /phaser/,
        name: '[path][name].[ext]'
      }
    })
  }
}

We need to also add a build rule for handling our audio assets. Otherwise, Nuxt won't be able to load an mp3 or an Ogg:

config.module.rules.push({
  test: /\.(ogg|mp3|wav|mpe?g)$/i,
  loader: 'file-loader',
  options: {
    name: '[path][name].[ext]'
  }
})

Now that we're correctly loading our assets, we need to create a plugin (@/plugins/phaserLoader.js) that shows Nuxt how to load a Phaser game:

import Vue from 'vue'

Vue.prototype.$phaserLoader = {
  loadGame(loaderKey) {
    if (process.client) {
      return require(`@/assets/phaser/${loaderKey}/game`)
    }
  }
}

Once you do that, don't forget to include it in your nuxt.config.js:

  plugins: [
    { src: '~/plugins/phaserLoader.js' }
  ]

By injecting this loader into the Vue scope, you can easily load any Phaser game in a component by providing a loaderKey to the loadGame function. We must confirm we're client-side before the function runs, because otherwise Nuxt will attempt to load Phaser server-side and will throw reference errors (there is no window, for example, which is critical for Phaser).

Now we have everything needed to assemble a very simple Vue component (called phaser-container) that can load our Phaser game and pass the Vuex store to it:

<template>
  <div :id="phaserContainer" />
</template>

<scrip>
export default {
  name: 'PhaserContainer',

  props: {
    width: {
      type: Number,
      default: 800
    },
    height: {
      type: Number,
      default: 600
    },
    pageContainer: {
      type: String,
      default: 'container'
    },
    phaserContainer: {
      type: String,
      default: 'phaser-container'
    },
    fixedSize: {
      type: Boolean,
      default: false
    },
    game: {
      default() {
        return {}
      }
    }
  },

  destroyed() {
    this.gameObject.destroy()
  },

  mounted() {
    this.$nextTick(() => {
      // get page's main container (for sizing)
      const pageContainer = document.getElementsByClassName(
        this.pageContainer
      )[0]

      // launch game with resizing
      if (this.fixedSize === false) {
        this.gameObject = this.game.launch({
          width:
            pageContainer.clientWidth < this.width
              ? pageContainer.clientWidth
              : this.width,
          height:
            pageContainer.clientHeight < this.height
              ? pageContainer.clientHeight
              : pageContainer.clientWidth * 0.7 < this.height
              ? pageContainer.clientWidth * 0.7
              : this.height,
          parent: this.phaserContainer,
          store: this.$store ? this.$store : null
        })
        // launch game without resizing
      } else {
        this.gameObject = this.game.launch({
          width: this.width,
          height: this.height,
          parent: this.phaserContainer,
          store: this.$store ? this.$store : null
        })
      }
    })
  }
}
</scrip>

The component can also be easily installed from npm (@andrewzigler/phaser-container) and used right in your Vue or Nuxt application. It's easier to break down how it works by giving an example of how to implement it, so I recommend checking out my Nuxt and Phaser example. Here's an overview of the component:

<phaser-container
      width="800"
      height="600"
      :page-container="pageContainer"
      :phaser-container="phaserContainer"
      :fixed-size=false
      :game="componentPayload"
    />

width: (width of game in pixels, default: 800)
height: (height of game in pixels, default: 600)
pageContainer: (class name of parent element, for determining max sizes, default: 'container')
phaserContainer: (ID name of element to contain Phaser element, default: 'phaser-container')
fixedSize: (if true, prevents the component from resizing based on pageContainer, default: false)
game: (imported Phaser game file)
store: (Vuex store, default: undefined)

In the mounted hook, we're finding the current size of the bounding container (pageContainer) and then resizing the Phaser game based on those dimensions. This could be useful to creating a responsive Phaser game that works across different displays. You can also disable the resizing by changing fixedSize to true. In the example above, payload is passed to the game property. On my example page, payload is determined by a computed function:

computed: {
    componentPayload() {
      switch (this.exampleComponentProps.type) {
        // if the page is loading a Phaser game
        case 'phaser': {
          this.$store.commit(
            'initializePhaser',
            this.exampleComponentProps.loaderKey
          )
          return this.$phaserLoader.loadGame(
            this.exampleComponentProps.loaderKey
          )
        }
        default:
          return {}
      }
    }
}

You'll recognize our phaserLoader from before. Here it's being used to actually load the game file so our page can use it! While not passed to the actual component, the phaser-container component will attempt to load your store into your Phaser game. It does this by appending the store to the Phaser game's registry prop, which is an instance of DataManager, which has an EventEmitter. Since your store will be appended to Phaser's global event manager, you can easily set up an event bus to connect reactive data between Phaser and Nuxt.

Here's how I put together all of the code for my Nuxt and Phaser example (all of this was inside my @/assets/phaser directory, and my game assets are in @/assets/phaser/exampleGame/assets:

exampleGame/game.js:

import Phaser from 'phaser'
import BounceScene from './scenes/BounceScene'
import event from './event'

function launch({
  width = 800,
  height = 600,
  parent = 'phaser-container',
  store
}) {
  const game = new Phaser.Game({
    type: Phaser.AUTO,
    width,
    height,
    parent,
    physics: {
      default: 'arcade',
      arcade: {
        gravity: { y: 400 }
      }
    },
    scene: [BounceScene]
  })

  // replace the EventEmitter on the DataManager with our own imported EventEmitter
  game.registry.events = event

  // append the Vuex store to EventEmitter
  game.registry.events.store = store

  // if there is no pre-existing game state, initialize it
  if (!store.getters.phaser.exampleGame.bounces) {
    event.store.commit('savePhaser', {
      gameName: 'exampleGame',
      prop: 'bounces',
      value: 0
    })
  }

  return game
}

export default launch
export { launch }

exampleGame/events.js:

const { Events } = require('phaser')
const events = new Events.EventEmitter()

events.on('bounce', () => {
  let value = events.store.getters.phaser.exampleGame.bounces
  value++

  events.store.commit('savePhaser', {
    gameName: 'exampleGame',
    prop: 'bounces',
    value
  })
})

export default events

exampleGame/scenes/BounceScene.js:

import { Scene } from 'phaser'
import background from '../assets/background.png'
import ball from '../assets/ball.png'
import bounceMp3 from '../assets/bounce.mp3'
import bounceOgg from '../assets/bounce.ogg'

export default class BounceScene extends Scene {
  constructor() {
    super({ key: 'BounceScene' })
  }

  preload() {
    this.load.image('background', background)
    this.load.image('ball', ball)
    this.load.audio('bounce', [bounceMp3, bounceOgg])
  }

  create() {
    this.add.image(400, 300, 'background')

    const ball = this.physics.add.image(400, 200, 'ball')
    ball.setCollideWorldBounds(true)
    ball.body.onWorldBounds = true
    ball.setBounce(1)
    ball.setVelocity(300, 50)

    this.sound.add('bounce')
    this.physics.world.on('worldbounds', () => {
      this.registry.events.emit('bounce')
      this.sound.play('bounce', { volume: 0.6 })
    })
  }
}

Now that you can integrate your Phaser games into Vue and Nuxt, what will you make? Phaser continues to only get bigger and better. Richard Davey (also known as Photonstorm) just announced this month that development will soon begin for Phaser 4, which will be supported in part by the Facebook Gaming and Instant Games teams. What's in store for the next major version of the framework? More details will be coming in the future, but in the meantime don't miss their announcement!