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 the unique capabilities of sockets to create a multiplayer gaming experience in any web application using Express, React, and socket.io.

Image description

A Brief Introduction to Sockets

       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.

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

// We use io from a separate library for client-side sockets
import { io } from "socket.io-client";

// initialize your socket to the same url as your server                  
const socket = io("http://localhost:3000");

// Inside of your component...
function Component(){

// Inside of a useEffect hook...
useEffect(() => {

// Place your assorted 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 socket.emit() to send events to the server, and socket.on() to listen for those events from the server. Our connect event, indicates a successful connection to the server. 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 socket.emit() from the server, and at any point outside of this connection, we may send data via emit() to the server, and vice versa. To summarize this; the basic syntax of this communication is essentially socket.on('x', {data}) will activate on the server side when socket.emit(x, {data}) is called on the client side, where x is a string which only triggers when its matching counterpart is called. Similar to the way express request handlers work in tandem with axios for example.

Multiplayer Game Logic

       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 logic for controlling your character will work in this game first. The initial part of this chain of inputs comes from our user when they push buttons on their keyboard or game-pad, we need to set ourselves up with a way to listen to all those inputs coming from the keyboard with basic event listeners being activated when the React component mounts:

  // EVENT LISTENERS FOR PLAYER INPUT
  useEffect(() => {
    // This 'isTyping' bool is if you want to toggle when the player is allowed to move their character
    if (isTyping === false) {
    // If they are not typing, start your event listeners!
      document.addEventListener('keydown', keyPress);
 // the first parameter is the event, the second is the function to be called
      document.addEventListener('keyup', keyUp);
    } else {
      document.removeEventListener('keydown', keyPress);
      document.removeEventListener('keyup', keyUp);
    }
// The return statement is for when the component unmounts
    return () => {
      document.removeEventListener('keydown', keyPress);
      document.removeEventListener('keyup', keyUp);
    };
// A dependency, meaning only trigger when this state variable is changed
  }, [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:

// Remember this function from earlier? I hope so!
  const keyPress = ({ key }: Element) => {
// It's 100% cleaner to use switch statements here. 
// But I'm opting for if/else to illustrate grandiose detail of what happens
    if (isTyping === false) {
      if (key === 'ArrowUp' || key === 'w') {
// Behold, our first socket.  It is emitting to 'keyPress', passing an object full of data.
        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 });
      }
    }
  };

// When they release the key...
  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
// This is the socket catching all the information from the client's event...
    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;
      }
    });
// On the server side, this all goes inside of io.on('connect', (socket) => { *** }}
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:

// Player constructor function. This is 100% better using ES6 or pseudoclassical, 
// Once again I am opting for using something else to illustrate grandiose detail. 
const Player = function (id: string, user: any, eventId: string): any {
  const self = {
    username: user.username,
    name: id,
    data: {
      // positions
      x: 25,
      y: 25,
    },
    pressingRight: false, // States of movement
    pressingLeft: false, // Remember state in the client side?
    pressingUp: false, // This is what it will be used for
    pressingDown: false, // Based on those keypress
    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:

// When a player joins the game/chat
socket.on('joinChat', ({ user, eventId }) => {
      // Add data to their socket, data is of type any
      socket.data.name = socket.id;
      socket.data.eventId = eventId;
      // join add's them to a "room" (discussed later)
      socket.join(eventId);
      // Create a player out of their information!
      const player = Player(socket.id, user, eventId);
      // stringName is really their socket.id... I did it this way just to illustrate that the data is unique to this particular socket
      const stringName = socket.data.name;
      // Add them to an object full of sockets
      SOCKET_LIST[stringName] = socket;
      // Add them to an object full of players
      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
    // Iterate through the list of players
    for (let key in PLAYER_LIST) {
      let player = PLAYER_LIST[key];
      // Add this information on the player into the pack
      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];
      // Sending info to the client here!
      socket.emit('newPositions', pack);
    } // How often the function is called... 
  }, 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(() => {
    // Page loads and a player joins the chat/game
    // Passing information that is used to create a player and a room. eventId can be a url endpoint or path for example.   
    socket.emit('joinChat', { user, eventId });


    // Update state of all players and their respective positions
    socket.on('newPositions', (data) => {
      let allPlayerInfo = [];
// We receive data from the server, and loop through it
      for (let i = 0; i < data.length; i++) {
// if the room matches, we keep it
        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,
          });
        }
      }
// Update a state variable you use to map players into the game
      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

Socket Rooms, NameSpaces ( nsp ) & Optimization

       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 '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. 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 passed and control who receives the data from your server-side sockets. There is a plethora of ways to refactor this code to your liking and help make it more efficient, or cultivate it to your needs. Simple things like switch statements, or making use of socket rooms and namespaces would accomplish this. I figured I would sacrifice these quintessential strategies in order to create code blocks that were more digestible for those of you who are brand new to coding, and looking to create your first multiplayer game. I hope this strategy also illustrates details and intricacies of how data can be accessed and passed back and forth using sockets, as well as allow you the time to linger on a more basic form of code, while providing you room for creativity in terms of optimization for more advanced developers. Anyways; Happy coding! -Cheers!

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs