A modern multiplayer Pong game with real-time gameplay, room-based matchmaking, ELO ranking system, and retro vibes!
- Quick Match - Instant matchmaking with random players
- Private Rooms - Create/join rooms with 6-character codes to play with friends
- Multiple Simultaneous Games - Many pairs of players can play at the same time
- Real-time multiplayer gameplay at 60 FPS
- ELO-based ranking system with live leaderboard updates
- Player statistics tracking (wins, losses, games played)
- Retro-style graphics with modern smoothing
- Original 80's inspired soundtrack
- Genome-based procedural music generation
- Touch and mouse controls for mobile/desktop
- Accurate staked prize reporting — My Wins shows full 2× payouts
- My Wins dashboard now surfaces total claimable/claimed ETH stats
- Claimable-only filter makes it easy to focus on pending prizes
- Game History cards now show staked payout totals with tooltips
- My Wins cards include a quick “Copy Room” action for support/debugging
- Game History and My Wins leverage shared ETH helpers to prevent rounding mistakes
- When the filter hides everything, use “Show All Wins” to reset the view
- Game History “Load More” now truly appends older matches
- My Wins “Load More” now appends earlier wins instead of replacing the list
/healthnow reports the active backend CORS origins/source
- Claim prizes directly via Wagmi hooks and see transaction updates inside a modal overlay.
- Summary cards highlight total claimable, claimed, and overall winnings calculated at 2× stakes.
- Toggle between all wins vs. claimable-only and copy room codes for support tickets.
- Filter by result or match type while Load More now appends older games instead of replacing them.
- Remaining game counts and tooltips make it easy to see how many matches are left.
- Staked games display stake + payout details so rewards are transparent.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Player Service │
│ (React) │◄───────►│ (Node.js + │◄───────►│ (Express API) │
│ Port: 3000 │ │ Socket.IO) │ │ Port: 5001 │
│ │ │ Port: 8080 │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└────────────────────────────┴────────────────────────────┘
Docker Network
(app-network bridge)
Location: frontend/src/
Responsibilities:
- User interface and game rendering
- Socket.IO client connection to backend
- Canvas-based game rendering at 60 FPS
- Player input handling (keyboard, mouse, touch)
- Game mode selection (Quick Match, Create Room, Join Room)
Key Files:
components/Welcome.js- Home screen with game mode buttons and leaderboardhooks/useLeaderboardSubscription.js- Consolidates HTTP + WebSocket leaderboard datahooks/useBackendUrl.js- Surfaces the resolved backend URL + source for debugging bannersutils/backendUrl.js- Normalizes backend URL resolution/fallback logic shared by the entire frontendutils/eth.js- Shared ETH formatting helpers (stake → payout doubling, wei summation, trimming)utils/pagination.js- Helps merge paginated results without duplicating itemsbackend/src/utils/corsOrigins.js- Provides sane defaults whenFRONTEND_URLis missingbackend/scripts/showCorsConfig.js- Quick helper to print the active backend CORS configconstants.js- StoresLEADERBOARD_LIMITso sockets and REST fetches stay in sync and now sourcesBACKEND_URLvia a resolver that falls back to localhost during development.components/MultiplayerGame.js- Real-time multiplayer game logicApp.js- React Router setup and username management
Flow:
- User enters username (stored in localStorage)
- Selects game mode:
- Quick Match:
socket.emit('findRandomMatch', playerData) - Create Room:
socket.emit('createRoom', playerData) - Join Room:
socket.emit('joinRoom', { roomCode, player })
- Quick Match:
- Post-match rematch accepts route back to
/game - Listens for events:
gameStart- Game beginsgameUpdate- Ball/paddle positions (60 times/second)gameOver- Match results and rating changesleaderboardUpdate(legacy alias:rankingsUpdate) - Live ranking updates- Pagination helpers keep large lists (Game History/My Wins) synchronized without duplicate cards
Location: backend/src/
Responsibilities:
- WebSocket server for real-time communication
- Game state management for all active games
- Matchmaking logic (random + room-based)
- Game physics calculations
- ELO rating calculations
- Communication with Player Service
- Reads
MONGODB_URIfrom environment (defaults tomongodb://mongo:27017/pong-it) - Optional
SOCKET_HEADER_LOGS=trueto emit sanitized Socket.IO headers for debugging (defaults off) - Leaderboard falls back to in-memory cache when no
PLAYER_SERVICE_URLis set - Default player rating is 1000 when no remote rating exists
- Keep
SOCKET_HEADER_LOGS=falsein production to avoid logging cookies/tokens
Key Files:
server.js- Express + Socket.IO server setupmultiplayerHandler.js- Main multiplayer logic coordinatorroomManager.js- Room creation, joining, lifecycle managementgameManager.js- Game state, ball physics, collision detectionleaderboardManager.js- Rating calculations, player service integration
Flow:
Room-Based Matchmaking:
- Client emits
createRoom - RoomManager generates 6-character code (e.g., "XY4K2N")
- Room stored in Map:
rooms.set(code, roomData) - Backend emits
roomCreatedwith code back to client - Another client emits
joinRoomwith same code - RoomManager validates and adds guest to room
- Backend emits
gameStartto both players in room
Game Loop (per room):
- GameManager creates game state with ball, paddles, score
- setInterval runs at 60 FPS:
setInterval(() => { const result = gameManager.updateGameState(roomCode); io.to(roomCode).emit('gameUpdate', result); }, 1000/60);
- Ball physics calculated server-side
- Collision detection with paddles
- Score tracking
- When score reaches 5:
- Calculate ELO changes
- Update Player Service
- Emit
gameOverwith results - Broadcast
leaderboardUpdate(andrankingsUpdatefor backwards compatibility) to all clients
Why Server-Side Game Logic?
- Prevents cheating (clients can't manipulate game state)
- Ensures synchronized gameplay between players
- Single source of truth for ball position and score
Location: player-service/src/
Responsibilities:
- Player profile storage (in-memory Map)
- Rating persistence
- Statistics tracking (wins, losses, games played)
- Leaderboard queries
Key Endpoints:
GET /players/top?limit=10- Top players by ratingGET /players/:name- Individual player dataPOST /players- Create new playerPATCH /players/:name/rating- Update rating after game
Data Model:
{
name: "Player1",
rating: 1000, // ELO rating (starts at 1000)
gamesPlayed: 15,
wins: 8,
losses: 7,
lastActive: Date
}Flow:
- Backend requests player rating before match
- After game ends, backend sends new ratings
- Player Service updates stats (wins/losses)
- Backend fetches top players
- Backend broadcasts to all connected clients
Why Separate Service?
- Microservice Architecture - Can be scaled independently
- Data Isolation - Player data separate from game logic
- Future-Proof - Easy to add database later without changing backend
- Independent Deployment - Can restart without affecting active games
Docker provides containerization - each service runs in an isolated environment with its own dependencies.
Benefits:
- Consistency - "Works on my machine" → "Works everywhere"
- Isolation - Services don't conflict (different Node versions, ports, etc.)
- Easy Setup -
docker-compose upstarts entire system - Scalability - Can run multiple instances of any service
File: docker-compose.yml
services:
frontend:
build: ./frontend # Build from frontend/Dockerfile
ports: ["3000:3000"] # Map host:container ports
environment:
- REACT_APP_BACKEND_URL=http://localhost:8080
networks: [app-network] # Connect to bridge network
restart: unless-stopped # Auto-restart on failure
backend:
build: ./backend
ports: ["8080:8080"]
environment:
- PLAYER_SERVICE_URL=http://player-service:5001 # Docker DNS
depends_on:
- player-service # Start after player-service
networks: [app-network]
restart: unless-stopped
mongodb:
image: mongo:7
ports: ["27017:27017"]
volumes: ["mongo-data:/data/db"]
networks: [app-network]
restart: unless-stopped
player-service:
build: ./player-service
ports: ["5001:5001"]
networks: [app-network]
restart: unless-stopped
networks:
app-network:
driver: bridge # Virtual network for inter-container communicationMongoDB is pinned to mongo:7 to align local containers with current tools.
depends_on ensures startup order but does not wait for Mongo to be ready; add healthchecks if needed.
1. Frontend → Backend (from Browser)
- Uses
http://localhost:8080(host machine port) - WebSocket connection via Socket.IO
- CORS enabled for browser access
- When
REACT_APP_BACKEND_URLis not set, the React app now falls back to this origin (orhttp://localhost:8080) and logs the detected source in the console to prevent silent failures. - Optional: set
REACT_APP_BACKEND_URL_FALLBACKto enforce a custom fallback origin when neither the env var norwindow.location.originapply (e.g., running fromfile://). - Optional: set
REACT_APP_SHOW_BACKEND_URL_BANNER=falseto hide the development banner that explains which backend URL/source is in use. - Backend counterpart: set
FRONTEND_URL(preferred) orFRONTEND_URL_FALLBACKto restrict allowed origins; otherwise localhost defaults are used for development. - Emergency mode:
FRONTEND_URL_ALLOW_ALL=truesets a wildcard (development only, logs warnings). - See
.env.examplefor backend environment variables set during development. MONGODB_URIdefaults tomongodb://mongo:27017/pong-itwhen using Docker Compose.PLAYER_SERVICE_URLis optional; when omitted, the backend serves leaderboard data from memory.- Use
FRONTEND_URL_DEV_ORIGINS(comma-separated) to customize the default allowlist for local gadgets. - Audio assets now honor
PUBLIC_URLso hosting under subpaths keeps sounds working.
Backend URL Troubleshooting
- Open the browser console to view the backend banner and confirm which origin/source is active.
- Override via
REACT_APP_BACKEND_URLorREACT_APP_BACKEND_URL_FALLBACKif the detected origin is wrong. - Hide the banner with
REACT_APP_SHOW_BACKEND_URL_BANNER=falseonce configured.
2. Backend → Player Service (Docker Network)
- Uses
http://player-service:5001(Docker DNS name) - Services on same network can use service names
- Isolated from external access
3. Docker Network Bridge
Host Machine (localhost)
↓
Port 3000 → frontend container
Port 8080 → backend container ──┐
Port 5001 → player-service ←─────┘ (internal network)
Port 27017 → mongodb container
MongoDB shares the app-network so the backend can reach it by hostname mongo.
MongoDB Service
- Stores player/game data at
mongodb://mongo:27017/pong-itusing themongo-datavolume. - Remove the
mongo-datavolume to reset local data. - Add connection retries/healthchecks if startup ordering is an issue.
- You can inspect data with MongoDB Compass at
mongodb://localhost:27017. - Local tools can connect on
localhost:27017via the compose port mapping. - The
mongo-datavolume will grow with matches; prune it if space is tight. - Back up
mongo-dataif you need to preserve local progress. - In production, enable Mongo auth and avoid exposing port 27017 publicly.
Building Images:
docker-compose buildCreates images from Dockerfiles:
frontend/Dockerfile→ Installs React dependencies, copies codebackend/Dockerfile→ Installs Node.js dependenciesplayer-service/Dockerfile→ Installs Express dependencies- Validate the combined config with
docker-compose config - View Mongo logs:
docker-compose logs mongo - MongoDB data lives in the
mongo-datavolume - Reset the volume with
docker volume rm celo-pong_mongo-dataif you need a clean DB - Stop and remove containers/volumes with
docker-compose down -v
Starting Services:
docker-compose up- Creates
app-networkbridge - Starts
player-servicefirst - Waits for it to be healthy
- Starts
backend(depends_on) - Starts
frontendin parallel - All services can communicate via network
Service Independence:
- Each has its own filesystem
- Own Node.js version
- Own dependencies (node_modules)
- Own process (can crash/restart independently)
- Health checks ensure dependencies are ready
Step 1: Player1 Creates Room
Frontend (Player1) Backend RoomManager
│ │ │
├─ createRoom ─────────>│ │
│ {name: "Alice"} ├──── createRoom() ────────>│
│ │ ├─ Generate code "ABC123"
│ │ ├─ Store in Map
│ │<───── return code ─────────┤
│<── roomCreated ───────┤
│ {code: "ABC123"} │
│ │
│ Display: "Room Code: ABC123"
Step 2: Player2 Joins Room
Frontend (Player2) Backend RoomManager
│ │ │
├─ joinRoom ───────────>│ │
│ {code: "ABC123", ├──── joinRoom() ──────────>│
│ name: "Bob"} │ ├─ Validate code
│ │ ├─ Add Bob to room
│ │<───── room ready ──────────┤
│ │
Step 3: Game Starts
Backend GameManager Both Players
│ │ │
├─ createGame() ─────>│ │
│ ├─ Init ball, paddles │
│<──── gameState ──────┤ │
├────── gameStart ──────────────────────────>│
│ (room ABC123) │
│ │
├─ Start 60 FPS loop │
│ │
Step 4: Gameplay (every 16ms)
Player1 Backend GameManager Player2
│ │ │ │
├─ paddleMove ──────>│ │ │
│ {position: 0.5} ├─ updatePaddle() ─────>│ │
│ │ │ │
│ │<─── gameState ─────────┤ │
│<── gameUpdate ─────┴────────────────────────┴──── gameUpdate ─>│
│ {ball: {x, y}, paddles, score} │
Step 5: Game Ends
Backend LeaderboardManager Player Service All Clients
│ │ │ │
├─ Score = 5 │ │ │
├─ processGameResult() │ │
│ ├─ getPlayerRating()->│ │
│ │<─── 1050 ───────────┤ │
│ ├─ calculateElo() │ │
│ │ (1050 + 25 = 1075)│ │
│ ├─ updateRating() ───>│ │
│ │ ├─ Save to Map │
│ ├─ getTopPlayers() ──>│ │
│ │<─── top 10 ─────────┤ │
├────────────── gameOver ──────────────────┴──────────────>│
├────────────── leaderboardUpdate ────────────────────────>│
- Docker (v20.10+)
- Docker Compose (v2.0+)
- Modern browser with WebSocket support
git clone https://github.com/escapeSeq/k-pong.git
cd k-pongdocker-compose up --buildThis will:
- Build all three services
- Create the Docker network
- Start containers in correct order
- Show logs from all services
Open your browser and navigate to: http://localhost:3000
Quick Match:
- Enter username
- Click "Quick Match"
- Wait for opponent
- Game starts automatically
Private Room:
- Player 1: Click "Create Private Room"
- Share the 6-character code with friend
- Player 2: Click "Join Room" and enter code
- Game starts when both connected
Run individual services locally:
# Backend
cd backend
pnpm install
pnpm dev
# Frontend
cd frontend
pnpm install
pnpm start
# Player Service
cd player-service
pnpm install
pnpm dev- Keyboard: UP/DOWN arrow keys
- Mouse: Move cursor up/down
- Touch: Touch and drag on mobile
- First player to 5 points wins
- Ball speed increases after each paddle hit
- ELO rating changes based on opponent's rating
| Mode | Description | Use Case |
|---|---|---|
| Quick Match | Instant random matchmaking | Play with anyone online |
| Create Room | Generate shareable code | Play with friends |
| Join Room | Enter 6-char code | Join friend's game |
- React 18
- Socket.IO Client
- React Router v6
- HTML5 Canvas
- Web Audio API
- Static assets (sounds) resolve via
PUBLIC_URLfor subpath hosting
- Node.js 18
- Express.js
- Socket.IO 4
- Custom game engine
- Node.js 16
- Express.js
- In-memory data store
- Docker & Docker Compose
- Bridge networking
- Multi-stage builds
k-pong/
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── Welcome.js # Home screen + leaderboard
│ │ │ ├── MultiplayerGame.js # Real-time game component
│ │ │ └── GameOver.js # Results screen
│ │ ├── utils/
│ │ │ └── soundManager.js # Audio handling
│ │ └── App.js
│ ├── Dockerfile
│ └── package.json
│
├── backend/
│ ├── src/
│ │ ├── server.js # Express + Socket.IO setup
│ │ ├── multiplayerHandler.js # Socket event handlers
│ │ ├── roomManager.js # Room lifecycle
│ │ ├── gameManager.js # Game physics
│ │ ├── leaderboardManager.js # ELO + player service
│ │ ├── gameHandlers.js # Legacy handlers
│ │ └── utils/
│ │ └── eloCalculator.js
│ ├── Dockerfile
│ └── package.json
│
├── player-service/
│ ├── src/
│ │ └── server.js # REST API
│ ├── Dockerfile
│ └── package.json
│
└── docker-compose.yml
GET /health # Health check
GET /players # All players
GET /players/top?limit=10 # Top players
GET /players/:name # Single player
POST /players # Create player
PATCH /players/:name/rating # Update rating
Client → Server:
socket.emit('findRandomMatch', playerData)
socket.emit('createRoom', playerData)
socket.emit('joinRoom', { roomCode, player })
socket.emit('paddleMove', { position })
socket.emit('leaveRoom')Server → Client:
socket.on('roomCreated', (data))
socket.on('waitingForOpponent', (data))
socket.on('gameStart', (gameState))
socket.on('gameUpdate', (gameState))
socket.on('gameOver', (result))
socket.on('leaderboardUpdate', (topPlayers))
socket.on('opponentLeft')
socket.on('error', (error))- Fork the repository
- Create feature branch (
git checkout -b feature/amazing-feature) - Commit changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - Open Pull Request
This project is licensed under the MIT License. See the LICENSE file for details.
- Original Pong game by Atari (1972)
- Socket.IO for real-time communication
- Docker for containerization
- Run
pnpm run show:cors(ornpm run show:cors) to verify the backend CORS configuration without starting the server. - Run
pnpm run test:corsto see how different env combinations resolve. - See
docs/dev-notes/note-91-cors-debugging.mdfor more context.

