A modern multiplayer Pong game with real-time gameplay, room-based matchmaking, ELO ranking system, and retro vibes!
- Run
docker-compose up --buildto bring up the frontend, backend, and player service together. - Alternatively, start each service directly with
pnpm --filter frontend dev,pnpm --filter backend dev, andpnpm --filter player-service dev(or runpnpm dev/pnpm startinside the respective directories). - Visit
http://localhost:3000,http://localhost:8080, andhttp://localhost:5001to ensure the frontend, backend, and player service dashboards are healthy.
| Script | Description |
|---|---|
./scripts/setup.sh |
Installs frontend/backend dependencies and ensures Foundry libraries are fetched. Run this after cloning or pulling. |
./scripts/lint-all.sh |
Runs ESLint against the React app and forge fmt --check against Solidity sources. |
./scripts/test-all.sh |
Executes CRA tests in CI mode and forge test for the escrow contract. |
./scripts/clean.sh |
Removes node_modules, Foundry build artifacts, and tears down Docker containers. Useful when resetting your workspace. |
All scripts should be executed from the repo root (bash ./scripts/<name>.sh).
-
Point Git to the bundled hooks once per clone:
git config core.hooksPath .husky. -
Hooks now run automatically:
pre-commit: secret scan →./scripts/lint-all.sh→./scripts/format-check.shpre-push:./scripts/test-all.sh
-
Bypass intentionally (CI or emergencies):
SKIP_HOOKS=1 git commit ...orSKIP_SECRET_SCAN=1 git commit ....
Documented workflows live in STARTUP_GUIDE.md → Contribution Workflow.
.editorconfigenforces LF endings, 2-space indents (4 for Solidity), and no trailing whitespace in code files..vscode/settings.jsontoggles format-on-save with Prettier and configures the Solidity extension..vscode/extensions.jsonrecommends Prettier, ESLint, GitLens, and the Solidity plugin—install them when VS Code prompts.
pnpm --filter frontend lint # CRA lint (ESLint)
pnpm --filter backend lint # Runs eslint/prettier via nodemon env
./scripts/format-check.sh # Prettier check for JS/CSS + Solidity formatter
./scripts/lint-all.sh # Aggregated lint + forge fmt --checkRun these before committing to reduce hook timeouts.
- Review
docs/dependency-matrix.mdfor backend/frontend package tables, Foundry libraries, upgrade policy, and the required checklist before bumping versions. - When bumping a dependency, copy the checklist into your PR description and tick each item.
- 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
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 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 leaderboardcomponents/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:
- Listens for events:
gameStart- Game beginsgameUpdate- Ball/paddle positions (60 times/second)gameOver- Match results and rating changesleaderboardUpdate (aka rankingsUpdate)- Live ranking updates
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
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(legacy aliasrankingsUpdate) 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
player-service:
> Note: The frontend now attempts to auto-detect the backend URL (falling back to http://localhost:8080 when the UI runs on port 3000) and surfaces a warning banner if it cannot reach the server. Setting REACT_APP_BACKEND_URL explicitly is still recommended for clarity, but a missing value will no longer break local fetch/socket calls.
build: ./player-service
ports: ["5001:5001"]
networks: [app-network]
restart: unless-stopped
networks:
app-network:
driver: bridge # Virtual network for inter-container communication1. Frontend → Backend (from Browser)
- Uses
http://localhost:8080(host machine port) - WebSocket connection via Socket.IO
- CORS enabled for browser access
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)
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
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 |
Debug tip: set
REACT_APP_DEBUG_SOCKET_EVENTS=trueto log leaderboard alias events in the browser console.
- React 18
- Socket.IO Client
- React Router v6
- HTML5 Canvas
- Web Audio API
- 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
| Directory | Purpose | Reference |
|---|---|---|
frontend/ |
React UI, routing, canvas/Audio logic | frontend/README.md |
backend/ |
Socket.IO server, matchmaking, game physics | backend/README.md |
player-service/ |
REST API for player stats and leaderboard | player-service/ (see README) |
blockchain/ |
Foundry contracts, deployment scripts, and docs | blockchain/README.md |
scripts/ |
(planned) reusable shell scripts for setup/lint/test | scripts/ |
- ELO – The ranking system that adjusts player ratings after each match.
- Room Code – 6-character identifier generated by the backend for private matches.
- Quick Match – Automatic matchmaking queue for random opponents.
- Player Service – Lightweight microservice that persists stats, rankings, and exposure for leaderboards.
- Backend – Socket.IO + Node.js server that orchestrates game physics and event delivery.
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);- Read
CONTRIBUTING.mdfor the contribution process, branch naming, and commit/PR expectations. - Use
.github/ISSUE_TEMPLATE/bug_report.mdor.github/ISSUE_TEMPLATE/feature_request.mdbefore starting work so maintainers have context. - Follow
.github/PULL_REQUEST_TEMPLATE.mdwhen opening a PR to surface testing and verification steps. - Keep changes backward compatible and document any configuration tweaks in
README.md,STARTUP_GUIDE.md,frontend/README.md,backend/README.md, orblockchain/README.mdwhen touched.
- Fork the repository.
- Create a 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 a Pull Request describing what changed, why, and which checks you ran.
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

