Real-Time Data Transfer Using Vue and Socket.IO [Part 3 of 3]
In the first post we covered setting up a simple server and client which communicate with each other using websockets. The second post focused on the creation of a chat room. This third and final post in the series will build upon the techniques that we covered in the previous posts to create a simple multiplayer game “Fill Frenzy!”.
Add Real-Time Game
The second of the main components we will cover will be the game. The game will allow multiple users to interact with the game board, with the ultimate goal being to fill in every tile on the game board while the server clears one of the tiles on a set interval. This step will follow much the same pattern as the chat component, but as it will also be lengthy and touch multiple files, I will break it down into manageable sections.
- We will start by implementing the game server. Create
server/game.js
and add the following code. - Update
server/index.js
to include our new functionality. - Next we will implement the game client. Create
src/store/game.js
and add the following code. - Update
src/store/index.js
with the following code. - Create
src/components/Game.vue
and add the following code. - Update
src/components/RealTimeDemo.vue
with the following code.
let socketio = undefined; let allTilesActive = false; let deactivationTime = 1000; let activeTiles = {};
Firstly, we create a variable socketio
to use as a reference to our main Socket.IO object, and variables to keep track of the state of the tiles in the game.
const difficultySettings = { easy: { name: 'Easy', gridSize: 5, tileSize: 128 }, medium: { name: 'Medium', gridSize: 10, tileSize: 64 }, hard: { name: 'Hard', gridSize: 20, tileSize: 32 } }; let gameDifficulty = difficultySettings.easy;
Secondly, we add some difficulty settings so that we can make the game challenging when we want, but also give us a nice easy mode to test our code with.
const initialise = (io) => { socketio = io; setInterval(() => { deactivateTile(); }, deactivationTime); }
Next we create an initialise function which we can use to set our socketio
reference. We also start a repeating timer to call the deactivateTile
function on a set interval.
const run = (socket) => { // new socket connected, send active tiles and game difficulty socket.emit('activeTiles', activeTiles); socket.emit('setDifficultyLevels', difficultySettings); socket.emit('gameDifficulty', gameDifficulty); if (allTilesActive) { // game is already completed, notify new connection socket.emit('gameCompleted'); } handleActivateTile(socket); handleAllTilesActive(socket); handleResetGame(socket); }
The run
function will handle the main functionality for the game for each individual client socket which is passed to the function as an argument. The function will be called only once per socket when a new connection is made, so we will take advantage of this to send the current state of the app to the socket by emitting the event activeTiles
which passes the corresponding information.
const handleActivateTile = (socket) => { // listen for 'activateTile' events socket.on('activateTile', (tile) => { // attach a tile id const key = `${tile.x},${tile.y}`; tile.id = key; // add the tile to the active tile collection if (!activeTiles.hasOwnProperty(key)) { activeTiles[key] = tile; // send the activated tile to all connected sockets socketio.emit('activateTile', tile); } }); }
The handleActivateTile
function listens for the activateTile
event, and will respond appropriately. We receive a tile from the client, add an ID and save it to our activeTiles
collection, and then update all clients with the new information.
const handleAllTilesActive = (socket) => { // listen for 'allTilesActive' events socket.on('allTilesActive', (tileCount) => { if (allTilesActive) { return; } // count the number of active tiles const totalTiles = Object.keys(activeTiles).length; // compare the count of active tiles to the tile count received if (totalTiles === tileCount) { // complete the game socketio.emit('gameCompleted'); allTilesActive = true; } }); }
The handleAllTilesActive
function listens for the allTilesActive
event, and will respond appropriately. We receive a count of the maximum number of tiles from the client, and then compare it to the number of tiles that we have tracked as active on the server. If the counts match we then send the gameCompleted
event to all clients.
const handleResetGame = (socket) => { // listen for 'resetGame' events socket.on('resetGame', (difficulty) => { resetGame(difficulty); }); }
The handleResetGame
function listens for the resetGame
event, and will also take a difficulty
setting to use for the next game.
const randomTile = () => { // find existing keys let keys = Object.keys(activeTiles); // get a random key const randomKey = keys[(keys.length * Math.random()) << 0]; // return the random tile return activeTiles[randomKey]; }
The randomTile
function picks a random tile to return from the activeTiles
collection.
const deactivateTile = () => { if (allTilesActive) { return; } // select a random tile let tile = randomTile(); if (tile) { // deactivate the tile socketio.emit('deactivateTile', tile.id); delete activeTiles[tile.id]; } }
The deactivateTile
function is called on a set interval, and will select a random tile from the activeTiles
collection to deactivate. It sends the deactivateTile
event to all clients and specifies which tile id has been deactivated.
const resetGame = (difficulty) => { // reset all tiles allTilesActive = false; activeTiles = {}; // set game difficulty gameDifficulty = difficultySettings[difficulty.toLowerCase()]; socketio.emit('gameDifficulty', gameDifficulty); socketio.emit('resetGame'); } // export these functions for external use module.exports = { initialise, run };
The resetGame
function resets the game state back to default settings, and sends the resetGame
event to all clients.
const express = require('express'); const http = require('http').Server(express); const socketio = require('socket.io')(http, { pingTimeout: 60000 }); const chat = require('./chat'); const game = require('./game'); const port = 3030; chat.initialise(socketio); game.initialise(socketio); socketio.on('connection', (socket) => { // new socket connected chat.run(socket); game.run(socket); }); http.listen(port, () => { console.log('Server started on port', port); });
After including the game.js
file we created, we pass the socketio
instance to the initialise
function. Then, when the connection
event triggers we pass the new socket
connection through to the run
function.
import Vue from 'vue'; export default { strict: false, namespaced: true, state: () => ({ tiles: {} }), getters: { GET_TILES: (state) => { return state.tiles; } }, mutations: { SET_TILES(state, tiles) { state.tiles = tiles; }, ADD_TILE(state, tile) { Vue.set(state.tiles, tile.id, tile); }, REMOVE_TILE(state, tileID) { Vue.delete(state.tiles, tileID); } }, actions: { RECEIVE_TILES({ commit }, tiles) { commit('SET_TILES', tiles); }, ACTIVATE_TILE({ commit }, tile) { commit('ADD_TILE', tile); }, DEACTIVATE_TILE({ commit }, tileID) { commit('REMOVE_TILE', tileID); } } };
The game store is similar to the chat store, with two main differences. There is only one set of data contained within the store, and we have added functionality to remove data.
import Vue from 'vue'; import Vuex from 'vuex'; import * as socketio from '../plugins/socketio'; import chat from './chat'; import game from './game'; Vue.use(Vuex); export default new Vuex.Store({ strict: false, actions: { SEND_EVENT({}, event) { socketio.sendEvent(event); } }, modules: { chat, game } });
Loading our new file into the store as a module allows us to maintain good object separation and the usage of namespaces to access particular parts of the store.
<template> <div> <v-card-title> Move the mouse to fill in the whole game board </v-card-title> <v-card-text> It's not impossible, but friends make it a lot easier! </v-card-text>
First we add some instructions to the game.
<v-card-text v-if="gameLoaded" class="game"> <div class="d-flex pa-0 grey lighten-2 board"> <div v-for="x in gameDifficulty.gridSize" :key="x"> <div v-for="y in gameDifficulty.gridSize" :key="y"> <div :class="getTileClass(x, y)" :style="tileStyle" @mouseenter="activateTile(x, y)" ></div> </div> </div> </div> <div v-if="isCompleted" class="text-h3 white--text text-center overlay" > Congratulations! </div> </v-card-text>
Next we build the game board, of which the bulk is taken care of using the v-for
directives to loop over both the gameDifficulty.gridsize
for x
and y
. The function getTileClass
, as well as the computed property tileStyle
are being used as tidy ways of setting styling within the template.
<v-card-title v-if="isCompleted"> <v-col cols="3"> <v-select v-model="selectedDifficulty" :items="difficultyLevelOptions" ></v-select> </v-col> <v-col cols="3"> <v-btn @click="resetGame(selectedDifficulty)">Reset</v-btn> </v-col> </v-card-title> <v-card-title v-else> <div v-if="gameLoaded">{{ percentCompleted }}% completed</div> </v-card-title> </div> </template>
The last part of the template adds a difficulty setting dropdown and a reset button if the game isCompleted
. We also add a handy little tracker that presents the percentage of game completion. The usage of the v-if
and v-else
directives means that only one of these components is rendered at a time.
<script> import { mapGetters } from 'vuex'; import { addEventListener } from '../mixins/addEventListener'; export default { name: 'Game', mixins: [addEventListener], data: () => ({ isCompleted: false, deleteTileID: undefined, difficultyLevels: {}, gameDifficulty: undefined, selectedDifficulty: undefined gameLoaded: false }),
The imports follow the same logic as the chat component, and we have some game specific variables set within the data()
property.
computed: { ...mapGetters({ ACTIVE_TILES: 'game/GET_TILES' }), activeTiles() { return this.ACTIVE_TILES ? this.ACTIVE_TILES : {}; }, tileStyle() { const width = `width: ${this.gameDifficulty.tileSize}px;`; const height = `height: ${this.gameDifficulty.tileSize}px;`; return `${width} ${height}`; }, percentCompleted() { if (!this.gameLoaded) return 0; const numTiles = this.gameDifficulty.gridSize * this.gameDifficulty.gridSize; const numActive = Object.keys(this.activeTiles).length; return Math.floor((numActive / numTiles) * 100); }, difficultyLevelOptions() { return Object.values(this.difficultyLevels) .map(difficulty => difficulty.name); } },
As the number of active tiles is variable, we’ve added our percentCompleted
calculations as a computed
property so that it is recalculated when needed. This helps keep our game responsive.
mounted() { this.addEventListener('activeTiles', (activeTiles) => { this.$store.dispatch('game/RECEIVE_TILES', activeTiles); }); this.addEventListener('setDifficultyLevels', (difficultyLevels) => { this.difficultyLevels = difficultyLevels; } this.addEventListener('gameDifficulty', (gameDifficulty) => { this.gameDifficulty = gameDifficulty; this.selectedDifficulty = gameDifficulty.name; } this.addEventListener('activateTile', (tile) => { this.$store.dispatch('game/ACTIVATE_TILE', tile); }); this.addEventListener('deactivateTile', (tileID) => { this.deleteTileID = tileID; setTimeout(() => { this.deleteTileID = undefined; },125); this.$store.dispatch('game/DEACTIVATE_TILE', tileID); }); this.addEventListener('gameCompleted', () => { this.isCompleted = true; }); this.addEventListener('resetGame', () => { this.$store.dispatch('game/RECEIVE_TILES', {}); this.isCompleted = false; }); },
The above code adds event listeners for the Game component. The gameCompleted
event is the last in a long chain of events, so it has no need to communicate through the store to the server.
methods: { activateTile(x, y) { this.$store.dispatch('SEND_EVENT', { type: 'activateTile', data: { x: x, y: y } }); }, isActive(x, y) { return this.activeTiles.hasOwnProperty(`${x},${y}`); }, isDeleting(x, y) { return this.deleteTileID === `${x},${y}`; }, getTileClass(x, y) { let tileClass = 'tile'; if (this.isCompleted) { tileClass += ' green'; } else if (this.isDeleting(x, y)) { tileClass += ' red'; } else if (this.isActive(x, y)) { tileClass += ' blue'; } return tileClass; }, resetGame(difficulty) { this.gameDifficulty = difficulty; this.$store.dispatch('SEND_EVENT', { type: 'resetGame', data: this.gameDifficulty }); } },
Next we add in our functions that we want the component to be able to access. These are mostly just helper functions designed to parse text used for styling our template.
watch: { percentCompleted(newValue) { if (this.gameLoaded) { if (newValue === 100) { this.$store.dispatch('SEND_EVENT', { type: 'allTilesActive', data: this.gameDifficulty.gridSize * this.gameDifficulty.gridSize }); } } }, gameDifficulty(newValue) { this.gameLoaded = (newValue != undefined); } } }; </script>
Similar to the computed
property, the watch
property is also something a little different. It allows us to keep an eye on a variable (in this case the computed value of percentCompleted
) and allows us to write some code that runs when the watched value changes.
When our watched value changes, we see if the newValue
equals 100
, and if so we send the allTilesActive
event to the server asking if it agrees that we’ve finished the game. We send the total number of game tiles along for the server to perform calculations with.
<style scoped> .game { position: relative; width: 640px; } .board { width: 640px; height: 640px; } .overlay { position: absolute; top: calc(50% - 24px); width: 100%; } </style>
The Game component makes use of the <style>
section to provide CSS styling to the template. You may notice the scoped
attribute in the opening tag – that is used to restrict any styling to only affect the template of this component, and not any others.
<template> <v-container> <v-row> <v-col :cols="4"> <chat /> </v-col> <v-col :cols="8"> <game /> </v-col> </v-row> </v-container> </template> <script> import Chat from './Chat'; import Game from './Game'; export default { name: 'RealTimeDemo', components: { Chat, Game } } </script>
Finally, we can add our newly created Game component to the RealTimeDemo
component.
We can now test our new functionality. Start the server with the command node server
, and open the app in your browser. Move your mouse around over the game board to activate tiles. When all of the tiles have been activated, the game state changes to show that you have finished. Congratulations!
If you have a friend on the local network, you can head back to src/plugins/socketio.js
and set networkConnection
to true
, then share the link.
Conclusion
Thank you for reading this far – I hope that means that you enjoyed this series of posts. While our “Fill Frenzy!” game isn’t likely to win any awards as is, you have now mastered the techniques to create your own multiplayer games and real-time applications.
You can find this project in its entirety on GitHub.
Header image courtesy of NASA on Unsplash