A full-stack multiplayer Pong web application built as part of the 42 curriculum. Players can compete in real-time matches or tournaments, chat with other users, and have their tournament results permanently recorded on the Ethereum blockchain. The app supports GitHub OAuth authentication, user profiles with friends/blocked lists, and is available in three languages.
| Layer | Technology | Justification |
|---|---|---|
| Backend | Fastify + TypeScript | Lightweight, high-performance Node.js framework with first-class WebSocket support for real-time game state streaming |
| Frontend | Vite + TypeScript | Fast HMR development, native ES modules, and minimal config for single-page application |
| Database | SQLite (better-sqlite3) | Embedded database with zero setup, perfect for single-server deployment; synchronous API simplifies game state management |
| Authentication | bcryptjs + GitHub OAuth | Secure password hashing with industry-standard algorithm; OAuth provides passwordless login option |
| Blockchain | Hardhat + Solidity (Sepolia) | Smart contract stores immutable tournament match results; ethers.js for frontend/backend chain interaction |
| Infrastructure | Docker Compose | Reproducible containerized deployment with a single command |
┌─────────────────────────────────────────────────────────────────────┐
│ users │
├─────────────────────────────────────────────────────────────────────┤
│ internal_id │ username │ password │ provider │ provider_id │ avatar │
│ friends │ blocked │ stats │ created_at │
└─────────────────────────────────────────────────────────────────────┘
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ tournament_players │
│ ├─────────────────────────────────────┤
│ │tournament_id│username│ display_name │
│ └─────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ tournaments │
├─────────────────────────────────────────────────────────────────────┤
│ internal_id │ id │ name │ size │ winner │ creator │
│ created_at │ started_at │ ended_at │ notes │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ matches │
├─────────────────────────────────────────────────────────────────────┤
│ internal_id │ id │ mode │ player_left │ player_right │ tournament_id│
│ round │ in_tournament_type │ in_tournament_placement_range │
│ score_left │ score_right │ winner │ started_at │ ended_at │ tx_hash │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ messages │
├─────────────────────────────────────────────────────────────────────┤
│ internal_id │ id │ sender │ receiver │ type │ content │ game_id │
│ sent_at │
└─────────────────────────────────────────────────────────────────────┘
- Real-time Pong gameplay with server-authoritative physics (~60 FPS state updates)
- Local multiplayer (two players on same keyboard)
- Remote multiplayer over WebSocket
- Tournament system with bracket matchmaking
- Blockchain integration — tournament match results saved to Ethereum (Sepolia testnet)
- User accounts with registration, login, avatars, friends, and block lists
- GitHub OAuth for passwordless authentication
- Live chat with direct messages and game invites
- Internationalization (English, German, French)
- Responsive SPA with browser history navigation
| Module | Type | Points | Description |
|---|---|---|---|
| Backend Framework | Major | 2 | Fastify with TypeScript for REST API and WebSocket game server |
| Tournament on Blockchain | Major | 2 | Solidity smart contract (TournamentMatches.sol) stores match results immutably on Sepolia |
| User Management | Major | 2 | Registration, login, profiles, avatars, friends/blocked lists, password hashing |
| Remote Authentication | Major | 2 | GitHub OAuth integration with CSRF protection |
| Remote Players | Major | 2 | Real-time multiplayer over WebSocket with server-authoritative game state |
| Live Chat | Major | 2 | Direct messaging, game invites, online status |
| Database | Minor | 1 | SQLite with better-sqlite3 for persistent storage |
| Browser Compatibility | Minor | 1 | Tested and compatible with latest Firefox and Chrome |
| Multiple Languages | Minor | 1 | i18n support for English, German, and French |
| User and Game Stats Dashboards | Minor | 1 | Detailed dashboard for user statistics and match history |
| Category | Points |
|---|---|
| Major Modules (6 × 2) | 12 |
| Minor Modules (4 × 1) | 4 |
| Total | 16 |
| Requirement | 14 |
| Bonus | +2 |
- Left paddle:
W(up) /S(down) - Right paddle:
↑(up) /↓(down)
Prerequisites
- Docker Desktop (or Docker Engine) with Compose v2 (uses
docker compose, notdocker-compose).
Quick start
# From repo root
make up # build (if needed) and start backend + frontend in the background
make logs # follow logs for both services
# Open the app
# Frontend: http://localhost:5173
# Backend: http://localhost:4000 (health: /api/health)Hot reload
- Source folders are bind-mounted into containers, so Vite and the Fastify dev server reload on file changes.
- On macOS, file watching is enabled via
CHOKIDAR_USEPOLLING=1in Compose.
What each Make target does (under the hood)
make up- Runs:
docker compose up -d - Builds images if missing and starts both services detached.
- Runs:
make down- Runs:
docker compose down - Stops and removes containers and the default network (keeps volumes).
- Runs:
make stop- Runs:
docker compose stop - Gracefully stops containers without removing them.
- Runs:
make build- Runs:
docker compose build - Rebuilds images using cache.
- Runs:
make rebuild- Runs:
docker compose build --no-cache - Full rebuild without cache (use after changing Dockerfiles or lockfiles).
- Runs:
make restart- Runs:
docker compose restart - Restarts running containers.
- Runs:
make logs- Runs:
docker compose logs -f - Tails logs for all services.
- Runs:
make ps- Runs:
docker compose ps - Shows container status.
- Runs:
make clean- Runs:
docker compose down -v - Stops and removes containers, network, and volumes (resets container
node_modules).
- Runs:
make prune- Runs:
docker system prune -f - Removes dangling images/containers/networks (be careful).
- Runs:
Notes from docker-compose.yaml
- Ports
- Frontend:
5173:5173(Vite dev server) - Backend:
4000:4000(Fastify HTTP/WebSocket)
- Frontend:
- Volumes
./frontend:/appand./backend:/appfor live reload./app/node_modulesas an anonymous volume so container installs don’t pollute the host.
- Commands
- Backend runs
npm run dev(watch mode). - Frontend runs Vite dev server bound to
0.0.0.0:5173.
- Backend runs
- If dependencies get out of sync, use
make clean && make upormake rebuild.
# Frontend (Vite)
npm install
npm run dev
# Backend (Fastify + websockets)
cd backend
npm install
npm run devOpen http://localhost:5173 in the browser once both servers are running. The frontend connects to the backend WebSocket at ws://localhost:4000/api/rooms/default/ws by default.
- Fastify drives the authoritative Pong simulation and exposes REST + WebSocket APIs.
- Vite-powered frontend renders the game canvas and relays paddle inputs to the backend via WebSocket commands.
- Game state (paddles, ball, score, winner) is streamed from backend → frontend ~60 FPS.
All backend endpoints return JSON format. All POST request expect the data to be sent in the body.
| Method | Path | Description | Returns |
|---|---|---|---|
GET |
/api/health |
Service heartbeat | |
GET |
/api/config |
(env override supported) | Returns { winningScore } |
POST |
/api/control |
Optional HTTP paddle control { roomId, paddle, direction } |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
WS |
/api/user/ws |
WS req, registers web socket | None | User socket |
| -------- | ---------------------------------- | ---------------------------------------- | ----------------------------- | ------------------------------- |
WS |
/api/local-single-game/:id/ws |
WS req, registers web socket | Id (local single game id) | Local single game socket |
WS |
/api/single-game/:id/ws |
WS req, registers web socket | Id (remote single game id) | Remote single game socket |
| -------- | ---------------------------------- | ---------------------------------------- | ----------------------------- | ------------------------------- |
WS |
/api/tournament/:id/ws |
WS req, registers web socket | Id (tournament) | Tournament socket |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
GET |
/api/users/all |
GET req | None | All users in database |
GET |
/api/users/online |
GET req | None | All online users |
| -------- | ---------------------------------- | ---------------------------------------- | ----------------------------- | ------------------------------- |
GET |
/api/user/:username |
GET req with username as param | Username | The user (if it exists) |
GET |
/api/user/online/:username |
Checks if username is online | Username | True or false |
GET |
/api/user/data |
GET req | None (session cookie) | userData |
GET |
/api/user/me |
Returns logged-in user | None (session cookie) | Current logged-in user |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
GET |
/api/user/check/:username |
Checks if username already exists | Username | True or false |
POST |
/api/user/register |
Registers new user | Username, password, avatar? | True or false |
POST |
/api/user/login |
Checks user credentials | Username, password | True or false |
POST |
/api/user/logout |
Logs user out | Username | True or false |
POST |
/api/user/update |
Updates user information | Username, newUsername?, newP | True or false |
POST |
/api/user/add-friend |
Adds other user as friend | Username, friend | True or false |
POST |
/api/user/remove-friend |
Removes other user as friend | Username, friend | True or false |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
GET |
/api/games/open |
GET req | None | All open games |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
GET |
/api/single-games/open |
GET req | None | All open single games |
GET |
/api/single-games/is-open/:id |
Check if single game is open | Id (single game) | True or false |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
GET |
/api/tournaments/all |
GET req | None | All tournaments in database |
GET |
/api/tournaments/open |
GET req | None | All open tournaments |
| -------- | ---------------------------------- | ---------------------------------------- | ----------------------------- | ------------------------------- |
GET |
/api/tournament/:id |
GET req | Id (tournament) | The tournament (if exists) |
GET |
/api/tournament/is-open/:id |
Check if tournament is open | Id (tournament) | True or false |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
GET |
/api/matches/all |
GET req | None | All matches in database |
| -------- | ---------------------------------- | ---------------------------------------- | ----------------------------- | ------------------------------- |
GET |
/api/match/:id |
GET req | Id (match) | The match (if exists) |
| Method | Path | Description | Params | Return |
|---|---|---|---|---|
GET |
/api/test/print-users |
Prints data in backend logs | None | All users in database |
GET |
/api/test/print-matches |
Prints data in backend logs | None | All matches in database |
GET |
/api/test/print-tournaments |
Prints data in backend logs | None | All tournaments in database |
GET |
/api/test/print-tournaments2 |
With tournament player struct | None | All tournaments in database |
GET |
/api/test/create-tournaments |
Creates 5 test tournaments | None | True or false |
GET |
/api/test/create-single-games |
Creates 5 test single games | None | True or false |
WebSocket commands from the frontend:
{ "type": "input", "paddle": "left" | "right", "direction": "up" | "down" | "stop" }
{ "type": "reset" }Server → client messages:
{
"type": "state",
"isRunning": true,
"width": 800,
"height": 450,
"tick": 42,
"paddles": { "left": { "y": 210 }, "right": { "y": 240 } },
"ball": { "x": 400, "y": 225, "vx": -180, "vy": 90, "r": 8 },
"score": { "left": 3, "right": 2 },
"isOver": false,
"winner": null,
"winningScore": 11
}Frontend:
cd frontend && npm ci && npm run dev
Backend:
cd backend && npm ci && npm run dev| Variable | Default | Purpose |
|---|---|---|
PORT |
4000 |
Backend HTTP/WebSocket port |
HOST |
0.0.0.0 |
Backend bind address |
FRONTEND_ORIGIN |
http://localhost:5173 |
Allowed CORS origin |
WINNING_SCORE |
11 |
Target score to win |
Frontend URL parameters roomId, wsHost, and wsPort let you connect to alternate rooms/hosts without recompiling.
MM = MatchManager SGM = SingleGameManager TM = TournamentManager RT = Routes BE = Backend FE = Frontend