Build

How to Make a Multiplayer Game: Full Tutorial and Demo

12 min read Michael Carroll on Dec 19, 2022
Try PubNub Today

Free up to 1MM monthly messages. No credit card required.

Subscribe to our newsletter

By submitting this form, you are agreeing to our Terms and Conditions and Privacy Policy.

Learn how to build a real-time multiplayer game with PubNub in the browser using HTML, CSS, JavaScript, and the Phaser framework.

Building a fast and scalable multiplayer game is no easy task, especially if the game is meant to be played in a web browser. Not only do you have to design a game that is functional on multiple browsers including desktop and mobile, but you also have to ensure that the gameplay is fluid and responsive when multiple players are enjoying the game in real time.

How to make a online game

This tutorial guides you through building the online multiplayer game Ninja Platformer, a browser-based collaborative puzzle game written in less than 1000 lines of code that encourages you to work with your friends to collect the keys to complete the levels. The game is built using Phaser, an HTML5 game development framework for Canvas and WebGL browser games designed for web and mobile games, as well as HTML, CSS, and JavaScript. Using Phaser's Arcade Physics Library, each character and object has its own physics body and properties, and the levels themselves are generated via JSON files.

To ensure that multiple players at a time can enjoy a smooth, reliable gameplay experience, PubNub is used to power the infrastructure to communicate this real-time information between these players and other social features to help make online games feel more interactive. Follow along as you learn about how each of these languages and technologies enables you to create your own browser-based multiplayer game.

Although this tutorial guides you step-by-step to create a multiplayer browser game, you can view the completed version of the game in the GitHub repository.

Environment Setup

To create your own HTML5 multiplayer platform game, you'll need a few basic tools to get started. Download a basic code editor, such as Visual Studio Code, and ensure you have access to the terminal.

Install Node.js

Node.js, an open-source JavaScript server environment, is necessary to install packages and execute necessary commands. Install the runtime environment that is appropriate for your operating system, which includes npm.

Run a Local Web Server

For the Phaser game engine to run in your browser, you are going to need to run a local web server. The Phaser files will not load without a webserver because browsers block files loading from different domains.

Create a folder on the desktop and call it Ninja-Multiplayer-Platformer. In the terminal, navigate to the directory where you created this folder. You have two options for running a local web server, http-server or browser-sync.

http-server launches a simple, but powerful, web server. Execute the following command to install the http-server package.

npm -g install http-server

Run the server with the command http-server.

Instead of http-server, you can use a tool called Browser Sync, which will automatically reload the browser every time you modify a file. Install browser-sync with the following command.

npm -g install browser-sync

Start the tool with the following command, which will launch the server and reload the browser when you modify any JavaScript file.

browser-sync start --server --files ="**/*.js"

Obtain PubNub Keys

To build an application that leverages PubNub's real-time data APIs, you need to sign up for your account to obtain API keys. Remember, PubNub will serve as the server-side infrastructure that powers the online multiplayer functionality of the game.

Once you have successfully signed up you will be taken to the admin page, where you can create an application that is associated with specific projects and keysets associated with those projects (development, testing, production, etc) to obtain the publish/subscribe keys necessary to connect to the PubNub network. Please view this how-to guide to learn how to create an application, keyset, and obtain keys.

Once you’re in the Admin Dashboard, name your application whatever you wish, and click the Create New App button. Once you create the application, click on the application and create a new keyset.

Click on the keyset, and it should load up a page that shows your keys in addition to features you can enable for your keys. You'll need to enable a few different features in your keyset for this application. You'll need to enable Presence to detect when new users come online, as well as check the Generate Leave on TCP FIN or RST setting. Also, enable Message Persistence to persist messages as they are published. Click on the Save Changes button to save the changes. Copy and save the publish and subscribe keys as you'll need these later on.

Project Assets

Download the ProjectAssets.zip file from the GitHub repository and extract these files to the Ninja-Multiplayer-Platformer folder. These files are the initial starting point of the tutorial, as it contains the starting HTML files, JavaScript files, and other media such as images and audio files.

Build and Run

In the Ninja-Multiplayer-Platformer folder, open js/main.js. Copy the following code to load the other Javascript files and initialize the phaser window.

// Load External Javascript files
const loadHeroScript = document.createElement('script');
loadHeroScript.src = './js/heroScript.js';
document.head.appendChild(loadHeroScript);
const loadLoadingState = document.createElement('script');
loadLoadingState.src = './js/loadingState.js';
document.head.appendChild(loadLoadingState);
const loadPlaystate = document.createElement('script');
loadPlaystate.src = './js/playState.js';
document.head.appendChild(loadPlaystate);
// =============================================================================
// Load the various phaser states and start game
// =============================================================================
window.addEventListener('load', () => {
  const game = new window.Phaser.Game(960, 600, window.Phaser.AUTO, 'game');
  game.state.disableVisibilityChange = true; // This allows two windows to be open at the same time and allow both windows to run the update function
  game.state.add('play', window.PlayState);
  game.state.add('loading', window.LoadingState);
  //window.createMyPubNub(0); // Connect to the PubNub network and run level code 0
  //window.StartLoading = function () {
    game.state.start('loading'); // Run the loading function once you successfully connect to the PubNub network
  //};
});

If you have not already started your local web server, be sure to do so now with one of the methods mentioned earlier. Save main.js and refresh the HTML window. You should see that the phaser window has been created. It should be completely black since there are no assets loaded in the scene yet. The window.Phaser.AUTO parameter is to specify whether you want a 2D canvas or a WebGL canvas. In this case, you will use WebGL by default but will fall back to 2D if it’s not supported.

Loading State

Next, you'll create the loading state to load the assets into the scene. Open js/loadingState.js and add the following functionality.

