CMPUT 404

Web Applications and Architecture

Lab 8 - WebSockets


Learn how to utilize WebSockets and Phaser.io. Create a basic Phaser game with WebSocket connectivity for real-time server to client communication. Use Node.js for our application server. Use TypeScript with Parcel for bundling browser client code.

Install Node.js

There are two different ways you can try to install node.js!

Option 1: Install node on my own computer

If you don't already have node.js installed, download and install node from the node from the node.js website.

This won't be possible on the lab machines, because it requires administrator/root.

If you already have node installed, please check the version with node -v.

Example:

$ node -v
v15.11.0

Make sure the version number is greater than 8! (In this case it's 15, which is fine.)

If your version is older than 8, please either uninstall node and reinstall a more recent version of node from the node.js website or follow the instructions in the next section for the lab machines and VM.

Option 2: Install Node with NVM

NVM allows you to install multiple versions of node on Linux or Macs without being an administrator or root user.

NVM does not work on windows.

Install Node Version Manager.

# curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash

# or wget
wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.34.0/install.sh | bash

Close and reopen your terminal, or load nvm manually.

export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

Install the latest release of node.

nvm install node # latest node version

# LTS. Alternatively 8.15.0 (6.X is EOL April 2019)
# nvm install 10.15.1 

Map this version of node as the current default.

nvm alias default node
node --version
# v11.10.0
npm --version
# 6.7.0
Initialize Phaser and WebSockerServer

Fork this repository and clone it locally onto your own machine.

Run the application locally, following the quickstart instructions.

npm install
npm start
# navigating to browser, localhost:8080

You can ignore warnings/errors about security vulnerabilities.

If you get errors after npm install on Mac OS, look for an error about Xcode. If there is an error about Xcode, you may need to install it: Xcode.

If you get the error Cannot find module 'ws' after running npm start, try downgrading your node version with npm install 10.15.1 OR nvm install 10.15.1 (depending on which node manager you're using).

If there is an error like favicon.ico: Invalid Version: undefined this is because of a bug in parcel bundler 1.12.4, so downgrade to 1.12.3 by running npm install parcel-bundler@1.12.3.

Question 1: What do you see in the browser? When you open another tab and perform a click/drag action, what happens?

Question 2: What are some of the differences between TypeScript and JavaScript?

Question 3: Why is a web application bundler (Parcel, Webpack, Rollup, etc.) useful for modern web projects? What are some features that ParcelJS provides?

Top Down Scroller

We will now utilize the other assets and create a top down game where we control our character using the arrow keys.

Checkout the topdown branch from GitHub and make note of the changes in client.ts.

git remote -v # show your current remotes
git remote add laborigin https://github.com/uofa-cmput404/nodejs-ws-lab.git
git fetch laborigin
git checkout -b topdown laborigin/topdown

Within client.ts the preload function is now loading our required assets. The create function has been updated to use our tilemap data, spritesheet, and character assets. Additional logic for handling animations and keyboard input has also been added. An update function has been added to handle input logic and playing our sprite animations.

Multiplayer Top Down Scroller

The init function will require modifications to work with multiple players.

Add a function that generates UUIDs before the Scene class. For this toy example, the following modified from github gist is acceptable:

function uuid(
  a?: any               // placeholder
): string {
  return a              // if the placeholder was passed, return
    ? (                 // a random number from 0 to 15
      a ^               // unless b is 8,
      Math.random()     // in which case
      * 16              // a random number from
      >> a / 4          // 8 to 11
    ).toString(16)      // in hexadecimal
    : (                 // or otherwise a concatenated string:
      1e7.toString() +  // 10000000 +
      -1e3 +            // -1000 +
      -4e3 +            // -4000 +
      -8e3 +            // -80000000 +
      -1e11             // -100000000000,
    ).replace(          // replacing
      /[018]/g,         // zeroes, ones, and eights with
      uuid              // random hex digits
    )
}

Initialize the ID as a class property, before the constructor. Add a players map object and refactor all references to player such that the map is now used instead.

class GameScene extends Phaser.Scene {

  private VELOCITY = 100;
  private wsClient?: WebSocket;

  // delete this
  // private player?: Phaser.GameObjects.Sprite;
  // ...

  private id = uuid();
  private players: {[key: string]: Phaser.GameObjects.Sprite} = {};

  // ...

// Refactor your code such that all references to this.player
// becomes this.players[this.id]

// create
public create() {
  // ...
  this.players[this.id] = this.physics.add.sprite(48, 48, "player", 1);
  this.physics.add.collider(this.players[this.id], layer);
  this.cameras.main.startFollow(this.players[this.id]);
}


// update
public update() {
  if (this.players[this.id]) {
    const player = this.players[this.id];
    let moving = false;

    if (this.leftKey && this.leftKey.isDown) {
      (player.body as Phaser.Physics.Arcade.Body).setVelocityX(-this.VELOCITY);
      player.play("left", true);
      moving = true;
    }
    // ...
    player.update();
  }
}

Modify the update function to broadcast our player's position during movement.

if (!moving) {
  (player.body as Phaser.Physics.Arcade.Body).setVelocity(0);
  player.anims.stop();
} else if (this.wsClient) {
  this.wsClient.send(JSON.stringify({
    id: this.id,
    x: player.x,
    y: player.y,
    frame: player.frame.name
  }));
}

Within server.js, change the WebSocket server message handler to accomodate a map of IDs and positions.

function setupWSServer(server) {
  // ...
  let actorCoordinates = { };
  wss.on("connection", (ws) => {
    ws.on("message", (rawMsg) => {
      console.log(`RECV: ${rawMsg}`);
      const incommingMessage = JSON.parse(rawMsg);
      actorCoordinates[incommingMessage.id] = {
        x: incommingMessage.x,
        y: incommingMessage.y,
        frame: incommingMessage.frame
      }
    // ...

Update the client.ts file to handle the new map of positions, rendering multiple characters on screen.

The ICoords interface must change to accomodate a map of Ids to coordinates and frame numbers.

interface ICoords {
  [key: string]: {
    x: number;
    y: number;
    frame: number;
  }
}

The websocket client should parse and store sprite objects received from the server.

public init() {
  // ...
  this.wsClient.onmessage = (wsMsgEvent) => {
    const allCoords: ICoords = JSON.parse(wsMsgEvent.data);
    for (const playerId of Object.keys(allCoords)) {
      if (playerId === this.id) {
        // we don't need to update ourselves
        continue;
      }
      const { x, y, frame } = allCoords[playerId];
      if (playerId in this.players) {
        // We have seen this player before, update it!
        const player = this.players[playerId];
        if (player.texture.key === "__MISSING") {
          // Player was instantiated before texture was ready, reinstantiate
          player.destroy();
          this.players[playerId] = this.add.sprite(x, y, "player", frame);
        } else {
          player.setX(x);
          player.setY(y);
          player.setFrame(frame);  
        }
      } else {
        // We have not seen this player before, create it!
        this.players[playerId] = this.add.sprite(x, y, "player", frame);
      }
    }
  }
}

Let's modify our update function to also render all of the other players.

public update() {
  for (const playerId of Object.keys(this.players)) {
    const player = this.players[playerId];

    if (playerId !== this.id) {
      player.setTint(0x0000aa); // so we can tell our guy apart
      player.update();
      continue;
    }
    // rest of the input handling code

The reference source code is available on the finishedLab branch. You should now have a game running in your browser that handles real-time concurrent connections and updates among multiple users!

Question 4: What are the different values for the readyState a WebSocket can be in? Briefly describe what each state means. (Hint: check out the Mozilla WebSocket API)

Question 5: What's the link to your github repo?

Hint: git push -u origin topdown:master