Building pong game using deepsteam.io

Introduction

This quick and simple tutorial will explain how to turn an existing browser game into a real-time multiplayer game using deepstream.io and just a few lines of code – you’ll also learn how to use your mobile device as a controller.

This post is guest post by the deepstream.io project team.

Choosing a game

There is a nice Pong implementation for browsers at Github.

It’s written as a tutorial with 5 parts.

However, the multiplayer mode is limited to a shared keyboard.

Ping pong game

Let’s improve on that by adding these features:

1: allow users to play the game using their mobile device.
2: allow controls (start and stop) from the device.
3: allow play on touch screens.

Game architecture

By keeping all the original logic in the browser, it can double as our game server, allowing you to keep track of the score and game status.

Each player opens the second page on their mobile to control the pong paddle.

The players’ data (control input and status) is synced in real-time between the pages via deepstream.

Generating a QR code

Players can easily join the game using a QR code. We’ll integrate a code for each player into the game’s sidebar.

index.html
<div class="sidebar sidebar-right">
  <h2>Player 2</h2>
  <a target="_blank" href="/controls.html#2" rel="noopener noreferrer">
    <div class="qrcode" id="qrcode2"></div>
  </a>
  <div class="online online-2">online</div>
</div>
<div class="sidebar">
  <h2>Player 1</h2>
  <a target="_blank" href="/controls.html#1" rel="noopener noreferrer">
    <div class="qrcode" id="qrcode1"></div>
  </a>
  <div class="online online-1">online</div>
</div>

To create the QR code we’ll use the npm module qrcodejs2 which can generate the code in the browser.

src/bootstrap.js
const options = {
  width: 128,
  height: 128,
  colorDark : "#000000",
  colorLight : "#ffffff",
  correctLevel : QRCode.CorrectLevel.H
}
new QRCode(document.getElementById("qrcode1"), Object.assign({
  text: window.location.origin + '/controls.html#1'
}, options))
new QRCode(document.getElementById("qrcode2"), Object.assign({
  text: window.location.origin + '/controls.html#2'
}, options))

Here is the output for same.
Pong multiplayer game

Controller page

controls.html
<!DOCTYPE html>
<html>
  <head>
    <title>pong controller</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <meta name="viewport" content="width=device-width, user-scalable=no">
    <link href="style/controls.css" media="screen, print" rel="stylesheet" type="text/css" />
  </head>
  <body>
    <div class="gamepad-container">
      <button class="gamepad gamepad-down"></button>
      <button class="gamepad gamepad-up"></button>
    </div>
    <script src="node_modules/deepstream.io-client-js/dist/deepstream.js"></script>
    <script src="src/controls/index.js" type="text/javascript"></script>
  </body>
</html>

As soon as the controller page is loaded the browser connects to the deepstream server and initializes a record for the player. This record contains the player’s name (for simplicity, players are just named 1 and 2) and a property indicates if the player is pressing a button (‘up’ or ‘down’) or if the button is not pressed (null).

src/controller/index.js
const deepstream = window.deepstream
const player = window.location.hash.substr(1) || 1

// ignore authentication in this tutorial
const ds = deepstream('localhost:6020').login({}, function() {
  if (success) {
    return new Gamepad()
  }
})

The _Gamepad_ class registers event listeners for both touch devices and mice.

src/controller/index.js
class Gamepad {
  constructor() {
    const buttons = document.querySelectorAll('.gamepad')
    this.initializeRecords('player/' + player)
    // up
    this.addEventListener(buttons[0], ['touchstart', 'mousedown'], this.onButtonPress)
    this.addEventListener(buttons[0], ['mouseup', 'touchend'], this.onButtonRelease)
    // down
    this.addEventListener(buttons[1], ['touchstart', 'mousedown'], this.onButtonPress)
    this.addEventListener(buttons[1], ['mouseup', 'touchend'], this.onButtonRelease)
  }

  addEventListener(element, types, handler) {
    element.addEventListener(type, handler.bind(this))
    for (let i=0; i<types.length; i++) {
      element.addEventListener(types[i], handler.bind(this))
    }
  }

