Open-source live-standings API for AoE2: DE tournaments. One deployment serves multiple tournaments — each a named roster of players on a leaderboard — tracking current ratings, max ratings, recent match history, win/loss streaks, and live-match detection. Brackets and branding stay in consumers; rosters, dates, and teams live here, so every consumer reads consistent, denormalized standings.
The upstream data layer is documented in docs/data-sources.md.
Age of Empires II © Microsoft Corporation. AoE2 Live Standings API was created under Microsoft's Game Content Usage Rules using assets from Age of Empires II and it is not endorsed by or affiliated with Microsoft.
- Architecture
- Tech Stack
- Requirements
- Quick Start
- Running with Docker
- API Documentation
- API Endpoints
- Authentication
- Consuming the API from Your Own Frontend
- Logging, Telemetry & Feature Flags
- Database Migrations
- Testing
- Linting & Formatting
- Git Setup & Pre-commit Hooks
- Project Structure
- Environment Variables
- License
Two Cloud Run services share one Postgres database and one container image, differentiated only by env vars (POLLING_ENABLED / LISTENER_ENABLED):
- worker — a pinned singleton (
max=1, private — no public traffic;minfollows the infraevent_mode). Polls the upstream Relic backend (aoe-api.worldsedgelink.com/community/*, seedocs/data-sources.md) on three cadences (30 s / 60 s / 15 s), writes to Postgres, and bumps a per-event row in the tinynudge_versionstable inside the same transaction whenever data changes (tagged with the tournaments the cycle touched). - api — autoscaling read tier (public; scaling driven by the infra
event_mode). Serves the/v1/*REST endpoints and the SSE/v1/stream. Each instance pollsnudge_versionsthrough its pooled engine every couple of seconds and fans nudges out to its local SSE subscribers — no session-pinnedLISTENconnection anywhere, so DB backends stay decoupled from instance count.
Relic backend (upstream)
▲
│ poll
│
┌─────────┴────────┐
│ worker service │ singleton; writes + nudge bump
│ (private, │
│ min=max=1) │
└─────────┬────────┘
│ write + nudge bump (in transaction)
▼
┌──────────────────┐
│ Postgres │
│ (snapshot) │
└─────────┬────────┘
│ read + poll nudge_versions
▼
┌──────────────────┐
│ api service │ autoscaled; serves /v1/* + SSE
│ (public, │
│ autoscaled) │
└─────────┬────────┘
│ /v1/* + SSE nudges
▼
consumers
(web client)
Reads are denormalized: each response row carries everything a consumer needs to render it, so consumers never fan out or join across endpoints.
In local development (and tests) both flags default true, so a single uvicorn process runs everything — mono mode. Tests bypass the lifespan entirely via ASGITransport.
| Component | Technology |
|---|---|
| Framework | FastAPI |
| Database | PostgreSQL (async via asyncpg) |
| ORM | SQLAlchemy 2.0 |
| Migrations | Alembic |
| Rate Limiting | slowapi |
| Logging | structlog |
| Telemetry | OpenTelemetry |
| Package Manager | uv |
| Containerization | Docker / Docker Compose |
| Testing | Pytest (async via pytest-asyncio) |
| Linting/Formatting | Ruff |
| Git Hooks | pre-commit |
- Python 3.12+
- uv
- Docker & Docker Compose (for local development)
# Copy environment file
cp .env.example .env
# Install dependencies
uv sync
# Start PostgreSQL
docker compose up -d db
# Run migrations
uv run alembic upgrade head
# Start the API
uv run uvicorn app.main:app --reloadThe API will be available at http://localhost:8000.
docker compose up # foreground (API + PostgreSQL + Adminer)
docker compose up -d # detachedAdminer is available at http://localhost:8080. Login: System=PostgreSQL, Server=db, User=postgres, Password=postgres, Database=aoe2_live_standings.
Once running, visit:
- Scalar UI: http://localhost:8000/docs
- OpenAPI JSON: http://localhost:8000/openapi.json
The OpenAPI spec is consumed by the companion consumer projects (see e.g. hera-streamer-invitational-2026-web) via orval to generate type-safe React Query hooks. Run the generator from the consumer side after starting this API locally (or after pointing at a deployed instance).
All application routes are served under the /v1 prefix so the API surface can be versioned as a whole. Infrastructure routes (/, /health, /docs, /openapi.json, /.well-known/security.txt) stay unversioned. Routers are registered in the ROUTERS tuple in app/main.py, which loop-mounts each one with the /v1 prefix.
Most of the API is scoped to a tournament:
GET /v1/tournaments— list tournaments;GET /v1/tournaments/{slug}— one tournamentGET /v1/tournaments/{slug}/standings— the tournament's standingsGET /v1/tournaments/{slug}/teams/standings— the tournament's team standingsGET /v1/tournaments/{slug}/matches,.../matches/{match_id}— match feed and detailGET /v1/tournaments/{slug}/live— the roster's live matchesGET /v1/tournaments/{slug}/players,.../players/{profile_id}— roster and player detail
Unscoped: GET /v1/leaderboards (leaderboard metadata), GET /v1/stream (SSE refresh nudges), GET /v1/flags (feature flags).
The reserved slug current resolves to the most recently started tournament (latest start_date <= now, then latest created_at as fallback), so external probes (uptime monitors, status pages) can hit any per-tournament read path without an infra redeploy across event rollovers. POST /v1/tournaments rejects current as a literal slug to keep the alias unambiguous. Event-specific frontends should pin to their literal slug; reserve current for tournament-agnostic infrastructure callers.
GET /v1/me— identity (user_id) plus the list of tournaments the caller owns. One round-trip lets the frontend gate admin UI without per-tournament probes. 401 when unauthenticated.
The management API lets a tournament host edit configuration without a redeploy. Every write route is gated — see Authentication. Writes accept an optional Idempotency-Key: <uuid> header to dedupe retries (same key + same body → cached response).
POST /v1/tournaments— create a tournament. Creation is invite-gated: the caller must be on the operator-approved creator allowlist (tournament_creators;GET /v1/meexposescan_create_tournaments), and becomes the first owner.DELETE /v1/tournaments/{slug}— delete the tournament and everything tournament-scoped (cascades to roster, teams, owners).PATCH /v1/tournaments/{slug}— edit a tournament's name, dates, leaderboard, rank metric (rank_by), prize pool, host stream URLs, or presentation bagGET /v1/tournaments/{slug}/owners— list owners;POSTto grant ownership to another criticalbit user;DELETE .../owners/{user_id}to revoke. Revoking the last owner is rejected (the tournament would become uneditable).POST /v1/tournaments/{slug}/players— add a profile to the roster;DELETE .../players/{profile_id}— remove onePOST /v1/tournaments/{slug}/teams— create a team;PATCH/DELETE .../teams/{team_id}— edit or delete onePOST /v1/tournaments/{slug}/teams/{team_id}/members— add a team member;DELETE .../members/{profile_id}— remove one
See /docs or /openapi.json for the full, authoritative spec.
Reads are public. The write/management API is authenticated against criticalbit-auth-api, the shared criticalbit.gg SSO service:
- Authentication — a write request must carry a valid
criticalbit_accesscookie (an RS256 JWT issued by criticalbit-auth-api). The API verifies it against that service's public JWKS endpoint (AUTH_JWKS_URL); a missing or invalid token is a401. - Authorization — a verified token identifies a criticalbit user. To edit a tournament, that user must have a row in this service's
tournament_ownerstable for it, or the request is a403. Ownership is per-tournament and modelled here — not in the auth service, which deliberately stays free of app-specific roles.
Ownership is granted and revoked through the owners API above. Tournament creation has one extra gate: an operator-managed tournament_creators allowlist (rows inserted out-of-band — approval is a human decision, and the platform deliberately has no admin role to automate it). A roster edited through this API is picked up by the polling worker on its next cycle, with no redeploy.
The read surface is public, and third-party frontends are welcome to build on it:
- Server-side callers (SSR, bots, overlays rendered through a proxy) need no configuration — every
GET /v1/*read is unauthenticated. - Browser-based apps work from any origin, no onboarding: public
/v1GETs answer cross-origin requests withAccess-Control-Allow-Origin: *(no credentials). The one exception isGET /v1/me— per-user and cookie-authenticated, it stays first-party-only. - Keep read requests simple (plain
GETs, no custom headers) — the open posture answers simple requests only, and the read surface needs nothing else. The SSE stream (GET /v1/stream) qualifies:EventSourcesends no custom headers. - Each nudge's payload carries
tournament_ids:nullmeans every subscriber should refetch; a list means only clients watching one of those tournaments need to. Filtering on it is optional — ignoring the field just costs wasted refetches. - Reads are CDN-cached (
s-maxage=15): prefer reacting to SSE nudges over aggressive polling timers.
The write/management API is first-party only. It authenticates with the operator's SSO cookie, and credentialed cross-origin access is deliberately restricted to the operator's own origins (the CORS_ORIGINS env var + first-party regex) — tournament organizers manage tournaments through the platform's management UI, not through third-party apps.
Logging uses structlog for structured output. In development you get colored console logs; in production, JSON.
Every request is assigned a unique X-Request-ID header (or reuses one from the incoming request), and it's automatically bound to all log entries for that request.
Configure via LOG_LEVEL env var (default: INFO).
OpenTelemetry tracing is included but disabled by default. To enable, set OTEL_ENABLED=true and point OTEL_EXPORTER_ENDPOINT at your collector (e.g. Jaeger, Grafana Tempo). FastAPI is auto-instrumented — no code changes needed.
Cloud Monitoring covers the prod deployment. Alert policies live in infra/terraform/monitoring.tf (poller silent-failure, upstream rate-limit) and infra/terraform/capacity_alerts.tf (Cloud Run concurrency, SQL CPU/connections).
A single-pane event-day dashboard (request rate, latency percentiles, instance counts, Postgres connections + CPU, poller per-task ok rate, upstream 429s) is defined in infra/terraform/dashboard.tf and lives at console.cloud.google.com/monitoring/dashboards/builder/8926650c-e0a2-45e6-bb1a-d2f0d02f04bc. The deploy also outputs this URL as event_day_dashboard_url so it survives a destroy-recreate without manual lookup.
Feature flags are read from FEATURE_* environment variables at startup (no database required). Set FEATURE_<NAME>=true or false in your .env.
The GET /v1/flags endpoint returns all flags as a JSON object.
Use the get_feature_flags() dependency in route handlers to check flags server-side via flags.is_enabled("flag_name").
This project uses Alembic for database migrations.
- Edit a model in
app/models/ - Make sure the model is imported in
alembic/env.py(so autogenerate can detect it) - Generate a migration:
uv run alembic revision --autogenerate -m "description of change" - Review the generated file in
alembic/versions/(autogenerate can miss some changes) - Apply the migration:
uv run alembic upgrade head
- Commit both the model change and the migration file
# Apply all pending migrations
uv run alembic upgrade head
# Rollback one migration
uv run alembic downgrade -1
# See current migration status
uv run alembic current
# See migration history
uv run alembic history
# Generate a migration without applying
uv run alembic revision --autogenerate -m "description"Tests use SQLite in-memory for speed and isolation.
# Run all tests
uv run pytest
# Verbose
uv run pytest -v
# With coverage
uv run pytest --cov=appThe test harness provides a client fixture (an unauthenticated async HTTP client), a session fixture (a direct async SQLAlchemy session for test setup), and an auth_as fixture that authenticates the client as a given user for write-endpoint tests.
This project uses Ruff for linting and formatting.
# Lint
uv run ruff check .
# Auto-fix
uv run ruff check --fix .
# Format
uv run ruff format .
# Check formatting without changes
uv run ruff format --check .Install pre-commit hooks so ruff runs automatically on every commit:
uv run pre-commit install
uv run pre-commit run --all-files # one-time run across the repoaoe2-live-standings-api/
├── app/
│ ├── auth/ # JWT verification + tournament-owner authorization
│ ├── models/ # SQLAlchemy models
│ ├── routers/ # FastAPI routers, mounted under /v1
│ ├── schemas/ # Pydantic request/response schemas
│ ├── config.py # Settings (env-backed) + production validation
│ ├── database.py # Async SQLAlchemy setup
│ ├── features.py # Feature flags (env-var backed) + /v1/flags
│ ├── logging.py # structlog configuration
│ ├── telemetry.py # OpenTelemetry setup
│ └── main.py # App entry point, middleware, infra routes
├── alembic/
│ ├── versions/ # Migration files
│ └── env.py # Alembic configuration
├── docs/
│ └── data-sources.md # Upstream data source notes
├── tests/
│ ├── conftest.py # Fixtures (client, session)
│ └── test_app.py # security.txt + rate-limit-exemption tests
├── .env.example
├── .pre-commit-config.yaml
├── .python-version # pyenv Python version
├── docker-compose.yml # API + PostgreSQL + Adminer
├── Dockerfile
└── pyproject.toml
"Required" means the value must be set when ENVIRONMENT=production — production config validation rejects defaults for these. Local development runs out of the box with no env vars set.
| Variable | Required | Description | Default |
|---|---|---|---|
ENVIRONMENT |
Optional | development or production |
development |
DATABASE_URL |
Required | PostgreSQL connection string | postgresql+asyncpg://postgres:postgres@localhost:5432/aoe2_live_standings |
CORS_ORIGINS |
Required | Comma-separated allowed origins | (empty — dev uses localhost:5100-5199) |
LOG_LEVEL |
Optional | Logging level | INFO |
OTEL_ENABLED |
Optional | Enable OpenTelemetry tracing | false |
OTEL_SERVICE_NAME |
Optional | Service name for traces | aoe2-live-standings-api |
OTEL_EXPORTER_ENDPOINT |
Optional | OTLP gRPC collector endpoint (used when OTEL_USE_CLOUD_TRACE is false) |
http://localhost:4317 |
OTEL_USE_CLOUD_TRACE |
Optional | Export spans directly to Google Cloud Trace via the native exporter (prod) | false |
OTEL_TRACES_SAMPLE_RATIO |
Optional | Fraction of incoming traces to sample (1.0 = 100%, 0.1 = 10%) | 1.0 |
SENTRY_DSN |
Optional | Sentry project DSN. Empty disables Sentry init entirely | (empty) |
FEATURE_* |
Optional | Feature flags (e.g. FEATURE_ERROR_ENVELOPE_V2=true) |
(none) |
POLLING_ENABLED |
Optional | Start the three upstream pollers in this process (worker service) | true |
LISTENER_ENABLED |
Optional | Start the nudge poll loop + SSE fan-out in this process (api service) | true |
UPSTREAM_BASE_URL |
Optional | Relic upstream base URL | https://aoe-api.worldsedgelink.com |
AUTH_JWKS_URL |
Optional | JWKS endpoint used to verify the write API's access tokens | https://auth-api.criticalbit.gg/auth/jwks |
AUTH_TOKEN_ISSUER |
Optional | Expected JWT iss claim; when set, tokens with a different issuer are rejected |
(empty — issuer not checked) |
Before deploying to production, replace the placeholder Contact: in the SECURITY_TXT constant (app/main.py) with a real security-disclosure address and bump Expires: if it's close.
Apache 2.0 — see LICENSE.