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:
- The client connects to the Socket.IO server
- The player joins a room
- The server waits until two players are connected
- The match starts
- 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 informationon→ 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.