WORKSHOP

Multiplayer Whack a mole!

by Maurici Abad

at Fontys ICT

INTRODUCTION

Who am I?

I'm Maurici Abad Gutierrez,

an exchange student

from Barcelona, Spain



 

Portfolio:

https://mauriciabad.com

 

LinkedIn:

https://linkedin.com/in/mauriciabad

HackUPC

I'm organizer of the largest hackathon in Barcelona.

We promote technology among students and create a great community.

 

Hackathon:

http://hackupc.com

 

Student organization:

http://hackersatupc.org

Multiplayer

Whack-A-Mole

Web App

 

Server:

  • npm
  • Node.js
  • Socket.io

 

Client:

  • JavaScript
  • CSS

 

 

Tools:

  • Heroku
  • GitHub

Result

  1. 💬 Create GitHub repository
  2. 💬 Initialize npm
  3. 💬 Create initial files
  4. 💬 Example code of Socket.io
  5. 👨‍💻 Code the Client
  6. 👨‍💻 Code the Server
  7. 💬 Deploy to Heroku
  8. 👨‍💻 Extra: Add scoreboard

THE PLAN

💬:  Follow guide

👨‍💻:  At your own

PAIR PROGRAMMING

Find a partner

SET UP

Prepare environment

https://code.visualstudio.com/

or any code editor

Install this:

Create a repository in GitHub

Create a repository in GitHub

Clone your repo

https://github.com/{username}/{repo-name}

git clone https://github.com/{username}/{repo-name}.git

cd {repo-name}

code .

Initialize npm

npm init

npm install express
{
  "name": "wmole",
  "version": "1.0.0",
  "description": "",
  "main": "src/app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node src/app.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/mauriciabad/wmole.git"
  },
  "author": "Maurici Abad Gutierrez",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/mauriciabad/wmole/issues"
  },
  "homepage": "https://github.com/mauriciabad/wmole#readme",
  "dependencies": {
    "express": "^4.17.1"
  }
}

Change this values

Create a .gitignore file

Folder structure

📁 dist
  📁 css
    📘 main.css
  📁 img
    🖼️ mole.svg
    🖼️ bunny.svg
  📁 js
    📒 index.js
  📙 index.html
📁 src
  📒 app.js
📄 .gitignore
📄 package.json

mole.svg

bunny.svg

Create src/app.js

/* - - - - Initialize variables - - - - */
const express = require('express');
const app     = express();
const http    = require('http').createServer(app);

// Your code goes here

/* - - - - Server logic - - - - */
app.use(express.static('dist'));

const port = process.env.PORT || 3000;

http.listen(port, () => {
  console.log(`listening on port ${port}`);
});

Create dist/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Wack A Mole Multiplayer</title>

  <link rel="stylesheet" href="css/main.css">
  <link href="https://fonts.googleapis.com/css?family=Luckiest+Guy&display=swap" rel="stylesheet">
</head>
<body class="body--play">

  <div class="score"><span id="score">0</span><span class="score__small"> pts.</span></div>

  <div class="grid">
    <!-- Repeat this 9 times changing the data-holenumber: { -->
    <div class="hole" data-holenumber="0">
      <img class="hole__img" data-content="mole" src="img/mole.svg" alt="🦔">
      <img class="hole__img" data-content="bunny" src="img/bunny.svg" alt="🐇">
    </div>
    <!-- } -->
  </div>

  <script src="js/index.js"></script>
</body>
</html>

Create dist/css/main.css

/* - - - - - - Shared - - - - - - */
:root {
  --green: #73B46E;
  --brown: #957863;
  --brown-dark: #403532;
}
body {
  color: var(--brown-dark);
  line-height: 1;
  margin: 0 auto;

  font-family: 'Luckiest Guy', cursive;
  background-color: var(--green);
  box-sizing: border-box;
}



