Real-Time Data Transfer Using Vue and Socket.IO [Part 3 of 3]

Blog / Rob Hyndman / May 31, 2021

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.

  1. We will start by implementing the game server. Create server/game.js and add the following code.
  2. 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.

  3. Update server/index.js to include our new functionality.
  4. 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.

  5. Next we will implement the game client. Create src/store/game.js and add the following code.
  6. 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.

  7. Update src/store/index.js with the following code.
  8. 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.

  9. Create src/components/Game.vue and add the following code.
  10. <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.

  11. Update src/components/RealTimeDemo.vue with the following code.
  12. <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