27
Tutorial: Two Player 2048 w/ JS/Node/Express + Socket.io
MikeShi42 (95)

Hey Everyone!

I wrote a multiplayer 2048 game, converting the classic 2048, adding a Node.js server + Socket.io, so you can race with your friends in 2048! I've also written a guide below on how I was able to build this step-by-step for some inspiration :)

Check out the demo here! Fork the repl and share your fork’s link with a friend to play with them!

Guide

Today we’re going to turn the classic 2048 game, into a competitive multiplayer game for you to race with your friends. The best part is that with repl.it, socket.io and the original open source 2048 code makes this really easy to do!

You’ll want to have some experience with HTML/CSS/JS and Node.js to get started with this guide.

Getting Started

repl.it is a powerful and beginner friendly web-based IDE that allows you to kick off a programming environment in almost any language you’d ever want to try with a couple of clicks. It has insanely easy Node.js/Express project hosting which we’ll use for this multiplayer project!

To start, we’re going to fork off this starter project, that’s simply the 2048 single player version uploaded to repl.it with minimal changes.

Setting Up our Socket.io Server

Now with our project set up and base files in place, we can start coding our Node.js server that will coordinate the moves between two players. We’ll be using Socket.io to let our clients and our server communicate in real time, without latency of HTTP requests and other complexities.

Socket.io is a library that handles delivering messages back and forth between our client code (browser game) and our Node.js server via WebSockets. In our game, it’ll allow our games to tell the server about moves we make, and the server to tell us about moves our opponents make, with only a few lines of code!

Installing the Socket.io NPM Package

We first want to install the socket.io package, we can do this via the package tool on the left side of your repl.it editor, and simply search “socket.io” and click the + icon.

Initializing Our Server

Next we can go to our “index.js” file for our Node server and clear out the current content so we can start fresh! We’ll then want to import Express, Socket.io and HTTP server dependencies to be able to serve the front-end code we just uploaded into the “public” folder, and accept messages via Socket.io.

const express = require('express');
const socketio = require('socket.io');
const http = require('http');

const app = express();
const server = http.Server(app);
const io = socketio(server); // Attach socket.io to our server

app.use(express.static('public')); // Serve our static assets from /public

server.listen(3000, () => console.log('server started'));

Handling Connections

Next we’ll want to create an array to hold our users and assign them a player number when they connect to our server. Add this bit of code right after the server.listen line:

const connections = [null, null];

// Handle a socket connection request from web client
io.on('connection', function (socket) {
  
  // Find an available player number
  let playerIndex = -1;
  for (var i in connections) {
    if (connections[i] === null) {
      playerIndex = i;
    }
  }
  
  // Tell the connecting client what player number they are
  socket.emit('player-number', playerIndex);
  
  // Ignore player 3
  if (playerIndex == -1) return;
  
  connections[playerIndex] = socket;
  
  // Tell everyone else what player number just connected
  socket.broadcast.emit('player-connect', playerIndex);
});

io.on(‘connection’, function...) listens for new client connections, and handles each connection by calling the defined function. socket.broadcast.emit will send a message to all other clients connected to the server and socket.emit will send a message to only the current connected client.

Now when a user connects, they’ll be given a player number (either 0, 1 or -1 if the game is full), and everyone else will know when a user has connected (so we know to start the game).

Handling Moves

Next, inside of our connection callback, we can handle when a user gives us a move, we should broadcast it to the other player. We can paste this bit of code right after the socket.broadcast.emit line from above.

  socket.on('actuate', function (data) {
    const { grid, metadata } = data; // Get grid and metadata properties from client
    
    const move = {
      playerIndex,
      grid,
      metadata,
    };

    // Emit the move to all other clients
    socket.broadcast.emit('move', move);
  });

Handling Disconnects

We don’t want a player that disconnects to “ghost” on our server as a real user. So we’ll also clear out that user’s socket handle when they disconnect.

  socket.on('disconnect', function() {
    console.log(`Player ${playerIndex} Disconnected`);
    connections[playerIndex] = null;
  });

We can simply add the above snippet below our ‘actuate’ message handler.

Your index.js should now look like this.

