# tapper

A distributed CS2 competitive matchmaking platform built as a fault-tolerant system. Players queue up, get matched, and play on dynamically provisioned game servers, tapper handles everything from authentication to live scoreboards.

The system runs two load-balanced API instances behind Traefik with automatic health checks and failover. Kill an instance mid-operation and the platform keeps running without interruption.

![Live match scoreboard](screens/match.png)

## Architecture

```text
Player / browser
      |
      v
Traefik v3.7.1
      |-- PathPrefix(`/`)    -> frontend (React 19 + Vite 8, served by Nginx on Alpine 3.23)
      |
      `-- PathPrefix(`/api`) -> api-1 / api-2 (Go 1.26.3 + Fiber)
                                      |
                                      v
                              PostgreSQL 18
                                      |
                                      v
                              Orchestrator -> dynamic CS2 servers
```
All services are containerized. A single `docker compose up --build` brings up the entire system including database migrations, key generation, and service discovery.

## Load Balancing & Failover

The core distributed systems requirement: two identical API instances sit behind Traefik v3, which acts as a reverse proxy and load balancer with Docker-native service discovery.

**How it works:**

- Traefik discovers API containers automatically via Docker labels, no manual configuration of upstream servers
- Health checks poll `GET /api/health` on each instance every 5 seconds with a 3-second timeout
- If an instance fails 3 consecutive health checks, Traefik removes it from the load balancer pool within ~15 seconds
- All traffic seamlessly routes to the remaining healthy instance
- When the failed instance recovers, Traefik re-adds it automatically
- Sticky sessions via cookies ensure SSE (Server-Sent Events) connections stay on the same instance

**Failover demo:**

```bash
# System running with both instances
docker compose stop api-2

# Traefik detects failure, routes all traffic to api-1
# Frontend continues working without errors
# Live matches are unaffected (game servers are independent)

