Socket.IO for real time multiplayer games

@sadenbouuu|December 2, 2025

Introduction

Real-time applications require instant communication between clients and servers. In multiplayer games, even a delay of a few milliseconds can affect gameplay fairness and overall user experience.

While building my three-in-one Ping Pong game, trans-ping-pongy, which supports bot, local, and remote modes, the remote mode introduced several networking challenges. I needed a reliable way to handle:

  • Player movement synchronization
  • Ball and score state broadcasting
  • Reconnection handling
  • Room management
  • Low-latency communication

To solve these problems, I used Socket.IO. In this article, I’ll explain how Socket.IO works and how I implemented it to power the multiplayer gameplay experience.

Why Socket.IO?

Socket.IO is a library that enables bidirectional communication between the client and the server.

Instead of relying on the traditional HTTP request-response cycle:

client → request → server → response

Socket.IO keeps a persistent connection open, allowing both the client and the server to exchange messages at any time.

This makes it particularly well suited for real-time applications such as multiplayer games.

My Architecture Overview

The multiplayer system followed a client-server architecture where responsibilities were clearly separated.

Client Responsibilities

The client handled:

  • Capturing keyboard input
  • Sending movement updates
  • Rendering the game state
  • Displaying scores and match status

Server Responsibilities

The server was responsible for:

  • Maintaining the authoritative game state
  • Updating ball physics
  • Detecting collisions
  • Broadcasting updated game states to connected players

Connection Flow

When a player joins a match, the following sequence occurs:

  1. The client connects to the Socket.IO server
  2. The player joins a room
  3. The server waits until two players are connected
  4. The match starts
  5. The server continuously broadcasts the updated game state

Example of establishing a client connection:

const socket = io(API_BASE_URL, {
  withCredentials: true,
});

Once connected, the client can emit and listen to events.

Listening & Emitting Events

Socket.IO operates similarly to a messaging system.

  • emit → send information
  • on → receive information

Example:

socket.emit("movePaddle", { y });

socket.on("gameState", (state) => {
  renderGame(state);
});

This event-driven communication model forms the foundation of the real-time interaction system.

Room Management

Each multiplayer match was assigned its own room.

This allowed the server to broadcast updates only to the players involved in a specific game rather than to every connected client.

socket.join(roomId);

io.to(roomId).emit("gameState", updatedState);

Without rooms, scaling multiplayer systems efficiently would become extremely difficult.

Reconnection Logic

One of the most important systems I implemented was player reconnection handling.

This feature allows players to rejoin an active game after disconnecting due to:

  • Network instability
  • Browser refreshes
  • Temporary connection loss

Instead of instantly terminating the game, the server preserves the player state for a limited amount of time.

1. Data Structures for Tracking Disconnections

export interface Player {
  id: string;
  socketId: string;
  username?: string;
  userId?: number;
  disconnectionTime?: number;
}

export interface Room {
  id: string;
  players: Player[];
  running: boolean;
  ended?: boolean;
  reconnectTimeout?: NodeJS.Timeout | null;
}

The disconnectionTime property allows the server to temporarily preserve disconnected players instead of immediately removing them.

2. Marking a Player as Disconnected

When a player disconnects, they are marked as disconnected rather than deleted from the room immediately.

export function markPlayerDisconnected(socketId: string): {
  roomId?: string;
  playerIndex?: number;
} {
  const roomId = socketRoom.get(socketId);
  const room = rooms.get(roomId);
  const player = room.players.find(
    p => p.socketId === socketId
  );

  player.disconnectionTime = Date.now();

  if (
    room.players.length > 0 &&
    room.players.every(p => p.disconnectionTime)
  ) {
    endRoomDueToDisconnect(roomId);
  }

  return { roomId, playerIndex: player.index };
}

This approach prevents accidental match termination due to short-lived disconnections.

3. Handling Socket Disconnections

When a socket disconnects during an active match, the server:

  • Marks the player as disconnected
  • Notifies the remaining player
  • Starts a reconnection timeout
socket.on("disconnect", () => {
  const roomId = [...socket.rooms].find(
    (r) => r !== socket.id
  );

  const room = getRoom(roomId);

  const player = room.players.find(
    (p) => p.id === socket.id
  );

  if (
    !room.ended &&
    room.running &&
    room.players.length === 2
  ) {
    const { playerIndex } =
      markPlayerDisconnected(socket.id);

    gameNamespace.to(roomId).emit(
      "player_disconnected",
      {
        playerIndex,
        username: player.username,
        message:
          `${player.username} disconnected. Waiting for reconnection...`
      }
    );

    const RECONNECT_TIMEOUT = 60000;

    room.reconnectTimeout = setTimeout(() => {
      const opponentIndex =
        playerIndex === 0 ? 1 : 0;

      stopGame(
        gameNamespace,
        room,
        opponentIndex
      );

      endRoomDueToDisconnect(roomId);

      gameNamespace.to(roomId).emit(
        "player_abandoned",
        {
          message:
            `${player.username} failed to reconnect. Game ended.`
        }
      );
    }, RECONNECT_TIMEOUT);
  }
});