Creating our Second (Remote) Game Board

Now with the server stuff out of the way, we can focus on turning our single board 2048, into a multi-board 2048!

Copy Pasta

The first thing we’d need to do, is literally copy and paste a second board from our public/index.html document.

We first wrap the <div class="game-container">...</div> element in another div like <div id="player-one"><div class="game-container">...</div></div>. We can duplicate that div and name it “player-two”. In the end, part of your index.html should look like this gist.

This way we have two boards, and we can address each by their class name as either player-one or player-two.

Sending Messages Over WebSocket

Now that we have two game boards, we’ll want to be able to control them over websockets.

Connecting the Sockets

First we’ll want to start talking to our server via socket.io. We’ll want to include the socket.io library via a <script /> tag in public/index.html.

Like so:

  ...
  </div>

  <!-- Below is the script tag you need to add! -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js"></script>
  <script src="js/utils/keyboard_input_manager.js"></script>
  ...

That way we’ll have the socket.io library loaded before any of our game code, any avoid any errors referencing the socket library in our code.

Next we’ll have to actually initialize the connection in public/js/application_manager.js.

window.requestAnimationFrame(function () {
  var socket = io.connect(window.location.origin);
  ...

Here we’re telling the socket.io to connect to our server, and the server is located at window.location.origin. This allows it to connect to whatever URL is hosting the page, so if you fork the repl.it, it’ll automatically point to your fork’s URL, instead of a hardcoded URL.

Modifying Game Manager

Now we’ll want to modify the game manager so that it can handle remote players and handle local players.

In public/js/game_manager.js we can add two new arguments in the front of the constructor called remotePlayer and socket. remotePlayer will be a boolean representing if this GameManager is managing a remote player, or the local player. socket will be the socket.io connection we just initialized in the above section.

The first couple lines of public/js/game_manager.js should look like this:

class GameManager {
  constructor(socket, remotePlayer, size, InputManager, Actuator, StorageManager) {

    this.size           = size; // Size of the grid
    this.inputManager   = new InputManager();
   ...

Before the end of the constructor, we’ll want to “save” the socket and remotePlayer values as local properties of the object. We’re also going to pass in remotePlayer as an argument to Actuator, which we’ll use later to decide which HTML Grid we should update (depending if it’s for player 1 or player 2).

  constructor(socket, remotePlayer, size, InputManager, Actuator, StorageManager) {

    this.size           = size; // Size of the grid
    this.inputManager   = new InputManager();
    this.storageManager = new StorageManager;
 
    // Change Actuator to take in remotePlayer as an argument!
    this.actuator       = new Actuator(remotePlayer);

    this.startTiles     = 2;
    ...
    this.inputManager.on("keepPlaying", this.keepPlaying.bind(this));

    // Add these two lines
    this.remotePlayer = remotePlayer;
    this.socket = socket;

    this.setup();
  }

Sending Moves over Websocket

Now we want that every time we make a move on our local board, we should tell the server about it, and the server will tell player 2 about the move we just made. First we want to create a function in our GameManager that will handle sending remote moves. We can simply insert this below our GameManager constructor we were writing in the previous section.

  sendRemoteMove(grid, metadata) {
    if (!this.remotePlayer) {
      this.socket.emit('actuate', { grid: grid, metadata: metadata });
    }
  }

This function takes in a grid: the current board state, and metadata: the score, win/loss, etc. of the game currently. If the current GameManager is not the remote player (so it’s the local player), then we should send the move over. We’ll emit a message with the event name of “actuate”. Notice how this event name matches the one we’re listening for on the server in /index.js. The second argument specifies the message payload of grid and metadata.

Now we want to call our function from GameManager.actuate. Notice the function already calls this.actuator.actuate which takes a grid and some metadata object. We can similarly call our sendRemoteMove function right after it. The actuate function should look like the below:

  // Sends the updated grid to the actuator
  actuate() {
    if (this.storageManager.getBestScore() < this.score) {
      this.storageManager.setBestScore(this.score);
    }
  
    // Clear the state when the game is over (game over only, not win)
    if (this.over) {
      this.storageManager.clearGameState();
    } else {
      this.storageManager.setGameState(this.serialize());
    }
    
    const grid = this.grid;
    const metadata = {
      score:      this.score,
      over:       this.over,
      won:        this.won,
      bestScore:  this.storageManager.getBestScore(),
      terminated: this.isGameTerminated()
    };
  
    this.actuator.actuate(grid, metadata);
    this.sendRemoteMove(grid, metadata); // Call our sendRemoteMove function!
  }

Handling Remote Moves over Websocket

We’ll also want to be able to take moves from player 2, and replay them on our local player 2 board to see what our competitor is doing.

First we want to define a function that can take a remote move and change the board to make the move. We’ll define the function right after sendRemoteMove that we defined before.

  handleRemoteMove(data) {
    const grid = data.grid;
    const metadata = data.metadata;
    this.actuator.actuate(grid, metadata);
  }

Next we’ll want to set up an event handler that will listen to when the remote player makes a move, but only if the current GameManager is handling the remote board. We’ll want to add a new if switch to our constructor function

  constructor(socket, remotePlayer, size, InputManager, Actuator, StorageManager) {
    ...

    // Add these two lines
    this.remotePlayer = remotePlayer;
    this.socket = socket;

    // Add this new if statement
    if (this.remotePlayer) {
      this.socket.on('move', this.handleRemoteMove.bind(this));
    }

    this.setup();
  }

If you’re testing the game now, you might notice that sometimes one player can sometimes update the other player’s board, but things aren’t consistent and everything is a bit buggy. We’ll be fixing that up in the following steps!

Updating Player 2’s HTML Board

Now that we can get moves over the socket connection, we want to update the correct board so that we can see what our opponent is doing. We’ll want to modify public/js/html_actuator.js, specify the HTMLActuator constructor so that it’ll target the right game board depending if it’s the local player or remote player. The constructor should look like this:

class HTMLActuator {
  constructor(remotePlayer) {
    if (remotePlayer) {
      this.tileContainer    = document.querySelector("#player-two .tile-container");
      this.messageContainer = document.querySelector("#player-two .game-message");
    } else {
      this.tileContainer    = document.querySelector("#player-one .tile-container");
      this.messageContainer = document.querySelector("#player-one .game-message");
    }
    
    this.scoreContainer   = document.querySelector(".score-container");
    this.bestContainer    = document.querySelector(".best-container");
  
    this.score = 0;
    
    this.remotePlayer = remotePlayer;
  }

We’re swapping out the old querySelector for tile container and game messages for one that targets specifically player one or player two’s game board. Additionally, we’re also going to save the remotePlayer as a local property so we can access it later.

Duplicating Game Manager

Next we can create another GameManager for player 2 in public/js/application.js, pass in the new arguments that we’ve defined for GameManager, and save the GameManager instances for later.

We’ll create two variables called “remoteGame” and “localGame” to store the two GameManager instances we’ll create. After everything, the application.js file should look something like this:

let remoteGame = null;
let localGame = null;

// Wait till the browser is ready to render the game (avoids glitches)
window.requestAnimationFrame(function () {
  const socket = io.connect(window.location.origin);
  
  remoteGame = new GameManager(socket, true, 4, KeyboardInputManager, HTMLActuator, LocalStorageManager);
  localGame = new GameManager(socket, false, 4, KeyboardInputManager, HTMLActuator, LocalStorageManager);
});

We’ve changed the GameManager to take the socket we’ve defined earlier as well as a bool value representing if the GameManager is responsible for the remote board or local board.

With that in place, we’re now actually pretty close to having two boards being able to interact with each other. If you’re testing it, you might notice that by opening two different tabs of 2048, that they’re able to talk to each other, but also seem to be weirdly linked together. We’ll fix that right now!

Don’t Listen To My Keys

An issue right now is that both GameManager (both remote and local) are listening to your keystrokes. Which leads to really weird behavior. Let’s make it so that only the local GameManager listens to keystrokes while the remote one only actuates on a socket message.

In our GameManager constructor we want to only listen to keystrokes if it’s not the remote player. Additionally, we’ll move the listener binding code into the setup function, which will help us control when the user can start issuing inputs into the game (which will be helpful later).

GameManager Before:

class GameManager {
  constructor(socket, remotePlayer, size, InputManager, Actuator, StorageManager) {
    ...
    this.startTiles     = 2;

    // We’ll be deleting these 3 lines
    this.inputManager.on("move", this.move.bind(this));
    this.inputManager.on("restart", this.restart.bind(this));
    this.inputManager.on("keepPlaying", this.keepPlaying.bind(this));
  
    this.remotePlayer = remotePlayer;
    ...

After:

class GameManager {
  constructor(socket, remotePlayer, size, InputManager, Actuator, StorageManager) {
    ...
    this.startTiles     = 2;
    this.remotePlayer = remotePlayer;
    ...

Setup Before:

  setup() {
    var previousState = this.storageManager.getGameState();
     ...
  }

Setup After:

  setup() {
    if (!this.remotePlayer) {
      this.inputManager.on("move", this.move.bind(this));
      this.inputManager.on("restart", this.restart.bind(this));
      this.inputManager.on("keepPlaying", this.keepPlaying.bind(this));
    }

    var previousState = this.storageManager.getGameState();
    ...

This makes sure that we’re only listening to moves when we’re the local player, and only after setup is called.

Testing!

With that, the basic game should be done! You can open two tabs to test out how your moves can be seen by both tabs. However, there’s still a lot of rough edges that need to be ironed out. You may have noticed that the board starts from the last saved state, and you never know if your friend has connected yet and whatnot. We’ll tackle all of those issues in the next section!

Game Polish

Removing Locally Saved State

We don’t want to start the game from whatever we last left off at, as that would gives us an unfair advantage! Instead we’ll disable the ability to restore the game state from local storage.

We’ll simply delete the restoring mechanism in GameManager.setup in public/js/game_manager.js. Additionally, we’ll only put down starting tiles if it’s the local game, so we don’t populate the remote board with starting tiles. In the end our setup function should look like this:

  setup() {
    if (!this.remotePlayer) {
      this.inputManager.on("move", this.move.bind(this));
      this.inputManager.on("restart", this.restart.bind(this));
      this.inputManager.on("keepPlaying", this.keepPlaying.bind(this));
    }

    this.grid        = new Grid(this.size);
    this.score       = 0;
    this.over        = false;
    this.won         = false;
    this.keepPlaying = false;

    if (!this.remotePlayer) {
      // Add the initial tiles
      this.addStartTiles();
    }

    // Update the actuator
    this.actuate();
  }

Game Starting Message

We want to know when player 2 has connected, as well as provide a countdown so that there is a delay between when player 2 has connected, and when the game will start.

We’ll first want to add the HTML elements to inform the user that they’re waiting for the next player and a countdown timer message as well. We’ll want to place these elements above the player board divs, that way it’s easily visible when the game loads.

    …
    <div class="above-game">
      <p class="game-intro">Join the numbers and get to the <strong>2048 tile!</strong></p>
      <a class="restart-button">New Game</a>
    </div>
    
    <!-- Add these two divs -->
    <div class="message-container waiting-message" style="display: none;">
      Waiting for Player 2...
    </div>
    <div class="message-container countdown-message" style="display: none;">
      Game starting in <span class="countdown-number"></span>...
    </div>

    <div id="player-one">
      <div class="game-container">
        <div class="game-message">
        ...

Additionally in public/style/main.css, we can define some styling so that our messages are easily visible.

@import url(fonts/clear-sans.css);

/* Styling to make the message containers look nice  */
.message-container {
  background: #8e7967;
  color: white;
  text-align: center;
  padding: 1em;
  margin: 1em;
  font-weight: bold;
  font-size: 1.2em;
}

html, body {
  ...

Next we’ll want to define some helper methods to hide/display the messages, as well as display the correct time for the countdown. We should do this in public/js/application.js. You can simply define these methods at the top of the script.

function waitingPlayerTwo(show) {
  const messageContainer = document.querySelector('.waiting-message');
  messageContainer.style.display = show ? 'block' : 'none';
}

function countdownMessage(show, number) {
  const messageContainer = document.querySelector('.countdown-message');
  const countdownNumber = document.querySelector('.countdown-number');
  
  messageContainer.style.display = show ? 'block' : 'none';
  countdownNumber.textContent = number;
}

Kicking Off the Game

Next we’ll want to define a method that actually kicks off the game. In this case, we’ll also have it do a countdown timer before starting the game. We can similarly just define this at the top of public/js/application.js

function startGame() {
  let seconds = 4; // Number of seconds + 1 to wait
  
  // Start a countdown timer
  const intervalId = setInterval(function() {
    // Subtract the number of seconds left and update UI
    seconds--;
    countdownMessage(true, seconds);
    
    if (seconds == 0) { // It's time to start the game!
      clearInterval(intervalId); // Stop the countdown
      countdownMessage(false, 0); // Hide the countdown message
      
      if (remoteGame != null && localGame != null) {
        localGame.restart(); // A game already exists, lets just reset the game.
      } else {
        // Start the game managers
        remoteGame.setup();
        localGame.setup();
      }
      
    }
  }, 1000);
}

The function may seem a bit complicated at first but breaks down to be fairly simple.

We first start with defining a function that is called every second via:

const intervalId = setInterval(function() {
  ...
}, 1000);

Next inside the function, we’ll want to subtract from the number of seconds we want to count down from, and then update the UI using our countdownMessage functions.

    seconds--;
    countdownMessage(true, seconds);

When the countdown has hit 0 seconds, we’ll want to stop the function from executing again via clearInterval, hide the countdown message, and then start the games.

Next we’ll want to sync up the waiting and countdown message with when users connect. In our requestAnimationFrame callback where we’re currently creating the game managers, we can listen to socket events to determine when we’ve connected, and when the opponent has connected to the server.

We can put this at the bottom of the requestAnimationFrame callback.

window.requestAnimationFrame(function () {
   ...
  localGame = new GameManager(socket, false, 4, KeyboardInputManager, HTMLActuator, LocalStorageManager);
  
  // Add this socket lisetner
  socket.on('player-number', function (playerNumber) {
    if (playerNumber == 1) {
      waitingPlayerTwo(true); // Show waiting message
      
      // On 2nd player connect, start the game
      socket.on('player-connect', function() {
        waitingPlayerTwo(false); // Hide waiting message
        startGame();
      });
    } else { // Immediately start the game if we're player two
      startGame();
    }
  });

If we’re player 1, we’ll display that we’re now waiting for player two to the user. When we’ve detected that another player has connected, we’ll hide the waiting message and start the game countdown. If we’re player two, we’ll just immediately start the game countdown after we’ve connected to the server.

Now there only leaves one last thing to do, is to not start the game immediately when we create a new GameManager.

In our public/js/game_manager.js, we’ll simply remove this.setup(); from the constructor. That way we won’t call it at instantiation, but instead only when we explicitly call it at startGame that we defined in application.js. Our constructor should look like this:

class GameManager {
  constructor(socket, remotePlayer, size, InputManager, Actuator, StorageManager) {
    ...

    // Add this new if statement
    if (this.remotePlayer) {
      this.socket.on('move', this.handleRemoteMove.bind(this));
    }

    // No setup!
  }

If all has gone well, your project should look similar to this repl.

Improve The Game!

This ends the basics of creating a multiplayer 2048 game! The final demo version has a couple of tweaks that you can check out the source code to see how I’ve done it like:

Side by Side Game Boards
Opponent Won Message
Eliminating Extraneous Buttons
Set Game Goal (Instead of winning @ 2048, can be easier or harder)
Game Full Message

Even More!

There’s space for even more improvement such as real-time chat using socket.io to chat with your opponent, a timer to time-cap the game, or mechanisms where your success impedes your opponent’s ability to succeed well (ex. frozen tiles). There’s a lot more creative ways to take the game that would be exciting to see!

Fin

Let me know if any of the steps were confusing or unclear :) I'll do my best to help!

You are viewing a single comment. View All
1
vedprad1 (477)

Thanks for sharing this! I’ve always been interested about this subject, and the way you explained about the sockets, made clear sense. Thanks!

1
MikeShi42 (95)

@vedprad1 Thanks! :D Let me know if I could help further in any way :)