Background on Project

This week I decided to combine a couple of my courses and make a project that used both websockets (from Collective Play) and image classification. This ended up manifesting in a multiplayer Rock, Paper, Scissors game in which the two players can make hand gestures into their webcam which is picked up by ML5, translated into their sign, then sent to the server for distribution to the other player.

Model Training

The first step of the project was training the model to see if I could get it to properly recognize what most people would consider rock, paper, scissors gestures. I enlisted the help of a few friends in different environments with different skin tones and hand sizes to try to get the most accurate representation of the gestures. This is where I ran into one of two primary hiccups with Teachable Machine: the lack of any in-progress 'save' functionality meant that I had to keep the browser instance open the entire time for about a day. If I accidentally closed it or reloaded it (which happend once at the start) or if it happend to crash, then all progress was lost. It ended up working out fine in the end, but it made me quite nervous for most of the day!


ML Model

This GIF shows it in the later environment of the game itself, only because I had forgotten to get a recording of it at the time. This does however show it working fairly well in a new environment with a very noisy image. Scissors works the best, followed by paper, followed by rock.

Client and Server Development

The setup of the model was fairly plain, however I did run into some issues. For Collective Play we are required to upload projects to Glitch.com to be able to run server code and rapidly create new instances of multiplayer setups. There was no way of getting it to work with Glitch, as Glitch wouldn't allow the weights bin to be loaded locally and ML5 kept changing the URL request for the model to lowercase letters. This gave me the option of re-recording the entire model (can't rename a Teachable Machine model after publishing) or just showing it being run locally.

// Setup function
    function setup() {
        // Remove P5 Canvas 
            noCanvas();
        // Create a camera input
            video = createCapture(VIDEO);
        // Initialize the Image Classifier method with MobileNet and the video as the second argument
            classifier = ml5.imageClassifier('model/model.json', video, modelReady);
    }
    
// Executed when ML model is loaded
    function modelReady() {
        console.log('Model Ready');
        classifyVideo();
    }

The logic of the image classification itself is relatively standard as well. It takes the image classifier, get the ordinary single 'most likely' result, stores it as a variable for later, and change the icon on screen to match.

// Get a prediction for the current video frame
    function classifyVideo() {
        classifier.classify(gotResult);
    }

// When we get a result
    function gotResult(err, results) {
        // Store current value
            currentThrow = results[0].label;
        // Change icon to represent current guess at symbol
        // The results are in an array ordered by confidence.
            $("#throwIcon").css("background-image","url('img/" + throwIcons[currentThrow] + "')");
        // Re-run classify function (infinite loop)
            classifyVideo();
    }

It does get rather interesting when integrated into sockets. First was the Socket.io handshake between the server and client to ensure that both were valid and log to both's console.

Client
Server
Client HTML
// Get socket connection
  socket.on('connect', function() {
    console.log("Connected");
    socket.emit('message', 'Hello server');
  });

Next was to transmit and receive the actual signs picked up by the classifer. This is done when a 'shoot' signal, timed by the server, is broadcast to both clients. It then start a three second countdown, and sends it back.

Client
Server
Client HTML
// Upon receiving shoot message from server
 socket.on('shoot', function(){
  setTimeout(function(){
    socket.emit('throw',currentThrow);
    console.log("Throw emitted: " + currentThrow);
  },3000);
 });

Finally, it receives the throws from the server and figures out who won. It appends the results to the table on the right hand side. This is done client side only because the programming for the server was easier and I was under a bit of a time crunch to get it done in time for the Collective Play class (which was a day after the assignment began for our class).

// Upon receiving result
  socket.on('result', function(result){
    let otherThrow;
    console.log(result);
    if(currentThrow!=result[0]){otherThrow==0}
    else if(currentThrow!=result[1]){otherThrow==1}
    // Draw
    else{$("#resultsTable  tr:last").after("<tr><td>" + currentThrow + "</td><td>" + currentThrow + "</td><td>Draw</td></tr>");}
    // Win
    if(currentThrow == 0 && result[otherThrow] == 2 || currentThrow == 1 && result[otherThrow] ==  0 || currentThrow == 2 && result[otherThrow] == 1){
        $("#resultsTable  tr:last").after("<tr><td>" + currentThrow + "</td><td>" + result[otherThrow] + "</td><td>Win</td></tr>");
    }
    // Loss
    else if(currentThrow == 2 && result[otherThrow] == 0 || currentThrow == 0 && result[otherThrow] ==  1 || currentThrow == 1 && result[otherThrow] == 2){
        $("#resultsTable  tr:last").after("<tr><td>" + currentThrow + "</td><td>" + result[otherThrow] + "</td><td>Win</td></tr>");
    }
  });

Full Code

app.js
script.js
index.html
style.css
// Create server
  let port = process.env.PORT || 8000;
  let express = require('express');
  let app = express();
  let server = require('http').createServer(app).listen(port, function () {
  console.log('Server listening at port: ', port);
  });

// Define variables
  let users = 0;
  let userIds = [];
  let userThrows = [];

// Tell server where to look for files
  app.use(express.static('public'));

// Create socket connection
let io = require('socket.io').listen(server);

// Listen for individual clients to connect
io.sockets.on('connection',
  // Callback function on connection
  // Comes back with a socket object
function (socket) {
    // Check to limit users to 2
    if(users < 2){
      // Log connection info
      console.log("We have a new client: " + socket.id);
      // Update user count and store id
      userIds[users] = socket.id;
      users += 1;

      // Listen for data from this client
      socket.on('message', function(message) {console.log("Received hello! " + message);});
      socket.on('throw', function(receivedThrow) {
        // Update user throw values
        userThrows[userIds.indexOf(socket.id)] = receivedThrow;
        console.log("THROW VALUE: " + userThrows[userIds.indexOf(socket.id)]);
      });

      // Listen for this client to disconnect
      socket.on('disconnect', function() {
        console.log("Client has disconnected " + socket.id);
        users -= 1;
      });
    } 
  }
);

setInterval(function(){
  // Emit 'shoot' command to get current user values
    io.sockets.emit('shoot', 'shoot');
    console.log("Shoot!")
  // Wait to check results - 3000 for countdown, 1000 for transit time
    setTimeout(function(){
      io.sockets.emit('result', userThrows);
    },4000);
},8000);