'use strict';
// =============================================================================
// Loading state
// =============================================================================
window.LoadingState = { // Create an object with all of the loading information inside of it
  init() {
    // keep crispy-looking pixels
    this.game.renderer.renderSession.roundPixels = true; // Make the phaser sprites look smoother
  },
  preload() {
    this.game.stage.disableVisibilityChange = true;
    // Load JSON levels
    this.game.load.json('level:0', 'data/level00.json');
    this.game.load.json('level:1', 'data/level01.json');
    this.game.load.json('level:2', 'data/level02.json');
    this.game.load.image('font:numbers', 'images/numbers.png');
    this.game.load.image('icon:coin', 'images/coin_icon.png');
    this.game.load.image('background', 'images/bg.png');
    this.game.load.image('invisible-wall', 'images/invisible_wall.png');
    this.game.load.image('ground', 'images/ground.png');
    this.game.load.image('grass:8x1', 'images/grass_8x1.png');
    this.game.load.image('grass:6x1', 'images/grass_6x1.png');
    this.game.load.image('grass:4x1', 'images/grass_4x1.png');
    this.game.load.image('grass:2x1', 'images/grass_2x1.png');
    this.game.load.image('grass:1x1', 'images/grass_1x1.png');
    this.game.load.image('key', 'images/key.png');
    this.game.load.spritesheet('decoration', 'images/decor.png', 42, 42);
    this.game.load.spritesheet('herodude', 'images/hero.png', 36, 42);
    this.game.load.spritesheet('hero', 'images/gameSmall.png', 36, 42);
    this.game.load.spritesheet('coin', 'images/coin_animated.png', 22, 22);
    this.game.load.spritesheet('door', 'images/door.png', 42, 66);
    this.game.load.spritesheet('icon:key', 'images/key_icon.png', 34, 30);
    this.game.load.audio('sfx:jump', 'audio/jump.wav');
    this.game.load.audio('sfx:coin', 'audio/coin.wav');
    this.game.load.audio('sfx:key', 'audio/key.wav');
    this.game.load.audio('sfx:stomp', 'audio/stomp.wav');
    this.game.load.audio('sfx:door', 'audio/door.wav');
    this.game.load.audio('bgm', ['audio/bgm.mp3', 'audio/bgm.ogg']);
  },
  create() {
    this.game.state.start('play', true, false, { level: window.globalCurrentLevel }); // Start Game
  }
};

This code executes several actions. The object window.LoadingState is created with the loading state information inside of it. In the init function, the sprite objects in the game are made to look smoother by using the Phaser API this.game.renderer.renderSession.roundPixels = true;. In the preload function, the JSON-level information is loaded from the data folder. This data information will be used to generate the levels. Then, every asset that will be used in the game needs to be preloaded into the cache. The various sprite sheets and audio are also preloaded during this time (audio is not used in this tutorial; however, you can easily add it by uncommenting code).

The final piece of code for loadingState.js runs create that starts the game and loads what the current level is via window.globalCurrentLevel. This will be handled in main.js. Navigate back to main.js. Add the following at the top of the file which will create the global variables necessary for the rest of the tutorial.

window.syncOtherPlayerFrameDelay = 0; //30 frames allows for 500ms of network jitter, to prevent late frames
window.currentChannelName; // Global variable for the current channel that your player character is on
window.currentFireChannelName; // Global variable that checks the current stage you are on to send the correct information to the PubNub Block
window.globalCurrentLevel = 0; // Global variable for the current level (index starts at 0)
window.UniqueID = window.PubNub.generateUUID(); // Generate a unique id for the player. Generated by the PubNub Network
window.globalLevelState = null; // Sets the globalLevelState to null if you aren't connected to the network. Once connected, the level will generate to the info that was on the block.
window.globalWasHeroMoving = true;
// console.log('UniqueID', UniqueID); // Print out your clientsr Unique ID
window.text1 = 'Level 1 Occupancy: 0'; // Global text objects for occupancy count
window.text2 = 'Level 2 Occupancy: 0';
window.text3 = 'Level 3 Occupancy: 0';
let textResponse1;
let textResponse2;
let textResponse3;
window.updateOccupancyCounter = false; // Occupancy Counter variable to check if the timer has already been called in that scene
window.keyMessages = [];

Play State

Navigate to js/playState.js and instantiate the PlayState game state and set up some variables.

const keyStates = {};
let keyCollected = false;
window.frameCounter = 0;
window.PlayState = {
    init(data) {
  this.keys = this.game.input.keyboard.addKeys({
    left: window.Phaser.KeyCode.LEFT,
    right: window.Phaser.KeyCode.RIGHT,
    up: window.Phaser.KeyCode.UP
  });
  this.coinPickupCount = 0;
  keyCollected = false;
  this.level = (data.level || 0);
},
create() {
  window.globalGameState = this;
  // fade in  (from black)
  this.camera.flash('#000000');
  // create sound entities
  this.sfx = {
    jump: this.game.add.audio('sfx:jump'),
    coin: this.game.add.audio('sfx:coin'),
    key: this.game.add.audio('sfx:key'),
    stomp: this.game.add.audio('sfx:stomp'),
    door: this.game.add.audio('sfx:door')
  };
  // create level entities and decoration
  this.game.add.image(0, 0, 'background');
  window.textObject1 = this.game.add.text(700, 5, window.text1, { font: 'Bold 200px Arial', fill: '#000000', fontSize: '20px' });
  window.textObject2 = this.game.add.text(700, 35, window.text2, { font: 'Bold 200px Arial', fill: '#000000', fontSize: '20px' });
  window.textObject3 = this.game.add.text(700, 65, window.text3, { font: 'Bold 200px Arial', fill: '#000000', fontSize: '20px' });
  if (window.globalLevelState === null) {
    window.globalLevelState = {
      time: 0,
      coinCache: this.game.cache.getJSON(`level:${this.level}`)
    };
  }
  this._loadLevel(window.globalLevelState.coinCache);
  // this._loadLevel(window.globalLevelState.value);
  // create UI score boards
  this._createHud();
},
}

In init(data)this.keys is set to be the command to detect which key on the keyboard has been pressed. In create, more variables are set and also make the screen fade in upon loading the web page by using the Phaser API call this.camera.flash('#000000');. The sound effect variables in the this.sfx object are then set. For this tutorial, all of the sound effects are commented out. You can enable sound effects for your own game by uncommenting the sound effects. The background image is also set by calling the command this.game.add.image(0, 0, 'background');.

Next, set the text objects that will be used to detect presence events in each room. There is an if statement that checks to see if the window.globalLevelState is equal to null. If it is equal, the coinCache is set equal to level:0 since you want the scene to load the first level if it doesn’t receive any information from PubNub. The _loadLevel function and _createHud functions are also called.

If you attempt to refresh and run the code, you will receive errors since you are calling functions that have yet to be created. Add some more functionality inside of window.playState and below the create function.

_loadLevel(data) {
  // console.log(data)
  // create all the groups/layers that we need
  this.bgDecoration = this.game.add.group();
  this.platforms = this.game.add.group();
  this.coins = this.game.add.group();
  // spawn hero and enemies
  this._spawnCharacters({ hero: data.hero, spiders: data.spiders });
  // spawn level decoration
  data.decoration.forEach(function (deco) {
    this.bgDecoration.add(
      this.game.add.image(deco.x, deco.y, 'decoration', deco.frame));
  }, this);
  // spawn platforms
  data.platforms.forEach(this._spawnPlatform, this);
  // spawn important objects
  data.coins.forEach(this._spawnCoin, this);
  this._spawnKey(data.key.x, data.key.y);
  this._spawnDoor(data.door.x, data.door.y);
  // enable gravity
  const GRAVITY = 1200;
  this.game.physics.arcade.gravity.y = GRAVITY;
},
_spawnPlatform(platform) {
  const sprite = this.platforms.create(platform.x, platform.y, platform.image);
  // physics for platform sprites
  this.game.physics.enable(sprite);
  sprite.body.allowGravity = false;
  sprite.body.immovable = true;
},
_spawnCoin(coin) {
  const sprite = this.coins.create(coin.x, coin.y, 'coin');
  sprite.anchor.set(0.5, 0.5);
  // physics (so we can detect overlap with the hero)
  this.game.physics.enable(sprite);
  sprite.body.allowGravity = false;
  // animations
  sprite.animations.add('rotate', [0, 1, 2, 1], 6, true); // 6fps, looped
  sprite.animations.play('rotate');
},
_addOtherCharacter(uuid) {
  // console.log('Added another character to game');
  if (window.globalOtherHeros.has(uuid)) { return; }
  // console.log('_addOtherCharacter', uuid);
  this.hero2 = new window.Hero(this.game, 10, 10);
  this.hero2.lastKeyFrame = 0;
  this.game.add.existing(this.hero2);
  window.globalOtherHeros.set(uuid, this.hero2);
},
_removeOtherCharacter(uuid) {
  if (!window.globalOtherHeros.has(uuid)) { return; }
  window.globalOtherHeros.get(uuid).destroy();
  window.globalOtherHeros.delete(uuid);
},
_spawnCharacters(data) {
  this.hero = new window.Hero(this.game, 10, 10);
  this.hero.body.bounce.setTo(0);
  const playerText = this.game.add.text(this.hero.position.x - 10, this.hero.position.y - 550, 'me', { fill: '#000000', fontSize: '15px' });
  playerText.anchor.set(0.5);
  this.hero.addChild(playerText);
  // console.log(playerText.position.x, playerText.position.y);
  window.globalMyHero = this.hero;
  window.globalOtherHeros = this.otherHeros = new Map();
  this.game.add.existing(this.hero);
  // globalMyHero.alpha = 1; //compensating for lag
  // window.sendKeyMessage({}); // UNCOMMENT LATER
},
_spawnKey(x, y) {
  this.key = this.bgDecoration.create(x, y, 'key');
  this.key.anchor.set(0.5, 0.5);
  // enable physics to detect collisions, so the hero can pick the key up
  this.game.physics.enable(this.key);
  this.key.body.allowGravity = false;
  // add a small 'up & down' animation via a tween
  this.key.y -= 3;
  this.game.add.tween(this.key)
    .to({ y: this.key.y + 6 }, 800, window.Phaser.Easing.Sinusoidal.InOut)
    .yoyo(true)
    .loop()
    .start();
},
_spawnDoor(x, y) {
  this.door = this.bgDecoration.create(x, y, 'door');
  this.door.anchor.setTo(0.5, 1);
  this.game.physics.enable(this.door);
  this.door.body.allowGravity = false;
},
_createHud() {
  const NUMBERS_STR = '0123456789X ';
  this.coinFont = this.game.add.retroFont('font:numbers', 20, 26, NUMBERS_STR, 6);
  this.keyIcon = this.game.make.image(0, 19, 'icon:key');
  this.keyIcon.anchor.set(0, 0.5);
  const coinIcon = this.game.make.image(this.keyIcon.width + 7, 0, 'icon:coin');
  const coinScoreImg = this.game.make.image(coinIcon.x + coinIcon.width, coinIcon.height / 2, this.coinFont);
  coinScoreImg.anchor.set(0, 0.5);
  this.hud = this.game.add.group();
  this.hud.add(coinIcon);
  this.hud.add(coinScoreImg);
  this.hud.add(this.keyIcon);
  this.hud.position.set(10, 10);
},

_loadLevel(data) creates asset groups that are needed later on in the code. The function spawns all of the level decorations (the mushrooms, grass, etc) from the JSON file information that was stored in the cache earlier in the code. Next, the animated coins, key, and door are spawned in the level along with setting and enabling gravity.

_spawnPlatform(platform) spawns each platform object and turns them into a sprite. They are set to not be affected by gravity and be immovable so other sprite objects can’t impact their position.

_spawnCoin(coin) creates each coin asset and places them on the screen and adds their animations.

_addOtherCharacter(uuid) adds a hero (another character) that is not your own to the screen when someone else connects to the same PubNub channel.

_spawnCharacters(data) spawns the hero asset into the game. The hero information is defined in heroScript.js which will be created shortly. playerText is set to appear above your player to differentiate between all of the connected players.

_spawnKey(x,y) creates the key that unlocks the door. A tween is applied to give the key the animation effect.

_spawnDoor(x,y) places the door in a set position. _createHud creates the overlay at the top left of the screen that checks to see if you have collected the key for that level and also how many coins you have obtained.

Keep in mind you will still get errors in the console since the heroScript.js code has not yet been initialized.

Hero Script

Open js/heroScript.js and add the following code.