/* - - - - - - Play - - - - - - */
.body--play{
  height: 100vh;
  overflow: hidden;
  
  display: flex;
  align-items: center;
  justify-content: space-evenly;
  flex-direction: column;
  
  user-select: none;
}
/* --- Grid & Holes --- */
.grid {
  --wide-size: calc(100vh - 8rem);
  --narrow-size: 100vw;

  width:  var(--wide-size);
  height: var(--wide-size);
  max-width:  var(--narrow-size);
  max-height: var(--narrow-size);

  display: grid;
  grid-template: 1fr 1fr 1fr / 1fr 1fr 1fr;
  gap: 1rem;

  padding: 1rem;
}
.hole {
  position: relative;

  background-color: #251F1D;
  border: solid 0.5rem var(--brown);
  border-radius: 100%;
  transition: filter 300ms ease-out;
  box-shadow: 
    inset 0 0vh 0 0 rgba(149, 120, 99, 0.1), 
    inset 0 2vh 0 0 rgba(149, 120, 99, 0.1), 
    inset 0 4vh 0 0 rgba(149, 120, 99, 0.1), 
    inset 0 6vh 0 0 rgba(149, 120, 99, 0.1), 
    inset 0 8vh 0 0 rgba(149, 120, 99, 0.1), 
    inset 0 10vh 0 0 rgba(149, 120, 99, 0.1);
}
.hole__img {
  opacity: 0;
  transform: scale(0.5) translateY(15%);
  transition: transform 100ms ease-out, opacity 100ms ease-out;

  will-change: transform, opacity;
  z-index: 1;

  position: absolute;
  top: 0; right: 0; bottom: 0; left: 0;
  padding: 0.75rem;
  object-fit: contain;
}
.hole__img--active {
  opacity: 1;
  transform: scale(1) translateY(0);
}
.hole__img--smashed {
  transform: scale(0.5) translateY(15%) rotate(720deg) !important;
}

/* --- Scores --- */
.score {
  font-size: 6rem;
  color: var(--brown-dark);
  line-height: 1;
}
.score__small { font-size: 0.6667em; }



/* - - - - - - Scoreboard - - - - - - */
.body--scoreboard { padding: 2rem; }
table { font-size: 7vw; margin: auto; border-collapse: collapse; }
thead { border-bottom: solid 0.125em var(--brown-dark); }
td,th { padding: 0.5rem; }
td:nth-child(1), th:nth-child(1) { text-align: left; padding-right: 2rem; }
td:nth-child(2), th:nth-child(2) { text-align: right; padding-left: 2rem; }
.fade{
  position: fixed;
  bottom: 0; left: 0; right: 0;
  height: 10rem;
  background: linear-gradient(to bottom, transparent, var(--green));
  z-index: 1;
}



/* - - - - - - Index - - - - - - */
.body--index {
  height: 100vh;
  max-width: 40rem;
  padding: 1rem;
  overflow: hidden;

  display: grid;
  grid-template: 1fr 2fr / 1fr;
  gap: 1rem;

  user-select: none;
}

a {
  background: var(--brown);
  box-shadow: 0 1rem 0 var(--brown-dark);
  margin-bottom: 1rem;
  border-radius: 1rem;
  
  font-size: 3rem;
  color: #fff;
  text-decoration: none;

  display: flex;
  align-items: center;
  justify-content: center;
}

Run your code

npm run start

Go to:

Socket.io

Official Socket.io documentation

Install Socket.io dependency

npm install socket.io

Load Socket.io library in the client

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Wack A Mole Multiplayer</title>

  <link rel="stylesheet" href="css/index.css">
  <link href="https://fonts.googleapis.com/css?family=Luckiest+Guy&display=swap" rel="stylesheet">
</head>
<body>

  <div class="score"><span id="score">0</span><span class="score__small"> pts.</span></div>

  <div class="grid">
    <!-- Repeat this 9 times changing the data-holenumber: { -->
    <div class="hole" data-holenumber="0">
      <img class="hole__img" data-content="mole" src="img/mole.svg" alt="🦔">
      <img class="hole__img" data-content="bunny" src="img/bunny.svg" alt="🐇">
    </div>
    <!-- } -->
  </div>

  <script src="/socket.io/socket.io.js"></script>
  <script src="js/index.js"></script>
</body>
</html>

Example data exchange

/* - - - - Initialize variables - - - - */
const express = require('express');
const app     = express();
const http    = require('http').createServer(app);
const io      = require('socket.io')(http);

/* - - - - Socket.io logic - - - - */
io.on('connection', (socket) => {
  console.log(`${socket.id} is connected`);

  socket.on('name', (name) => {
    console.log(`${socket.id} name is ${name}`);
    socket.emit('log', `Welcome ${name}! :D`);
  });
  
  socket.on('disconnect', () => {
    console.log(`${socket.id} disconected`);
  });
});

/* - - - - Server logic - - - - */
app.use(express.static('dist'));

const port = process.env.PORT || 3000;

http.listen(port, () => {
  console.log(`listening on port ${port}`);
});
const socket = io();

socket.emit('name', 'Lucas');

socket.on('log', (data) => {
  console.log(data);
});

src/app.js

dist/js/index.js

log

name

LET'S CODE!

Client Requirements [ALL]

  • Add Socket.io even listeners.
    • When a "spawn" event is received:
      • Runs displaySpawn function.
    • When a "score" event is received:
      • Runs displayScore function.
  • Add event listeners.
    (mousedown and touchstart)
    • When the player clicks or touches a hole:
      • Emits a "smash" event to the server.
      • Runs displaySmash function.
/* - - - Display UI changes - - - */
function displaySmash(holeNumber) {
  let holeActiveContentElement = document.querySelector(`[data-holeNumber='${holeNumber}'] > .hole__img--active`);
  if (holeActiveContentElement) {
    holeActiveContentElement.classList.remove('hole__img--active');
    holeActiveContentElement.classList.add('hole__img--smashed');
    setTimeout(() => {
      holeActiveContentElement.classList.remove('hole__img--smashed');
    }, 100);
  }
}

function displaySpawn({holeNumber, content, duration}) {
  let holeContentElement = document.querySelector(`[data-holeNumber='${holeNumber}'] > [data-content='${content}']`);
  
  holeContentElement.classList.add('hole__img--active');
  
  setTimeout(() => {
    holeContentElement.classList.remove('hole__img--active');
  }, duration - 100);
}

function displayScore(score) {
  document.querySelector('#score').textContent = score;
}

Help code to change UI

Server Requirements 1

  • Create an object that represents the status of the game (gameStatus). 
    • Contains info about the holes, for example it's content.
    • Contains info about the players, for example their score.

Server Requirements 2

  • When a player joins or leaves the game the gameStatus is updated.
    • Also, ​logs a message.

Server Requirements 3

  • When a player smashes a hole, gets points.
    • mole = +1 point         bunny = -3 points       nothing = 0 point
    • Points are earned only one time.
    • Minimum player score is 0.

Server Requirements 4

  • Create a function that places moles/bunnies in holes,
    in other words: updates the holes in the gameStatus.
    • ​Fill a random and empty hole.
    • Odds of placing a mole are greater than a bunny.
    • Each mole/bunny takes a different time to disappear.
    • Time between spawns is random.

Server Requirements [ALL]

  • Create an object that represents the status of the game (gameStatus). 
    • Contains info about the holes, for example it's content.
    • Contains info about the players, for example their score.
  • When a player joins or leaves the game the gameStatus is updated.
    • Also, ​logs a message.
  • When a player smashes a hole, gets points. 
    • mole = +1 point         bunny = -3 points       nothing = 0 points
    • Points are earned only one time.
    • Minimum player score is 0.
  • Create a function that places moles/bunnies in holes,
    in other words: updates the holes in the gameStatus.
    • ​Fill a random and empty hole.
    • Odds of placing a mole are greater than a bunny.
    • Each mole/bunny takes a different time to disappear.
    • Time between spawns is random.

DEPLOY

Official Heroku documentation

Create an Account

Create new app

Chose a unique app name

Set deployment method to GitHub

https://dashboard.heroku.com/apps/{app}/deploy/github

{app} = Your app name

Connect to your repo

https://dashboard.heroku.com/apps/{app}/deploy/github

{app} = Your app name

Enable automatic deploys

https://dashboard.heroku.com/apps/{app}/deploy/github

{app} = Your app name

Trigger first deploy

https://dashboard.heroku.com/apps/{app}/deploy/github

{app} = Your app name

Open the app

https://dashboard.heroku.com/apps/{app}/deploy/github

{app} = Your app name

If something goes wrong...
Check the logs

https://dashboard.heroku.com/apps/{app}/logs

{app} = Your app name

CODE THE SCOREBOARD

Scoreboard Requirements

  • Rename index.html to play.html
  • Rename index.js to play.js
  • Create in the dist/ folder: index.html and scoreboard.html
  • Create in the dist/js/ folder: scoreboard.js
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Wack A Mole Multiplayer</title>

  <link rel="stylesheet" href="css/main.css">
  <link href="https://fonts.googleapis.com/css?family=Luckiest+Guy&display=swap" rel="stylesheet">
</head>
<body class="body--index">

  <a href="scoreboard.html">Scoreboard</a>
  <a href="play.html">Play</a>

</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Wack A Mole Multiplayer</title>

  <link rel="stylesheet" href="css/main.css">
  <link href="https://fonts.googleapis.com/css?family=Luckiest+Guy&display=swap" rel="stylesheet">
</head>
<body class="body--scoreboard">

  <table>
    <thead>
      <tr><th>Player</th><th>Points</th></tr>
    </thead>
    <tbody id="scoreboard">
    </tbody>
  </table>

  <div class="fade"></div>

  <script src="/socket.io/socket.io.js"></script>
  <script src="js/scoreboard.js" defer></script>
</body>
</html>

dist/index.html

dist/scoreboard.html

Scoreboard Server Requirements

  • Scoreboard and Players connect to different Socket.io rooms.
  • There can be multiple scoreboards connected.
  • When a player sends a new username, the username is saved.
    • If the player doesn't choose a name, a default one is assigned.
  • Create a function that sends the current scores.
    • Sends an array of objects, sorted in descendant order by score.
      (the array represents a table and the objects the rows)
    • Sends the scores to all scoreboards connected.
    • Find in the code where to run this function.
[
  { "username": "Anna", "score": 13 },
  { "username": "Emma", "score": 10 },
  { "username": "Finn", "score":  6 },
  { "username": "Tess", "score":  2 }
]

Example scoreboard JSON

Scoreboard Client Requirements

  • Connects to a different room than dist/js/play.js.
  • When a "score" event is received:
    • Parses the json to html table and displays it.

  • In dist/js/play.js when loaded asks for username:
    • ​Uses a prompt.
    • Emits a "username" event to the server.
[
  { "username": "Anna", "score": 13 },
  { "username": "Emma", "score": 10 },
  { "username": "Finn", "score":  6 },
  { "username": "Tess", "score":  2 }
]

Example scoreboard JSON

SOLUTION

CODE IN GITHUB

dist/js/play.js

const socket = io('/play');

const username = prompt('Enter a username', '');
if(username) socket.emit('username', username);

document.querySelectorAll('.hole').forEach(hole => {
  hole.addEventListener('mousedown', smash);
  hole.addEventListener('touchstart', smash);
});

socket.on('spawn', displaySpawn);
socket.on('score', displayScore);



function smash(event) {
  event.preventDefault();

  let holeNumber = event.currentTarget.dataset.holenumber;
  
  socket.emit('smash', holeNumber);
  
  displaySmash(holeNumber);
}

/* - - - Display UI changes - - - */
function displaySmash(holeNumber) {
  let holeActiveContentElement = document.querySelector(`[data-holeNumber='${holeNumber}'] > .hole__img--active`);
  if (holeActiveContentElement) {
    holeActiveContentElement.classList.remove('hole__img--active');
    holeActiveContentElement.classList.add('hole__img--smashed');
    setTimeout(() => {
      holeActiveContentElement.classList.remove('hole__img--smashed');
    }, 100);
  }
}