The game remains active while waiting for the disconnected player to reconnect.

4. Reconnecting a Player

When a player reconnects, the server attempts to restore them to their original slot in the room.

export function reconnectPlayer(
  roomId: string,
  userId: number,
  newSocketId: string,
  username?: string
): {
  success: boolean;
  message?: string;
  playerIndex?: number;
} {
  const room = rooms.get(roomId);

  if (!room || room.ended) {
    return {
      success: false,
      message: "Room not found or ended"
    };
  }

  let disconnectedPlayer =
    room.players.find(
      p =>
        p.disconnectionTime &&
        (
          p.userId === userId ||
          p.username === username
        )
    );

  if (!disconnectedPlayer) {
    disconnectedPlayer =
      room.players.find(
        p => p.disconnectionTime
      );
  }

  if (!disconnectedPlayer) {
    return {
      success: false,
      message: "No disconnected slot found"
    };
  }

  delete disconnectedPlayer.disconnectionTime;

  const oldSocketId =
    disconnectedPlayer.socketId;

  socketRoom.delete(oldSocketId);

  disconnectedPlayer.socketId =
    newSocketId;

  disconnectedPlayer.id =
    newSocketId;

  socketRoom.set(newSocketId, roomId);

  return {
    success: true,
    playerIndex:
      disconnectedPlayer.index
  };
}

This allows matches to continue seamlessly after temporary disconnections.

5. Handling Reconnection on Join

When a player joins a room, the server first checks whether the join request is actually a reconnection attempt.

socket.on(
  "join",
  ({ roomId, username }, callback) => {
    const room = getRoom(roomId);

    if (
      room &&
      userId &&
      room.players.length > 0
    ) {
      const disconnectedPlayer =
        room.players.find(
          p =>
            p.disconnectionTime &&
            (
              p.userId === userId ||
              p.username === username
            )
        );

      if (disconnectedPlayer) {
        const reconnectResult =
          reconnectPlayer(
            roomId,
            userId,
            socket.id,
            username
          );

        if (reconnectResult.success) {
          socket.join(roomId);

          if (room.reconnectTimeout) {
            clearTimeout(
              room.reconnectTimeout
            );

            room.reconnectTimeout = null;
          }

          socket.emit("ready", {
            message: room.running
              ? "Game already started"
              : "Game resuming"
          });

          gameNamespace.to(roomId).emit(
            "player_reconnected",
            {
              playerIndex:
                reconnectResult.playerIndex,
              username: username,
              message:
                `${username} reconnected! Game continues...`
            }
          );

          return;
        }
      }
    }

    // Normal join logic
  }
);

This reconnection workflow greatly improves the multiplayer experience.

6. Frontend Reconnection Handling

On the frontend, the client listens for reconnection-related events to update the UI.

socket.on(
  "player_disconnected",
  (payload: any) => {
    setInfo(
      `${payload.username || "Player"} disconnected. Game continues...`
    );
  }
);

socket.on(
  "player_reconnected",
  (payload: any) => {
    setReady(true);

    setInfo(
      `${payload.username || "Player"} reconnected! Game continues...`
    );
  }
);

socket.on(
  "player_abandoned",
  (payload: any) => {
    setInfo(
      payload.message ||
      "Opponent failed to reconnect. You win!"
    );

    setReady(false);
  }
);

These events ensure players receive immediate feedback about the match state.

Performance Considerations

To maintain smooth gameplay and minimize latency:

  • Updates were sent at a fixed tick rate
  • Payload sizes were minimized
  • Unnecessary emissions were avoided

In real-time systems, even small optimizations can significantly improve responsiveness and overall gameplay quality.

Conclusion

Building the multiplayer architecture for trans-ping-pongy was an excellent opportunity to explore real-time networking concepts and event-driven systems.

Using Socket.IO allowed me to implement:

  • Real-time synchronization
  • Room-based matchmaking
  • Reliable event broadcasting
  • Graceful reconnection handling
  • Low-latency communication

This project deepened my understanding of multiplayer game architecture and highlighted the importance of scalability, synchronization, and fault tolerance in real-time applications.

GitHubXLinkedIndev.toMediumRSS