'use strict';
// =============================================================================
// Create Player (Hero)
// =============================================================================
window.Hero = class Hero extends window.Phaser.Sprite {
  constructor(game) {
    super();
    window.Phaser.Sprite.call(this, game, 10, 523, 'hero');
    // anchor
    this.anchor.set(0.5, 0.5);
    // physics properties
    this.game.physics.enable(this);
    this.body.collideWorldBounds = true;
    // animations
    this.animations.add('stop', [0]);
    this.animations.add('run', [1, 2], 8, true); // 8fps looped
    this.animations.add('jump', [3]);
    this.animations.add('fall', [4]);
    // starting animation
    this.animations.play('stop');
  }
  move(direction) {
    // guard
    if (this.isFrozen) { return; }
    const SPEED = 200;
    this.body.velocity.x = direction * SPEED;
    // update image flipping & animations
    if (this.body.velocity.x < 0) {
      this.scale.x = -1;
    } else if (this.body.velocity.x > 0) {
      this.scale.x = 1;
    }
  }
  jump() {
    // Hero jumping code
    const JUMP_SPEED = 600;
    const canJump = this.body.touching.down && this.alive && !this.isFrozen;
    // console.log({
    //   canJump: canJump,
    //   'this.body.touching.down': this.body.touching.down,
    //   'this.alive': this.alive,
    //   'this.isFrozen': this.isFrozen
    // });
    if (canJump || this.isBoosting) {
      this.body.velocity.y = -JUMP_SPEED;
      this.isBoosting = true;
    }
    return canJump;
  }
  update() {
    // update sprite animation, if it needs changing
    const animationName = this._getAnimationName();
    if (this.animations.name !== animationName) {
      this.animations.play(animationName);
    }
  }
  freeze() { // When player goes through door do animation and remove player
    this.body.enable = false;
    this.isFrozen = true;
  }
  // returns the animation name that should be playing depending on
  // current circumstances
  _getAnimationName() {
    let name = 'stop'; // default animation
    if (this.isFrozen) {
      name = 'stop';
    } else if (this.body.velocity.y < 0) {
      name = 'jump';
    } else if (this.body.velocity.y >= 0 && !this.body.touching.down) {
      name = 'fall';
    } else if (this.body.velocity.x !== 0 && this.body.touching.down) {
      name = 'run';
    }
    return name;
  }
};

heroScript.js sets up the player animations and handles the player movement. In move(direction), the orientation the player is facing depends on the velocity of the player.

In jump, the properties to determine if the player can jump or not are also set.

In update, the sprite animation is updated only if it needs to be changed based on player input.

In freeze, the animation of the player going through the door is played.

In _getAnimationName, the various animation names are set depending upon the hero body’s velocity.

Save your document then refresh your browser window. You should see something such as this.

multiplayer game beginner tutorial 1024x505

Player Movement

Next, you need to implement the logic for player movement. Navigate back to js/playState.js. Below create, add the following update logic.

update() {
  window.frameCounter++;
  this._handleCollisions();
  this._handleInput();
  // update scoreboards
  this.coinFont.text = `x${this.coinPickupCount}`;
  this.keyIcon.frame = keyCollected ? 1 : 0;
},
shutdown() {
  // this.bgm.stop();
},
_canHeroEnterDoor(hero) {
  return keyCollected && hero.body.touching.down;
},_handleCollisions() {
   for (let i = 0; i < 2; i++) { // prevent collisions for pushing thru
     this.game.physics.arcade.collide(this.hero, this.platforms);
     for (const uuid of window.globalOtherHeros.keys()) {
       const otherplayer = window.globalOtherHeros.get(uuid);
       this.game.physics.arcade.collide(otherplayer, this.platforms, null, null, this);
       this.game.physics.arcade.overlap(otherplayer, this.coins, this._onHeroVsCoin, null, this);
       this.game.physics.arcade.overlap(otherplayer, this.key, this._onHeroVsKey, null, this);
       this.game.physics.arcade.overlap(otherplayer, this.door, this._onOtherHeroVsDoor, this._canHeroEnterDoor, this);
     }
     // hero vs coins (pick up)
     this.game.physics.arcade.overlap(this.hero, this.coins, this._onHeroVsCoin, null, this);
     // hero vs key (pick up)
     this.game.physics.arcade.overlap(this.hero, this.key, this._onHeroVsKey, null, this);
     // hero vs door (end level)
     this.game.physics.arcade.overlap(this.hero, this.door, this._onHeroVsDoor, this._canHeroEnterDoor, this);
     // ignore if there is no key or the player is on air
   }
 },
_handleInput() {
  // handleKeyMessages(); // UNCOMMENT LATER
  //  logCurrentState(this.game);
  if (this.hero) { // Added this so we can control spawning of heros
    if (this.keys.left.isDown) {
      if (!keyStates.leftIsDown) {
        // console.log('left pushed');
        // window.sendKeyMessage({ left: 'down' });  // UNCOMMENT LATER
      }
      keyStates.leftIsDown = true;
    } else {
      if (keyStates.leftIsDown) {
        // console.log('left un-pushed');
        // window.sendKeyMessage({ left: 'up' }); // UNCOMMENT LATER
      }
      keyStates.leftIsDown = false;
    }
    if (this.keys.right.isDown) {
      if (!keyStates.rightIsDown) {
        // console.log('right pushed');
        // window.sendKeyMessage({ right: 'down' }); // UNCOMMENT LATER
      }
      keyStates.rightIsDown = true;
    } else {
      if (keyStates.rightIsDown) {
        // console.log('right un-pushed');
        // window.sendKeyMessage({ right: 'up' }); // UNCOMMENT LATER
      }
      keyStates.rightIsDown = false;
    }
    if (this.hero.body.touching.down) {
      if (this.keys.up.isDown) {
        if (!keyStates.upIsDown) {
          // window.sendKeyMessage({ up: 'down' }); // UNCOMMENT LATER
          window.globalMyHero.jump();
        }
        keyStates.upIsDown = true;
      } else {
        if (keyStates.upIsDown) {
          // console.log('up un-pushed');
          // window.sendKeyMessage({ up: 'up' }); // UNCOMMENT LATER
        }
        keyStates.upIsDown = false;
      }
    }
    if (this.keys.left.isDown) { // move hero left
      this.hero.move(-1);
    } else if (this.keys.right.isDown) { // move hero right
      this.hero.move(1);
    } else { // stop
      this.hero.move(0);
    }
    // handle jump
    const JUMP_HOLD = 10;// 200; // ms
    if (this.keys.up.downDuration(JUMP_HOLD)) {
      // let didJump = this.hero.jump();
      // if (didJump) { this.sfx.jump.play();}
    }
    for (const uuid of window.globalOtherHeros.keys()) {
      const otherplayer = window.globalOtherHeros.get(uuid);
      if (Date.now() + JUMP_HOLD <= otherplayer.jumpStart) {
        // otherplayer.jump();
      }
      if (otherplayer.goingLeft) { // move hero left
        otherplayer.move(-1);
      } else if (otherplayer.goingRight) { // move hero right
        otherplayer.move(1);
      } else { // stop
        otherplayer.move(0);
      }
    }
  }
  if (window.globalWasHeroMoving && this.hero.body.velocity.x === 0 && this.hero.body.velocity.y === 0 && this.hero.body.touching.down) {
    // window.sendKeyMessage({ stopped: 'not moving' }); // UNCOMMENT LATER
    console.log('stopped');
    window.globalWasHeroMoving = false;
  } else if (window.globalWasHeroMoving || this.hero.body.velocity.x !== 0 || this.hero.body.velocity.y !== 0 || !this.hero.body.touching.down) {
    window.globalWasHeroMoving = true;
  }
},

update adds one to the frame count every frame. This is used to sync the player movements across all devices without the need to send PubNub publishes every frame. It also calls _handleInput() and _handleCollisions every frame.

shutdown is used to stop the background music from playing if you so wish to enable audio.

_canHeroEnterDoor(hero) checks to see if the key has been collected and if the hero is touching the platform object to be allowed to enter through the door.

_handleCollisions handles all of the scenes' object collision events. For instance, if the hero collides with a key, it will execute the this._onHeroVsKey function.

_handleInput runs every frame and checks to see if your hero object exists on the screen. If it does, it determines if any of the keys have been pressed down. If true, a message is sent that the button pressed is up. You'll notice that some code is commented out until the PubNub functionality is implemented - this will be updated later on to send key event messages through the PubNub network. The character moves by calling this.hero.move(-1)this.hero.move(1) or this.hero.move(0). This calls the move function that was implemented in heroScript.js. Save all of your documents and refresh the web browser. You should see the following screen and be able to move your character around using the left, right, and up arrows.

multiplayer game making players move 1 1024x536

Implement Object Collisions

After testing that you are able to perform basic move mechanics, you will add the functionality for object collision between players and the objects on the screen. Each function is executed when a specific collision event occurs. Add these functions below _handleInput in playState.js.

_onHeroVsKey(hero, key) {
  // this.sfx.key.play();
  this.door.frame = 1;
  key.kill();
  keyCollected = true;
  window.sendKeyMessage({ keyCollected });
},
_onHeroVsCoin(hero, coin) {
  // this.sfx.coin.play();
  coin.kill();
  logCurrentStateCoin(this.game, coin);
  this.coinPickupCount++;
},
_onHeroVsDoor(hero, door) {
    // 'open' the door by changing its graphic and playing a sfx
  door.frame = 1;
    // this.sfx.door.play();
    // play 'enter door' animation and change to the next level when it ends
  hero.freeze();
  this.game.add.tween(hero)
    .to({ x: this.door.x, alpha: 0 }, 0, null, true)
    .onComplete.addOnce(this._goToNextLevel, this);
},
_onOtherHeroVsDoor(hero, door) {
  // 'open' the door by changing its graphic and playing a sfx
  door.frame = 1;
  // this.sfx.door.play();
  // play 'enter door' animation and change to the next level when it ends
  hero.freeze();
  this.game.add.tween(hero)
    .to({ x: this.door.x, alpha: 0 }, 500, null, true);
},
_goToNextLevel() {
  this.camera.fade('#000000');
  this.camera.onFadeComplete.addOnce(function () {
    window.globalUnsubscribe();
    window.updateOccupancyCounter = false;
    if (this.level === 2) {
      window.createMyPubNub(0);
    } else {
      window.createMyPubNub(this.level + 1);
    }
  }, this);
},

If you refresh the window, you will get an error since logCurrentStateCoin has yet to be defined. Go to the top of playState.js and add the following right below the window.frameCounter variable.

function logCurrentStateCoin(game, coin) {
  // Log Current Game State of Collected Coins
  for (const value of window.globalLevelState.coinCache.coins) {
    if (coin.x === value.x) {
      window.globalLevelState.coinCache.coins.splice(window.globalLevelState.coinCache.coins.indexOf(value), 1);
      // console.log(value)
    }
  }
  window.fireCoins();
  // console.log(window.globalLevelState.coinCache.coins)
}

Although logCurrentStateCoin has been defined, an error will still be thrown since window.fireCoins has not yet been defined. Go ahead and comment out that line of code by using the // tags in front of the statement. Refresh the window and you should be able to move your player around and collect the coins without trouble. However, if you try to collect the key, you will get an error since sendKeyMessage has yet to be defined either. This will be handled shortly.

multiplayer game object collision tutorial 1024x562

Handle Messages

In playState.js, the handleKeyMessages function will be added below the logCurrentStateCoin function. This function handles all of the messages that get received by the client. Essentially this function is syncing all the clients so the movements are accurately displayed on the screen.

