DEV Community

Cover image for Sockets & Multiplayer Gaming
Barrington Hebert
Barrington Hebert

Posted on

Sockets & Multiplayer Gaming

Ever wanted to make a multiplayer game? It may sound intimidating at first but it offers an exciting chance to learn all the wonders of sockets and how they work. No, not the sockets we use as tools in a mechanical hardware sense, but sockets as a point of connection that allows clients or programs to communicate with one another. They are used primarily to send and receive data over a network, I like to think of them as being similar to http requests in the context of web development, except they specialize in their ability to be utilized for communication from client to client. In this short example, I will demonstrate how we can use this gift of the sockets to create a multiplayer gaming experience in any web application using Express, React, and socket.io.

Image description

First, the way we initialize sockets is by making use of the socket() function, which creates a new socket object. We first start on our server side, and can initialize the socket there:

import { createServer } from "http";
import { Server, Socket } from "socket.io";

const httpServer = createServer(); // create your server normally
const io = new Server(httpServer, { // pass that server in here
  // ... // your options, such as your cors setup, can go here
});

io.on("connection", (socket: Socket) => { // this is when the socket is initialized
  // We will pass our socket events here
});

httpServer.listen(4000); // sets up your server
Enter fullscreen mode Exit fullscreen mode

Image description

Here we can see that socket.io comes with typescript support, where we can let typescript know we are dealing with a socket inside of our function. In our case we are using Express, we must make use of a slightly different setup:

const app = require("express")();
const httpServer = require("http").createServer(app);
const options = { /* ... */ };
const io = require("socket.io")(httpServer, options);

io.on("connection", socket => { /* ... */ });

httpServer.listen(4000);

Enter fullscreen mode Exit fullscreen mode

As you can see, in this case; we are not using app.listen(PORT) in this case; this is because it will create a new HTTP server. We do not want this; as we want to ensure we use the server that has been initialized with sockets ready for use. Socket.IO needs to attach itself to an http server to handle initial handshakes and upgrades to the WebSocket protocol, however if we were to use app.listen() Express will create its own HTTP server instance. It will also lead to issues with CORS, as Express handles CORS seperately from Socket.IO, which will lead to potential conflicts. Thus the best way to use Socket.IO is to create an HTTP server instance and explicitly pass it to Socket.IO, which gives us more control and avoids potential conflicts.

Anyway, now that we have our socket on the server side ready, lets start looking at our client-side for sockets:

import { io } from "socket.io-client";

const socket = io("http://localhost:3000");

function Component(){

useEffect(() => {

// socket event listeners
socket.on("connect", () => {
  console.log("Connected to the server!");
});

socket.on("message", (data) => {
  console.log("Received message:", data);
});
})



//...

} 
Enter fullscreen mode Exit fullscreen mode

Here we can see how our sockets will be set up on the client side, it requires we use a different library socket.io-client, and we import io from there, then initialize it whilst passing our server URL into it as an argument. From here, we can use .emit() to send events to the server, and .on() to listen for events from the server. Our connect event, indicates a successful connection to the server, and helps us recognize when our sockets are connected. In React, we want to wrap all of this inside of our useEffect hook, so that the sockets are at a constant ready state to receive emit() from the server, and to send emit() to the server, and vice versa.

Now that a basic rundown of how sockets and events work have been successfully reviewed, lets start implementing a multiplayer game where our characters can move around the screen in real time. This subject is slightly more advanced, and we will go over how the controls will work in this game as well. First we need to set ourselves up with a way to listen to all inputs coming from the keyboard on our site by using event listeners:

  // EVENT LISTENERS FOR PLAYER INPUT
  useEffect(() => {
    if (isTyping === false) {
      document.addEventListener('keydown', keyPress);
      document.addEventListener('keyup', keyUp);
    } else {
      document.removeEventListener('keydown', keyPress);
      document.removeEventListener('keyup', keyUp);
    }
    return () => {
      document.removeEventListener('keydown', keyPress);
      document.removeEventListener('keyup', keyUp);
    };
  }, [isTyping]);
Enter fullscreen mode Exit fullscreen mode