function displaySpawn({holeNumber, content, duration}) {
  let holeContentElement = document.querySelector(`[data-holeNumber='${holeNumber}'] > [data-content='${content}']`);
  
  holeContentElement.classList.add('hole__img--active');
  
  setTimeout(() => {
    holeContentElement.classList.remove('hole__img--active');
  }, duration - 100);
}

function displayScore(score) {
  document.querySelector('#score').textContent = score;
}

src/app.js

/* - - - - Initialize variables - - - - */
const express = require('express');
const app     = express();
const http    = require('http').createServer(app);
const io      = require('socket.io')(http);

const ioPlay       = io.of('/play');
const ioScoreboard = io.of('/scoreboard');

const game = {
  holes: [
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
    { content: 'none', smashedBy: [] },
  ],
  players: {
    // 'exampleUserId': { score: 0, username: 'Player' },
  },
  points: { mole: +1, bunny: -3, none:  0 }
};


/* - - - - Game spawning logic - - - - */
setSpawner();
setSpawner();
setSpawner();

function setSpawner() {
  let holeNumber = Math.floor(Math.random() * 9);
  let content    = (Math.random() > 0.3) ? 'mole' : 'bunny';
  let duration   = 300 + Math.random() * 900;
  
  if(game.holes[holeNumber].content === 'none'){
    game.holes[holeNumber] = { content, smashedBy: [] };
  
    ioPlay.emit('spawn', {holeNumber, content, duration});
    
    setTimeout(() => {
      game.holes[holeNumber] = { content: 'none', smashedBy: [] };
    }, duration);
  }

  setTimeout(setSpawner, 100 + Math.random() * 3100);
}

/* - - - - Player logic - - - - */
ioPlay.on('connection', (socket) => {
  console.log(`${socket.id} joined the game`);

  game.players[socket.id] = {
    score: 0,
    username: 'Player',
  };

  updateScoreboard();

  socket.on('username', (username) => {
    game.players[socket.id].username = username;
    updateScoreboard();
  });

  socket.on('smash', (holeNumber) => {
    let hole = game.holes[holeNumber];
    
    if(!hole.smashedBy.includes(socket.id)){
      hole.smashedBy.push(socket.id);

      let oldScore = game.players[socket.id].score;
      let newScore = Math.max(0, oldScore + game.points[hole.content]);
      
      if(newScore !== oldScore) {
        game.players[socket.id].score = newScore;
        socket.emit('score', newScore);
        updateScoreboard();
      }
    }
  });

  socket.on('disconnect', () => {
    console.log(`${socket.id} left the game (${game.players[socket.id].username})`);

    delete game.players[socket.id];
    updateScoreboard();
  });
});


/* - - - - Scoreboard logic - - - - */
ioScoreboard.on('connection', (socket) => {
  console.log(`${socket.id} joined the scoreboard`);

  socket.emit('score', toSortedArray(game.players));

  socket.on('disconnect', () => {
    console.log(`${socket.id} left the scoreboard`);
  });
});

function updateScoreboard() {
  ioScoreboard.emit('score', toSortedArray(game.players));
}

function toSortedArray(players) {
  return Object.values(players).sort((a, b) => b.score - a.score);
}


/* - - - - Server logic - - - - */
app.use(express.static('dist'));

const port = process.env.PORT || 3000;

http.listen(port, () => {
  console.log(`listening on port ${port}`);
});

dist/js/scoreboard.js

const scoreboardElement = document.getElementById('scoreboard');
const socket = io('/scoreboard');

socket.on('score', (players) => {
  scoreboardElement.innerHTML = players.reduce((html, player) => {
    return `${html}<tr><td>${player.username}</td><td>${player.score}</td></tr>`
  }, '');
});

MORE IDEAS...

  • Make a page to place moles manually
  • Make a better design with CSS
  • Add sounds and music
  • Make the app installable (PWA)
  • End the game when someone gets 25pts.

THANKS FOR ATTENDING

Workshop - Multiplayer Whack a mole!

By Maurici Abad Gutierrez

Workshop - Multiplayer Whack a mole!

  • 483