function handleKeyMessages() {
  const earlyMessages = [];
  const lateMessages = [];
  window.keyMessages.forEach((messageEvent) => {
    if (window.globalOtherHeros) { // If player exists
      if (messageEvent.channel === window.currentChannelName) { // If the messages channel is equal to your current channel
        if (!window.globalOtherHeros.has(messageEvent.message.uuid)) { // If the message isn't equal to your uuid
          window.globalGameState._addOtherCharacter(messageEvent.message.uuid); // Add another player to the game that is not yourself
          const otherplayer = window.globalOtherHeros.get(messageEvent.message.uuid);
          otherplayer.position.set(messageEvent.message.position.x, messageEvent.message.position.y); // set the position of each player according to x y
          otherplayer.initialRemoteFrame = messageEvent.message.frameCounter;
          otherplayer.initialLocalFrame = window.frameCounter;
          window.sendKeyMessage({}); // Send publish to all clients about user information
        }
        if (messageEvent.message.position && window.globalOtherHeros.has(messageEvent.message.uuid)) { // If the message contains the position of the player and the player has a uuid that matches with one in the level
          window.keyMessages.push(messageEvent);
          const otherplayer = window.globalOtherHeros.get(messageEvent.message.uuid);
          const frameDelta = messageEvent.message.frameCounter - otherplayer.lastKeyFrame;
          const initDelta = otherplayer.initialRemoteFrame - otherplayer.initialLocalFrame;
          const frameDelay = (messageEvent.message.frameCounter - window.frameCounter) - initDelta + window.syncOtherPlayerFrameDelay;
          /*console.log({
            lastKeyFrame: otherplayer.lastKeyFrame,
            frameCounter: messageEvent.message.frameCounter,
            frameDelta,
            rf_lf: otherplayer.initialRemoteFrame - otherplayer.initialLocalFrame,
            frameDelay
          });*/
          if (frameDelay > 0) {
            if (!messageEvent.hasOwnProperty('frameDelay')) {
              messageEvent.frameDelay = frameDelay;
              otherplayer.totalRecvedFrameDelay += frameDelay;
              otherplayer.totalRecvedFrames++;
            //console.log('avgFrameDelay', otherplayer.totalRecvedFrameDelay / otherplayer.totalRecvedFrames);
            }
            earlyMessages.push(messageEvent);
            //console.log('initDelta', initDelta, 'early', frameDelay);
            //console.log('early', frameDelay);
            return;
          } else if (messageEvent.message.keyMessage.stopped === 'not moving') {
            console.log('initDelta', initDelta, 'stopping player');
            otherplayer.body.position.set(messageEvent.message.position.x, messageEvent.message.position.y);
            otherplayer.body.velocity.set(0, 0);
            otherplayer.goingLeft = false;
            otherplayer.goingRight = false;
            if (otherplayer.totalRecvedFrames > 0) {
              const avgFrameDelay = otherplayer.totalRecvedFrameDelay / otherplayer.totalRecvedFrames;
              const floorFrameDelay = Math.floor(avgFrameDelay);
            //console.log('otherplayer.initialRemoteFrame before', otherplayer.initialRemoteFrame);
              otherplayer.initialRemoteFrame += floorFrameDelay - 7;
            //console.log('otherplayer.initialRemoteFrame after', otherplayer.initialRemoteFrame);
              console.log('avg frame delay', avgFrameDelay, 'adjusting delta', floorFrameDelay);
            }
            otherplayer.totalRecvedFrameDelay = 0;
            otherplayer.totalRecvedFrames = 0;
          } else if (frameDelay < 0) {
            otherplayer.totalRecvedFrameDelay += frameDelay;
            otherplayer.totalRecvedFrames++;
            lateMessages.push(messageEvent);
            console.log('initDelta', initDelta, 'late', frameDelay);
            return;
          } else {
          //console.log('initDelta', initDelta, 'ontime', frameDelay);
          }
          otherplayer.lastKeyFrame = messageEvent.message.frameCounter;
          if (messageEvent.message.keyMessage.up === 'down') { // If message equals arrow up, make the player jump with the correct UUID
            otherplayer.jump();
            otherplayer.jumpStart = Date.now();
          } else if (messageEvent.message.keyMessage.up === 'up') {
            otherplayer.jumpStart = 0;
          }
          if (messageEvent.message.keyMessage.left === 'down') { // If message equals arrow left, make the player move left with the correct UUID
            otherplayer.goingLeft = true;
          } else if (messageEvent.message.keyMessage.left === 'up') {
            otherplayer.goingLeft = false;
          }
          if (messageEvent.message.keyMessage.right === 'down') { // If message equals arrow down, make the player move right with the correct UUID
            otherplayer.goingRight = true;
          } else if (messageEvent.message.keyMessage.right === 'up') {
            otherplayer.goingRight = false;
          }
        }
      }
    }
  });
  if (lateMessages.length > 0) {
  //console.log({ lateMessages, earlyMessages });
  }
  window.keyMessages.length = 0;
  earlyMessages.forEach((em) => {
    window.keyMessages.push(em);
  });
}

This function handles all messages coming from other clients that are connected to the game. The function won’t properly work until the multiplayer components are added to the game, but still handles important behavior. The message data checks to see if the message is equal to the current channel the client is subscribed to. If the client receives a message from someone who is not in the game, a new player is created and their position is set. A message is sent to update all clients about this new player and their position. handleKeyMessages also checks frame count to make sure all clients are in sync. The messageEvent.message.keyMessage for the input events of all other users and will update the player state for all clients.

Implement Multiplayer

You will now be able to implement the multiplayer component of the game by adding PubNub functionality, which allows other players to join the game. In js/main.js, add the following PubNub initialization and event listener functionality after the global variables you and above the JavaScript files you loaded into the scene.

window.createMyPubNub = function (currentLevel) {
  // console.log('createMyPubNub', currentLevel);
  window.globalCurrentLevel = currentLevel; // Get the current level and set it to the global level
  window.currentFireChannelName = 'realtimephaserFire2';
  window.currentChannelName = `realtimephaser${currentLevel}`; // Create the channel name + the current level. This way each level is on its own channel.
  let checkIfJoined = false; // If player has joined the channel
  // Setup your PubNub Keys
  window.pubnub = new window.PubNub({
    publishKey: 'ADD-YOUR-PUBNUB-PUBKEY-HERE',
    subscribeKey: 'ADD-YOUR-PUBNUB-SUBKEY-HERE',
    uuid: window.UniqueID,
  });
  // Subscribe to the two PubNub Channels
  window.pubnub.subscribe({
    channels: [window.currentChannelName, window.currentFireChannelName],
    withPresence: true,
  });
// Create PubNub Listener for message events
window.listener = {
  status() {
    // Send fire event to connect to the block
    const requestIntMsg = { requestInt: true, currentLevel: window.globalCurrentLevel, uuid: window.UniqueID };
    window.pubnub.fire({
      message: requestIntMsg,
      channel: window.currentFireChannelName,
      sendByPost: false
    });
  },
  message(messageEvent) {
    if (messageEvent.message.uuid === window.UniqueID) {
      return; // this blocks drawing a new character set by the server, to lower latency
    }
    if (messageEvent.channel === window.currentFireChannelName) {
      window.globalLastTime = messageEvent.timetoken; // Set the timestamp for when you send fire messages to the block
      if (messageEvent.message.int === true && messageEvent.message.sendToRightPlayer === window.UniqueID) { // If you get a message and it matches with your UUID
        window.globalLevelState = messageEvent.message.value; // Set the globalLevelState to the information set on the block
        window.StartLoading(); // Call the game state start function in onLoad
      }
    }
    if (window.globalOtherHeros) { // If player exists
      if (messageEvent.channel === window.currentChannelName) { // If the messages channel is equal to your current channel
        if (!window.globalOtherHeros.has(messageEvent.message.uuid)) { // If the message isn't equal to your uuid
          window.globalGameState._addOtherCharacter(messageEvent.message.uuid); // Add another player to the game that is not yourself
          window.sendKeyMessage({}); // Send publish to all clients about user information
          const otherplayer = window.globalOtherHeros.get(messageEvent.message.uuid);
          otherplayer.position.set(messageEvent.message.position.x, messageEvent.message.position.y); // set the position of each player according to x y
          otherplayer.initialRemoteFrame = messageEvent.message.frameCounter;
          otherplayer.initialLocalFrame = window.frameCounter;
          otherplayer.totalRecvedFrameDelay = 0;
          otherplayer.totalRecvedFrames = 0;
        }
        if (messageEvent.message.position && window.globalOtherHeros.has(messageEvent.message.uuid)) { // If the message contains the position of the player and the player has a uuid that matches with one in the level
          window.keyMessages.push(messageEvent);
        }
      }
    }
  },
  presence(presenceEvent) { // PubNub on presence message / event
    let occupancyCounter;
    function checkFlag() {  // Function that reruns until response
      if (window.globalOtherHeros && checkIfJoined === true) { // If the globalother heros exists and if the player joined equals true
        clearInterval(occupancyCounter); // Destroy the timer for that scene
        window.updateOccupancyCounter = true; // Update the variable that stops the timer from running
        // Run PubNub HereNow function that controls the occupancy
        window.pubnub.hereNow(
          {
            includeUUIDs: true,
            includeState: true
          },
          (status, response) => {
            // If I get a valid response from the channel change the text objects to the correct occupancy count
            if (typeof (response.channels.realtimephaser0) !== 'undefined') {
              textResponse1 = response.channels.realtimephaser0.occupancy.toString();
            } else {
              textResponse1 = '0';
            }
            if (typeof (response.channels.realtimephaser1) !== 'undefined') {
              textResponse2 = response.channels.realtimephaser1.occupancy.toString();
            } else {
              textResponse2 = '0';
            }
            if (typeof (response.channels.realtimephaser2) !== 'undefined') {
              textResponse3 = response.channels.realtimephaser2.occupancy.toString();
            } else {
              textResponse3 = '0';
            }
            window.text1 = `Level 1 Occupancy: ${textResponse1}`;
            window.text2 = `Level 2 Occupancy: ${textResponse2}`;
            window.text3 = `Level 3 Occupancy: ${textResponse3}`;
            window.textObject1.setText(window.text1);
            window.textObject2.setText(window.text2);
            window.textObject3.setText(window.text3);
          }
        );
      }
    }
    if (window.updateOccupancyCounter === false) {
      occupancyCounter = setInterval(checkFlag, 200); // Start timer to run the checkflag function above
    }
    if (presenceEvent.action === 'join') { // If we receive a presence event that says a player joined the channel from the PubNub servers
      checkIfJoined = true;
      checkFlag();
      // text = presenceEvent.totalOccupancy.toString()
      if (presenceEvent.uuid !== window.UniqueID) {
        window.sendKeyMessage({}); // Send message of players location on screen
      }
    } else if (presenceEvent.action === 'leave' || presenceEvent.action === 'timeout') {
      checkFlag();
      try {
        window.globalGameState._removeOtherCharacter(presenceEvent.uuid); // Remove character on leave events if the individual exists
      } catch (err) {
        // console.log(err)
      }
    }
  }
};
  // If person leaves or refreshes the window, run the unsubscribe function
  window.addEventListener('beforeunload', () => {
    navigator.sendBeacon(`https://pubsub.pubnub.com/v2/presence/sub_key/mySubKey/channel/ch1/leave?uuid=${window.UniqueID}`); // pub
    window.globalUnsubscribe();
  });
  // Unsubscribe people from PubNub network
  window.globalUnsubscribe = function () {
    try {
      // console.log('unsubscribing', window.currentChannelName);
      window.pubnub.unsubscribe({
        channels: [window.currentChannelName, window.currentFireChannelName],
        withPresence: true
      });
      window.pubnub.removeListener(window.listener);
    } catch (err) {
      // console.log("Failed to UnSub");
    }
  };
  window.pubnub.addListener(window.listener);
};

window.createMyPubNub initializes variables and channel names that PubNub is going to use for network communication. window.currentChannelName is set to equal the user's current level. The PubNub keys are set up and the subscribed channels are specified. An event listener is added that detects when the browser is unloaded, and a message is sent that a user has left the channel so the presence event updates for all other clients. The globalUnsubscribe function removes the listener for the client and unsubscribes the client from the channel.

The window.listener event listener is listening for events every frame but will run on the initial connection status to PubNub, when a message is sent on the channel, or when a presence change occurs.

In the status(status) callback, messages are sent with request-level information from the KV store. You can learn more about the KV store in PubNub's Functions feature.

In the message(messageEvent) callback, the message channel name is checked to see if it is equal to the current fire channel name. If true, window.StartLoading is called to load the game. Then, if the message channel is equal to the window.currentChannelName, another player is added to the game and its position is set in the correct location based on the message data.

In the presence(presenceEvent) callback, if a player joins, leaves, or timeouts the channel, then a call to hereNow is made that checks to see how many people are in the channel and outputs the current occupancy along with the UUIDs in the channel.

Below the window.listener event listener that was just added but above the load external JS files code, implement two functions window.sendKeyMessage and window.fireCoins. window.sendKeyMessage will send messages out to all clients connected to the channel. The message will contain player UUID information, position, and frame count. window.fireCoins will send a message to PubNub Functions to inform the function of the current cache state of the user.

window.sendKeyMessage = (keyMessage) => {
  try {
    if (window.globalMyHero) {
      window.pubnub.publish({
        message: {
          uuid: window.UniqueID,
          keyMessage,
          position: window.globalMyHero.body.position,
          frameCounter: window.frameCounter
        },
        channel: window.currentChannelName,
        sendByPost: false, // true to send via posts
      });
    }
      // console.log("send message!")
  } catch (err) {
    console.log(err);
  }
};
window.fireCoins = () => {
  const message = {
    uuid: window.UniqueID,
    coinCache: window.globalLevelState.coinCache,
    currentLevel: window.globalCurrentLevel,
    time: window.globalLastTime
  };
  // console.log('fireCoins', message);
  window.pubnub.fire(
    {
      message,
      channel: window.currentFireChannelName,
      sendByPost: false, // true to send via posts
    });
};