These event listeners allow you to listen for any keyboard usage our user does while on our page. I should also clarify that in my component, I implemented a way for players to send messages to one another, so I am making use of an 'isTyping' state variable that ensures the event listeners are turned off whenever a player is typing inside of our inbox (which triggers isTyping to change it's state to true, in which case these event listeners are removed). Anyways, when the keys are pressed or released, we will be able to trigger events or functions based on this. These functions will look something like the following depending on your controls:

  const keyPress = ({ key }: Element) => {
    if (isTyping === false) {
      if (key === 'ArrowUp' || key === 'w') {
        socket.emit('keyPress', { inputId: 'Up', state: true });
      } else if (key === 'ArrowDown' || key === 's') {
        socket.emit('keyPress', { inputId: 'Down', state: true });
      } else if (key === 'ArrowLeft' || key === 'a') {
        socket.emit('keyPress', { inputId: 'Left', state: true });
      } else if (key === 'ArrowRight' || key === 'd') {
        socket.emit('keyPress', { inputId: 'Right', state: true });
      }
    }
  };

  const keyUp = ({ key }: Element) => {
    if (key === 'ArrowUp' || key === 'w') {
      // Up
      socket.emit('keyPress', { inputId: 'Up', state: false });
    } else if (key === 'ArrowDown' || key === 's') {
      // Down
      socket.emit('keyPress', { inputId: 'Down', state: false });
    } else if (key === 'ArrowLeft' || key === 'a') {
      // Left
      socket.emit('keyPress', { inputId: 'Left', state: false });
    } else if (key === 'ArrowRight' || key === 'd') {
      // Right
      socket.emit('keyPress', { inputId: 'Right', state: false });
    }
  };

Enter fullscreen mode Exit fullscreen mode

As you can see, our sockets emit data to a keyPress listener on the server side, along with some details needed for those listeners. On the server side; you will see that the listeners are set up like this:

    // Controls movement. Update their respective state via socket.id
    socket.on('keyPress', ({ inputId, state }) => {
      if (inputId === 'Up') {
        PLAYER_LIST[socket.id].pressingUp = state;
      }
      if (inputId === 'Left') {
        PLAYER_LIST[socket.id].pressingLeft = state;
      }
      if (inputId === 'Right') {
        PLAYER_LIST[socket.id].pressingRight = state;
      }
      if (inputId === 'Down') {
        PLAYER_LIST[socket.id].pressingDown = state;
      }
    });
Enter fullscreen mode Exit fullscreen mode

This socket will catch anything that is sent from the client to 'keypress', and update a player based on their socket.id, which comes inherent on every socket. The PLAYER_LIST is an object which each player of our game is added. We can tell which player is which based on their socket.id, which comes unique for each client. These key values will point to a player object that looks something like this:

const Player = function (id: any, user: any, eventId: any): any {
  const self = {
    username: user.username,
    name: id,
    data: {
      // positions
      x: 25,
      y: 25,
    },
    pressingRight: false, // states of movement
    pressingLeft: false,
    pressingUp: false,
    pressingDown: false,
    maxSpd: 10,
    sentMessage: false,
    currentMessage: '',
    eventId,
    updatePosition() {
      // method for updating state of movement
      if (self.pressingRight) {
        self.data.x += self.maxSpd;
      }
      if (self.pressingLeft) {
        self.data.x -= self.maxSpd;
      }
      if (self.pressingUp) {
        self.data.y -= self.maxSpd;
      }
      if (self.pressingDown) {
        self.data.y += self.maxSpd;
      }
    },
  };
  return self;
};
Enter fullscreen mode Exit fullscreen mode

As you can see, each of these players will have a method that will update based on the passed in data of their movement; and in turn will update their own x and y positions by the value of their maxSpd. We create these players whenever they initially join a room, so on the client side we will need them to join a certain room which I based on their eventId value, and they will also need to be added to a socket list. This is so I can update every single player in the list based on their respective socket in the player list. I accomplished this task by utilizing something like this:

socket.on('joinChat', ({ user, eventId }) => {
      socket.data.name = socket.id;
      socket.data.eventId = eventId;
      socket.join(eventId);
      const player = Player(socket.id, user, eventId);
      const stringName = socket.data.name;
      SOCKET_LIST[stringName] = socket;
      PLAYER_LIST[socket.id] = player;
    });
Enter fullscreen mode Exit fullscreen mode

I placed this on the server side, so whenever the client-side sockets emit that someone has joined a chatroom/game, we can attach essential information on that player to their socket.data (which is naturally of type any; this is how we can attach information to our socket for use later on in our program), then we create a Player using our Player constructor function, and add the player to a player list, and their socket to a socket list. Lastly, on the server side, we need to set something up to update all the client information continuously. This is where the functionality of our player list and players is finally revealed:

  setInterval(() => {
    let pack = []; // package to store players
    for (let key in PLAYER_LIST) {
      let player = PLAYER_LIST[key];
      player.updatePosition();
      pack.push({
        id: player.name,
        x: player.data.x,
        y: player.data.y,
        username: player.username,
        sentMessage: player.sentMessage,
        currentMessage: player.currentMessage,
        room: player.eventId,
      });
    }
    // loop through the sockets and send the package to each of them
    for (let key in SOCKET_LIST) {
      let socket = SOCKET_LIST[key];
      socket.emit('newPositions', pack);
    }
  }, 1000 / 25);
Enter fullscreen mode Exit fullscreen mode

Here, we create a pack, to store all of our players, then loop through each player in our list, and call their respective updatePosition methods. Then add those updated positions to our pack, which is then added to our PLAYER_LIST. Lastly, we loop through the entirety of that list, and emit to the newPositions socket event on the client side, along with that data. Thus, on the client side, we now know we need trigger our 'joinChat'
and listen for 'newPositions' from the server socket events, ensuring we are passing and set up to handle the proper information:

useEffect(() => {

    socket.emit('joinChat', { user, eventId });


    // Update state of all players and their respective positions
    socket.on('newPositions', (data) => {
      let allPlayerInfo = [];
      for (let i = 0; i < data.length; i++) {
        if (data[i].room === eventId) {
          allPlayerInfo.push({
            id: data[i].id,
            x: data[i].x,
            y: data[i].y,
            username: data[i].username,
            sentMessage: data[i].sentMessage,
            currentMessage: data[i].currentMessage,
            room: data[i].room,
          });
        }
      }
      setAllPlayers(allPlayerInfo);
    });
  }, []);
Enter fullscreen mode Exit fullscreen mode

allPlayers is a state variable which was initialized using the useState hook, so whenever it changes, the component reliant upon these variables will update itself. I ensure to map through each player in this array of players and make use of their x and y variables to position them within the game. Due to the fact that the server is making use of setInterval which is triggered 25 times per second, this means the positions of the players are going to appear to move at 25 frames per second; which is optimal for RPG's and most games which aren't First-Person-Shooters. I also ensure that we filter out any player who does not belong on the client-side by checking which 'room' they are in when this object is passed. Of course the most efficient way to do this is by making use of "rooms" in sockets. Such as socket.nsp.to(room).emit(data); on our server side. This will require some knowledge of namespaces and rooms in sockets.

Image description

For Socket.IO, nsp (namespaces) act as isolated channels for communication. When you create a new socket, it's like tuning into a radio station that broadcasts on a specific frequency. Without specifying a namespace, you're essentially broadcasting your message on a public channel where anyone can listen in. But by using nsp, you create a private channel for your sockets to communicate on. Imagine a large office building with hundreds of employees. If everyone talked at once, you'd have a cacophony of noise and no one would get anything done.

Now, introduce conference rooms with walls—each room is a namespace. When you use socket.nsp.to('room').emit(data), you're essentially speaking into the intercom of that specific conference room. Only the sockets that have joined that 'room' & namespace will receive the message.
Take a step further back and look at the documentation on namespaces:
Official documentation states that

io.sockets === io.of("/") // <-- this is a default 'namespace' of io. Where the namespace is essentially global. 
Enter fullscreen mode Exit fullscreen mode

So whenever I try to put two and two together; I am thinking that:
socket.nsp === io.of('this socket') ,
in which case
socket.nsp.emit(data)
emits data to a point that is dependent on the original way you initiated io... meaning I
'nsp' is a property that references the io initialization. The latter half:
socket.to(room).emit behaves normally by emitting to all in the room except yourself.
Then the final part is just chaining the two
socket.nsp.to(room).emit(data) thus your socket emits to yourself, and to everyone else in a certain 'room'. In summary, there are many ways to create a multiplayer game and communicate information back and forth between the server and other clients at a breakneck pace through the utilization of sockets. HTTP requests are simply too slow; and sockets somewhat skim some of the extra data off themselves by not using large request and response objects. Usage of socket-rooms and namespaces can help limit the amount of data and control who receives the data from your server-side sockets. Happy coding! -Cheers!

Top comments (0)