docker compose start api-2
# Traefik re-discovers api-2, resumes load balancing
```

**Key design decisions:**

- Both API instances share the same PostgreSQL database, no replication complexity needed for this use case
- Both instances share the same RSA key pair (mounted read-only from a Docker volume) so access tokens are valid on either instance
- The matchmaker uses `SELECT ... FOR UPDATE SKIP LOCKED` to prevent both instances from processing the same queue entries simultaneously
- Game server containers run independently of the API, an API failover does not interrupt a live CS2 match

## Access/Refresh Authentication

All API endpoints (except registration, login, token refresh/logout, leaderboard, and health check) require a valid access token. The implementation uses RS256 JWTs for access tokens and opaque rotating refresh tokens, so this is not an OAuth flow and not a single long-lived JWT:

- The private key (signing) is only accessible to API instances
- A public key could be distributed to other services for verification without exposing the signing capability
- Both API instances share the same key pair, so an access token issued by api-1 is valid on api-2
- Refresh tokens are random opaque secrets; only their SHA-256 hashes are stored in PostgreSQL
- Refresh tokens rotate on every refresh and expire after 24 hours
- Logout revokes the submitted refresh token server-side

**Token flow:**

1. User registers or logs in, API returns `access_token` plus `refresh_token`
2. Access token contains claims: `user_id`, `role` (user/moderator/admin/owner), `tier` (normal/premium/vip), `purpose=access`
3. Every subsequent API request includes `Authorization: Bearer <access_token>`
4. Fiber middleware verifies the RS256 signature using the public key and extracts claims
5. When the access token expires, the frontend posts the opaque refresh token to `/api/auth/refresh`
6. The API verifies the refresh-token hash in PostgreSQL, revokes it, and returns a new access/refresh pair
7. On logout, the frontend posts the current refresh token to `/api/auth/logout`, then clears local auth state

**Key generation** is handled by a one-shot init container (`jwt-keygen`) that runs before the API starts. It generates a 2048-bit RSA key pair if one doesn't exist, stored in a Docker volume shared between both API instances.

**SSE tokens** are short-lived, purpose-scoped JWTs issued only for `EventSource`, where the browser cannot send an `Authorization` header.
## Containerization

The entire system runs in Docker containers orchestrated by Docker Compose:

| Container | Image | Purpose |
|-----------|-------|---------|
| `traefik` | traefik:v3.7.1 | Reverse proxy, load balancer, TLS termination |
| `frontend` | Custom (Node 26 alpine3.23 build -> Nginx alpine3.23) | React SPA served via Nginx |
| `api-1` | Custom (Go binary) | API instance 1 |
| `api-2` | Custom (Go binary) | API instance 2 (identical) |
| `postgres` | postgres:18-alpine3.23 | Database with persistent volume |
| `orchestrator` | Custom (Go binary) | Game server lifecycle management |
| `jwt-keygen` | alpine:3.23 | One-shot RSA key generation |
| CS2 servers | Custom (CS2 + MatchZy) | Dynamically created per match |

**Network isolation:**

- `tapper-web`, Traefik, frontend, and API instances (public-facing)
- `tapper-internal`, API instances, PostgreSQL, orchestrator (no external access)
- `tapper-gameservers`, Orchestrator and dynamically created CS2 containers

CS2 game servers are created dynamically by the orchestrator using the Docker SDK. When a match is found, the orchestrator provisions a container with the appropriate configuration, port mapping, and MatchZy plugin settings. After the match ends, the container is automatically removed and the port is returned to the pool.

## Load Testing

Load tests are written using k6 and target access-token-protected endpoints to measure real-world performance:

```bash
k6 run loadtest/script.js           # Standard load test
k6 run loadtest/failover-test.js    # Test with instance shutdown mid-run
```

**Test scenarios:**

- **Smoke test**, 1 virtual user, verify all endpoints work
- **Load test**, Ramp to 50 concurrent users, sustained for 2 minutes
- **Stress test**, Ramp to 200 concurrent users, find breaking point
- **Failover test**, Ramp to 50 users, kill api-2 mid-test, measure recovery

**Documented metrics:**

- Requests per second (throughput)
- Response time percentiles (p50, p95, p99)
- Error rate under load and during failover
- Before vs. after failover comparison
- Bottleneck analysis (expected: PostgreSQL connection pool saturation)

## Quick Start

```bash
git clone <repo-url>
cd tapper
cp .env.example .env   # Configure secrets
docker compose up --build
```

The system will:
1. Generate RSA keys for JWT signing (if not present)
2. Start PostgreSQL and auto-migrate all tables
3. Build and start both API instances, frontend, and orchestrator
4. Traefik discovers all services and begins health checking
5. Seed default configuration (Elo parameters, port pool)

| URL | Service |
|-----|---------|
| `http://localhost` | Frontend |
| `http://localhost/admin` | Admin Dashboard |
| `http://localhost:8080` | Traefik Dashboard |

## Environment Variables

```env
POSTGRES_PASSWORD=...          # Database password
GSLT_TOKENS=token1,token2,...  # Steam Game Server Login Tokens (App ID 730)
STEAM_API_KEY=...              # Steam Web API key (profile enrichment)
API_SECRET=...                 # Shared secret for internal service auth
PUBLIC_HOST=localhost           # Public IP/domain for connect strings
OWNER_EMAIL=admin@example.com  # First user to register with this email gets owner role
```

## Tech Stack