  initializeRecords(playerRecordName) {
    this.record = ds.record.getRecord(playerRecordName)
    this.record.set({
      name: player,
      direction: null
    })
  }

  onButtonPress(event) {
    event.preventDefault()
    const target = event.target
    const up = target.classList.contains('gamepad-up')
    const down = target.classList.contains('gamepad-down')
    let direction
    if (up) {
      direction = 'up'
    } else if (down) {
      direction = 'down'
    } else {
      direction = null
    }
    this.updateDirection(direction)
  }

  updateDirection(direction) {
    this.record.set('direction', direction)
  }

  onButtonRelease() {
    this.record.set('direction', null)
  }
}

Here is the output for same.
simple game controller

Wiring up our gamepads to the main game

Let’s add some code to subscribe to the record’s changes on the main
page in order to update the paddle.

First, we need to connect to the deepstream server like we did on
the controller page:

src/game.js
const deepstream = require('deepstream.io-client-js')
const dsClient = deepstream('localhost:6020').login()

Add the record subscriptions to the `Runner.addEvents` function

src/game.js
addEvents: function() {
  Game.addEvent(document, 'keydown', this.onkeydown.bind(this))
  Game.addEvent(document, 'keyup',   this.onkeyup.bind(this))

  const player1 = dsClient.record.getRecord('player/1')
  const player2 = dsClient.record.getRecord('player/2')
  player1.subscribe(data => {
    this.game.updatePlayer(1, data)
  })
  player2.subscribe(data => {
    this.game.updatePlayer(2, data)
  })
},

Then add these two functions to the `Pong` object:

src/pong.js
 updatePlayer: function(player, data) {
    if (player == 1) {
      this.updatePaddle(this.leftPaddle, data)
    } else if (player == 2) {
      this.updatePaddle(this.rightPaddle, data)
    }
  },

  updatePaddle: function(paddle, data) {
    const direction = data.direction
    if (!paddle.auto) {
      if (direction === 'up') {
        paddle.moveUp();
      } else if (direction === 'down') {
        paddle.moveDown();
      } else if (direction === null) {
        paddle.stopMovingUp()
        paddle.stopMovingDown()
      }
    }
  },

That’s it! Simple. We’ve converted the game into a real-time multiplayer game!

Wait, how can I play it?

You need to install deepstream and run the server. For this tutorial, you don’t need any special configuration, so just go start the server. Do it now.

To bundle the JavaScript and run an HTTP server you can use the npm start script:

# ensure that deepstream server is running
npm start

How can I join the game from another device?

Since we used localhost to connect to the deepstream server the game will only work on the same machine, but you can use this same software to access the game.

in other words: We can open the controls page in
another browser but not on the smartphone or another computer.

We need to change the deepstream host in src/controls/index.js to this:

const DEEPSTREAM_HOST = window.location.hostname + ':6020'
const ds = deepstream(DEEPSTREAM_HOST).login({}, function(success) {
  //...
})

If you want to play using a WiFi network you need to find out your WiFi IP address and replace localhost with your IP in the browser of the main page to something like this:

http://192.168.1.10:9966

Starting and stopping the game

Leave controllers game

To improve the UX we’ll add another record which contains the current status
of the game, e.g. if there is a winner and which players are currently online.

With that information, the players can start and stop the game.

Let’s add a join/leave button to the controller page:

controls.html
<button class="join-leave">join</button>

And add handlers to the Gamepad class to toggle the state between online and offline.
We use another record (status) with a property for each player: player1-online and player2-online.

src/controls/index.js
class Gamepad {
  constructor() {
    //...
    this.joinButton = document.querySelector('.join-leave')
    this.addEventListener(this.joinButton, ['click'], this.startStopGameHandler)
  }

  initializeRecords(playerRecordName) {
    //...
    const statusRecord = ds.record.getRecord('status')
    statusRecord.subscribe(`player${player}-online`, online => {
      if (online === true) {
        document.body.style.background ='#ccc'
        this.joinButton.textContent = 'leave'
      } else {
        document.body.style.background ='white'
        this.joinButton.textContent = 'join'
      }
    }, true)
  }

