Online multiplayer implementation of the classic Pong game, coded during Christmas 2024.
The app is hosted on Render.com and can be accessed on these domains:
- Client: ping.malpou.io
- Server: pong.malpou.io
Instructions for running the applications locally.
The client is built with React and TypeScript, bundled using Vite.
- Node.js 20 or higher
- Go to the
client
directory - Run
npm i
- Run
npm run dev
The server is implemented in Python using FastAPI as the web framework and PostgreSQL as the database.
- Python 3.13
- Poetry
- Docker
- Go to the
server
directory - Run
poetry env use 3.13
- Run
poetry install
- Run
uvicorn main:app --reload
The game uses a binary WebSocket protocol for efficient real-time communication between client and server.
- Client connects to WebSocket endpoint:
ws://<server>/game/<room_id>
- Server assigns player role ("left" or "right") upon successful connection
- Connection is rejected if room is full (2 players already connected)
- Game starts automatically when second player joins
- Game pauses if a player disconnects and resumes when they reconnect
WAITING
: Room has less than 2 players, waiting for morePLAYING
: Active game with 2 playersPAUSED
: Game paused due to player disconnectionGAME_OVER
: Game ended with a winner (first to 5 points)
The server provides game specifications needed to set up the playing field through a REST endpoint.
GET /specs
{
"ball": {
"radius": 0.02, // Ball radius as percentage of screen width
"initial": {
"x": 0.5, // Initial X position (0-1)
"y": 0.5 // Initial Y position (0-1)
}
},
"paddle": {
"height": 0.2, // Paddle height as percentage of screen height
"initial": {
"y": 0.5 // Initial Y position (0-1)
},
"collision_bounds": {
"left": 0.1, // X position for left paddle collision
"right": 0.9 // X position for right paddle collision
}
},
"game": {
"points_to_win": 5, // Points needed to win the game
"bounds": {
"width": 1.0, // Game field width (normalized)
"height": 1.0 // Game field height (normalized)
}
}
}
All dimensions are normalized (0-1) so clients can scale them to their actual screen dimensions. These specifications should be retrieved before connecting to the WebSocket to properly set up the game field.
The specifications provide:
- Ball dimensions and initial position
- Paddle dimensions, initial position, and collision boundaries
- Game field dimensions and win condition
Size: 1 byte
[Message Type]
1 byte
Message Types:
0x01
: Paddle Up Command0x02
: Paddle Down Command
Each server message begins with a message type indicator:
[Message Type]
1 byte
Message Types:
0x01
: Game State Message0x02
: Game Status Message
Size: 20 bytes total
[Message Type][Ball X][Ball Y][Left Paddle Y][Right Paddle Y][Left Score][Right Score][Winner]
1 byte 4 bytes 4 bytes 4 bytes 4 bytes 1 byte 1 byte 1 byte
Field Types:
- Message Type: uint8 (1 byte)
- Ball X Position: float32, big-endian (4 bytes)
- Ball Y Position: float32, big-endian (4 bytes)
- Left Paddle Y Position: float32, big-endian (4 bytes)
- Right Paddle Y Position: float32, big-endian (4 bytes)
- Left Score: uint8 (1 byte)
- Right Score: uint8 (1 byte)
- Winner: uint8 (1 byte)
- 0: No winner
- 1: Left player won
- 2: Right player won
Variable size message
[Message Type][Length][Status String]
1 byte 1 byte variable
Field Types:
- Message Type: uint8 (1 byte)
- Length: uint8 (1 byte) - length of status string
- Status String: UTF-8 encoded string (variable length)
Status String Values:
- "waiting_for_players": Waiting for more players to join
- "game_starting": Both players present, game is starting
- "game_paused": Game paused due to player disconnect
- "game_over_left": Left player won
- "game_over_right": Right player won
interface GameState {
ball: {
x: number;
y: number;
};
paddles: {
left: number;
right: number;
};
score: {
left: number;
right: number;
};
winner: 'left' | 'right' | null;
}
class PongClient {
private ws: WebSocket;
constructor(server: string, roomId: string) {
this.ws = new WebSocket(`ws://${server}/game/${roomId}`);
this.ws.binaryType = 'arraybuffer';
this.setupHandlers();
}
private setupHandlers() {
this.ws.onmessage = (event) => {
const data = new DataView(event.data);
const messageType = data.getUint8(0);
switch (messageType) {
case 0x01: // Game State
this.handleGameState(data);
break;
case 0x02: // Game Status
this.handleGameStatus(data);
break;
}
};
}
private handleGameState(data: DataView) {
const gameState = {
ball: {
x: data.getFloat32(1),
y: data.getFloat32(5)
},
paddles: {
left: data.getFloat32(9),
right: data.getFloat32(13)
},
score: {
left: data.getUint8(17),
right: data.getUint8(18)
},
winner: (() => {
const winnerCode = data.getUint8(19);
switch (winnerCode) {
case 1: return 'left';
case 2: return 'right';
default: return null;
}
})()
};
this.updateGame(gameState);
}
private handleGameStatus(data: DataView) {
const length = data.getUint8(1);
const decoder = new TextDecoder();
const status = decoder.decode(new Uint8Array(data.buffer, 2, length));
this.updateGameStatus(status);
}
public sendPaddleUp() {
const command = new Uint8Array([0x01]);
this.ws.send(command);
}
public sendPaddleDown() {
const command = new Uint8Array([0x02]);
this.ws.send(command);
}
private updateGame(gameState: GameState) {
// Update game rendering with new state
}
private updateGameStatus(status: string) {
// Update UI based on game status
}
}
See the following modules for the server-side implementation:
binary_protocol.py
: Protocol encoding/decodingroom_manager.py
: Game room and player managementmain.py
: WebSocket endpoint and game loop