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.
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.
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.
<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.
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.
Controller page
<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).
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.
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.
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:
const dsClient = deepstream('localhost:6020').login()
Add the record subscriptions to the `Runner.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:
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:
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 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:
Starting and stopping the 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:
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.
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.
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:
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:
and add the new function to the `Runner` object. We can also reset the
online status for the players if the game is over:
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:
if ('vibrate' in navigator) {
if (data.lastGoal) {
navigator.vibrate([100, 300, 100, 300, 100])
} else {
navigator.vibrate(100)
}
}
})
Use accelerometer to control the paddle
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:
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:
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:
this.setPaddlePosition(paddle, data.position)
}
and add the setPaddlePosition to the Pong object:
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.