  startStopGameHandler(e) {
    ds.record.getRecord('status').whenReady(statusRecord => {
      const oldValue = statusRecord.get(`player${player}-online`)
      statusRecord.set(`player${player}-online`, !oldValue)
    })
  }
}

In the Runner.addEvents the function we can now listen to the status record and toggle the online indicator. To start the game both players need to join the game.

src/game.js
const status = dsClient.record.getRecord('status')
status.subscribe('player1-online', online => {
  this.toggleChecked('.online-1', online)
  this.updateGameStatus(online, status.get('player2-online'))
})
status.subscribe('player2-online', online => {
  this.toggleChecked('.online-2', online)
  this.updateGameStatus(status.get('player1-online'), online)
})

and add a new function to the Runner object:

src/game.js
updateGameStatus: function(player1, player2) {
  if (player1 && player2) {
    this.game.stop()
    this.game.startDoublePlayer()
  } else {
    this.game.stop()
  }
}

Send feedback to the player

Let’s give the user some feedback if you did a goal and if he wins a match.
The status record can be reused with another property: `player1-goals` and `player2-goals`.
We can trigger a function from the `Pong.goal` function:

src/pong.js
this.runner.notifyGoal(playerNo, this.scores[playerNo], lastGoal);

and add the new function to the `Runner` object. We can also reset the
online status for the players if the game is over:

src/game.js
notifyGoal: function(playerNo, goals, lastGoal) {
  const statusRecord = dsClient.record.getRecord('status')
  if (lastGoal) {
    statusRecord.set('player1-online', false)
    statusRecord.set('player2-online', false)
  }
  statusRecord.set(`player${playerNo+1}-goals`, {amount: goals, lastGoal: lastGoal})
},

Now we need to listen to the goals property in the Gamepad class within the initializeRecords function:

src/controls/index.js
statusRecord.subscribe(`player${player}-goals`, data => {
  if ('vibrate' in navigator) {
    if (data.lastGoal) {
      navigator.vibrate([100, 300, 100, 300, 100])
    } else {
      navigator.vibrate(100)
    }
  }
})

Use accelerometer to control the paddle

Accelerometer

Using the buttons on a touch device feels a bit sluggish, right?. So let’s improve it by using the
accelerometer instead. To simplify the code we replace all the code for the buttons on the controller.

We replace the buttons with an accelerometer indicator:

controls.html
<div class="accelerometer-indicator"></div>

In the Gamepad class, we attach a handler for the DeviceMotionEvent event. This event provides several properties, but we need just the
accelerationIncludingGravity.y value. The value range is from -10+10.
To get a percentage we can use this formula:

vPercent = 1 - (vAbs/20) - (1/2)
src/controls/index.js
  constructor() {
    this.indicator = document.querySelector('.accelerometer-indicator')
    if (window.DeviceMotionEvent != null) {
      window.addEventListener('devicemotion', this.listenOnMotion.bind(this))
    }
  }

  listenOnMotion(e) {
    const value = e.accelerationIncludingGravity.y
    const percentage = 1 - ((value / 10) - 1) / 2;
    const margin = Math.round(percentage * window.innerHeight - this.indicator.style.height)
    this.indicator.style['margin-top'] = margin + 'px'
    this.record.set('position', percentage)
  }

For the main page we need to adjust the condition within the Pong.updatePaddle function:

src/pong.js
if (data.position != null) {
  this.setPaddlePosition(paddle, data.position)
}

and add the setPaddlePosition to the Pong object:

src/pong.js
setPaddlePosition: function(paddle, percentage) {
  const height = defaults.height - defaults.paddleHeight - defaults.wallWidth
  const absolute = Math.round(percentage * height)
  paddle.setpos(paddle.x, absolute)
},

Finished!
Now we implemented all three features which are mentioned at the beginning of this tutorial. Boom.

Conclusion

We learned how we can modify the existing code to avail new feature in it using Deepstream.io. You can download the code from here and run it in your system.

Shahid
Shahid

Founder of Codeforgeek. Technologist. Published Author. Engineer. Content Creator. Teaching Everything I learn!

Articles: 126