| Layer | Technology |
|-------|-----------|
| Frontend | React 19, Vite 8, TailwindCSS 4, Framer Motion, Three.js |
| Load Balancer | Traefik v3.7.1 (Docker-native discovery, health checks, Let's Encrypt) |
| Backend API | Go 1.26.3, Fiber v2 |
| Authentication | RS256 access tokens + rotating refresh tokens + Steam OpenID 2.0 |
| Database | PostgreSQL 18, GORM |
| Orchestrator | Go, Docker SDK |
| Game Server | CS2 Dedicated Server + MatchZy (CounterStrikeSharp) |
| Live Updates | Server-Sent Events (SSE) |
| Load Testing | k6 + ApacheBench |

## How a Match Works

![Queue page](screens/queue.png)

1. **Register & login**, access/refresh-token auth, link Steam account via OpenID 2.0
2. **Queue up**, Select competitive/casual mode and map preferences
3. **Get matched**, Matchmaker groups 10 players, balances teams by MMR
4. **Server provisioned**, Orchestrator spins up a CS2 Docker container with MatchZy
5. **Connect**, 3-minute join window, click connect or use `steam://connect/` link
6. **Play**, Knife round, side selection, competitive match (MR12) with live web scoreboard
7. **Results**, Stats recorded, MMR updated via Elo system, server cleaned up

## Features

### Matchmaking & Rating
- MMR-based team balancing with configurable Elo engine (all parameters adjustable via admin dashboard)
- Dynamic K-factor, round margin adjustment, individual performance modifiers
- 10-match placement system, inactivity decay, 9 rank tiers
- Concurrent-safe matchmaker using `SELECT ... FOR UPDATE SKIP LOCKED`

### Game Server Management
- Dynamic CS2 container provisioning via Docker SDK
- Port pool allocation (27015-27064, up to 50 concurrent matches)
- MatchZy plugin: player whitelisting, knife round, side pick, round-by-round webhooks
- Automatic cleanup and orphan recovery on restart

### Admin System
- **Owner**: Live system monitoring (CPU/RAM/disk per container via SSE), platform config, maintenance mode
- **Admin**: User management, Elo config editor, audit log, analytics
- **Moderator**: Support ticket queue, user lookup, match management
- Full audit trail for all staff actions

### Support & Moderation
- Ticket system with priority levels and SLA targets
- Ban system with warnings, temp/perm bans, appeal flow
- Abandon detection with escalating cooldowns

## API Endpoints

### Public
```
POST   /api/auth/register         Create account
POST   /api/auth/login            Login (returns JWT)
GET    /api/auth/steam            Steam OpenID redirect
GET    /api/auth/steam/callback   Steam verification
GET    /api/health                Health check (used by Traefik)
```

### Protected (JWT required)
```
POST   /api/auth/refresh          Refresh token
GET    /api/profile               Own profile + stats
GET    /api/profile/:username     Public profile
POST   /api/queue/join            Join matchmaking queue
POST   /api/queue/leave           Leave queue
GET    /api/queue/status          Queue status
GET    /api/matches               Match history (paginated)
GET    /api/matches/:id           Match details
GET    /api/matches/:id/live      Live scoreboard (SSE)
GET    /api/matches/:id/stream    Match event stream
GET    /api/leaderboard           Top players by MMR
POST   /api/support/tickets       Create ticket
GET    /api/support/tickets       List own tickets
```

### Staff (role-gated)
```
GET    /api/mod/users/:id         User details (moderator+)
POST   /api/mod/users/:id/ban     Issue ban (moderator+)
POST   /api/mod/matches/:id/cancel  Cancel match (moderator+)
PATCH  /api/admin/users/:id       Edit user (admin+)
PUT    /api/admin/elo/config      Update Elo parameters (admin+)
GET    /api/admin/audit-log       Audit trail (admin+)
GET    /api/owner/system          System metrics (owner)
GET    /api/owner/system/stream   Live metrics SSE (owner)
POST   /api/owner/maintenance     Toggle maintenance (owner)
```

### Internal (orchestrator <-> API, shared secret auth)
```
GET    /api/internal/match-config/:id   Match config for MatchZy
POST   /api/internal/match-events       Match lifecycle webhooks
POST   /api/internal/demo-upload        Demo file upload
```

## Project Structure

```
tapper/
├── docker-compose.yml      # Full stack orchestration
├── .env.example            # Environment variables template
├── backend/                # Go API (Fiber, GORM, JWT, matchmaker)
├── orchestrator/           # Go service (Docker SDK, RCON, port management)
├── gameserver/             # CS2 Docker image (Metamod + MatchZy)
├── frontend/               # React SPA (auth, queue, live match, admin)
├── traefik/                # Traefik configuration
└── loadtest/               # k6 test scripts
```

## Production Deployment

```bash
# Minimum: 8 cores, 32GB RAM, 100GB SSD (supports 2-3 concurrent CS2 matches)

# Firewall
ufw allow 80/tcp && ufw allow 443/tcp
ufw allow 27015:27064/tcp && ufw allow 27015:27064/udp

# Set PUBLIC_HOST to your domain in .env
# Traefik handles TLS via Let's Encrypt automatically
```