Update Functionality & Run

You'll need to go back and uncomment lines of code in other files that allow usage of the recently added functions. At the bottom of main.js, uncomment the following code where the event listener loads the scene.

window.createMyPubNub(0); // Connect to the PubNub network and run level code 0
window.StartLoading = function () {
    game.state.start('loading'); // Run the loading function once you successfully connect to the PubNub network
};

Navigate to playState.js uncomment window.fireCoins(); in the logCurrentStateCoin() function. Go down to _handleInput and uncomment the following.

handleKeyMessages();
...
window.sendKeyMessage({ left: 'down' });
...
window.sendKeyMessage({ left: 'up' });
...
window.sendKeyMessage({ right: 'down' });
...
window.sendKeyMessage({ right: 'up' });
...
window.sendKeyMessage({ up: 'down' });
...
window.sendKeyMessage({ up: 'up' });
...
window.sendKeyMessage({ stopped: 'not moving' });

Finally, in _spawnCharacters, uncomment window.sendKeyMessage({});.

Save your files and refresh your window. If you open up two separate windows of the game, you should be able to move your character on one window and see the character move on the other window with low latency.

PubNub Functions to Manage Game State

You will notice that if you collect a coin in one window, there will be coins missing on the other player's screen. This functionality is managed by PubNub's Functions feature, which enables you to capture events that are happening on the PubNub Platform.

Navigate back to your PubNub Account and select the application that is powering the game. On the left-hand side of the dashboard, click the Functions box. Click the Create Module button and name it whatever you wish. Then create a new Function and call it what you would like as well. Make sure you select Before Publish or Fire and the channel name is realtimephaserFire2.

Copy the following code into the portal, where you can enter your own JS functionality.

export default (request) => { 
    const pubnub = require('pubnub');
    const db = require('kvstore');
    const xhr = require('xhr');
    const keyName = "gamestate2_" + request.message.currentLevel;
    if(request.message.int || request.message.fromServer) {
        return request.ok(); // Return a promise when you're done 
    }
    if(request.message.requestInt){
      db.get(keyName).then((value) => {
            pubnub.publish({
                "channel": "realtimephaserFire2",
                "message": {
                    value: value,
                    int: true,
                    sendToRightPlayer: request.message.uuid
                }
            }).then((publishResponse) => {
                //console.log(publishResponse);
            });
        });
        return request.ok(); // Return a promise when you're done
    }
    console.log("spitout", request.message)
    pubnub.time().then((timetoken) => {
        db.get(keyName).then((value) => {
            if(value === null || value.time < request.message.time || true) {
                value = {time: timetoken, coinCache: request.message.coinCache};
                db.set(keyName, value, 1);
                 console.log('set', keyName, value);
            }
            pubnub.publish({
                "channel": "realtimephaserFire2",
                "message": {value: value, fromServer: true}
            }).then((publishResponse) => {
                //console.log(publishResponse);
            });
        });
    });
    return request.ok(); // Return a promise when you're done 
};

The information saved and processed is the contents of the JSON-level information that was sent earlier. The function essentially determines that if JSON information exists in the Function, publish that information to the newly connected user. If there is no information, use the local JSON information for that client. The only time the JSON updates in the PubNub Function is when a coin is collected in the scene by any player.

Now click the + button in the PubNub Functions dashboard and create a new function and name it onLeave except call it only After Presence and on the channel realtimephaserFire2. Add the following code to the function.

export default (request) => { 
    const pubnub = require('pubnub');
    const db = require('kvstore');
    const xhr = require('xhr');
    if(request.message.occupancy === 0){ 
        for(var currentLevel = 0; currentLevel < 100; currentLevel++) {
            const keyName = "gamestate2_" + currentLevel;
            db.removeItem(keyName);
        }
    }
    return request.ok(); // Return a promise when you're done 
}

This PubNub Function will only run when someone joins, leaves, or timeouts a channel. If the total occupancy of the game equals zero, it resets the current level cache to nothing so the coins will appear for anyone new that joins the game.

What's Next

You've successfully created your own browser-based, multiplayer platformer game with PubNub! Phaser, HTML, CSS, and JavaScript were used to build, style, and operate the game itself, while PubNub is used to power the online social component of the game that allows players to see and interact with one another.

If you would like to learn more about how PubNub powers games, take a look at our growing collection of updates, demos, tutorials, and documentation.

1. See how PubNub can help you attract and retain players with in-app chat, live -leaderboard updates, and push notifications.

2. Explore PubNub's Unity Developer Path, a guided journey in how you can use PubNub's Unity SDK to build real-time games with ease.

3. Play the Unity demo PubNub Prix, a racing game where you can chat with other players in and view leaderboard updates in real time.

4. Learn about how PubNub can help your game in the how-to guides.

5. Follow a step-by-step tutorial to set up and build the Unity game PubNub Prix.

6. Dive into our documentation for the PubNub Unity SDK, to learn how to customize PubNub's features that uniquely fit your application.

If you have any other questions or concerns, please feel free to reach out to devrel@pubnub.com.

More from PubNub

How to Add a Notification Badge to Icons in React Native
Real-time Chat BlogDec 19, 20226 min read

How to Add a Notification Badge to Icons in React Native

Display real-time notification badges with PubNub and React Native to display important information and bring users back to your...

Michael Carroll

Michael Carroll

Digital Twins and the Future of Real-Time Data
InsightsDec 6, 20224 min read

Digital Twins and the Future of Real-Time Data

The concept of Digital Twins has evolved over the last two decades, however, one thing remains the same: the need for real-time...

Michael Carroll

Michael Carroll

How Many Text Characters Fit in a 32KB PubNub Message?
Real-time Chat BlogNov 24, 20224 min read

How Many Text Characters Fit in a 32KB PubNub Message?

Learn the ins-and-outs of PubNub message size and get a better idea of how many text characters fit in a single message.

Michael Carroll

Michael Carroll