Architecture overview¶
How the pieces fit together — from a player finishing a map in-game to a number on the website.
The integration spine¶
flowchart LR
subgraph game[In-game]
TM[Trackmania dedicated servers]
K[kontrol controllers]
TM <--> K
end
subgraph platform[Kacky platform]
API[Backend API<br/>.NET / ASP.NET Core]
DB[(PostgreSQL)]
API <--> DB
end
subgraph clients[Clients]
WEB[Website]
ADMIN[Admin panel]
BOT[Discord bot]
end
K -- "Redis Streams<br/>player.* server.* map.*" --> API
API -- "REST + SignalR" --> WEB
API -- "REST" --> ADMIN
API -- "notification.* streams" --> BOT
- kontrol — the in-game controllers (a MiniControl fork plus Kacky plugins) drive the Trackmania dedicated servers and report what happens to the backend.
- Redis Streams — the sole message bus between kontrol and the backend. Subjects are namespaced
(
player.*,server.*,map.*,admin.*), with request/reply correlated by id. - Backend API — a .NET / ASP.NET Core service. It consumes the streams, persists to PostgreSQL, and serves the clients over REST + SignalR. It is the owner of all truth.
- Clients — the website (player stats, rankings, live status), the admin panel (event/user/server
management), and the Discord bot (which also consumes
notification.*streams).
Why a message bus, not direct calls¶
The game servers and the backend have independent lifecycles — servers restart, deploy, and go offline; the backend deploys on its own cadence. A message bus decouples them: kontrol publishes what happened and the backend consumes at its own pace, with automatic recovery if either side restarts. It also means the contract between them is a set of message shapes, not a synchronous API — which is powerful but loosely typed, so the two repos must change their payloads in lockstep.
The contract is loosely typed — treat it like a versioned API¶
The stream payloads are validated for shape but not meaning. A field renamed on one side and not the other fails silently at runtime rather than erroring. So the discipline is:
- Meaning-bearing field names — never overload one field for two concepts.
- Breaking changes ship backend-first, additively (expand/contract), never as a hard rename.
- Each stream handler is the source of truth for its payload; the consumer's schema mirrors it.
Source of truth, by layer¶
| Layer | Source of truth |
|---|---|
| API shape (for the frontends) | The backend OpenAPI spec — clients are generated, never hand-written. See API. |
| Ranks / world records / personal bests | The online-only Record ledger → mv_map_rankings. See Records vs Finishes. |
| Finish counts / history | Raw Finish rows. |
| Game-server liveness | The server.sync stream — a server that syncs is alive; no sync for >5 min marks it offline. |
Where the code lives¶
The platform is a monorepo. The backend is src/KackyGG.Api/ (.NET, EF Core, MediatR/CQRS,
PostgreSQL, Redis Streams); the website and admin panel are React apps; the Discord bot is a
Discord.js/TypeScript app. Infrastructure (Hetzner, Cloudflare, Ansible) lives in a sibling repo.
Deeper contributor docs
The operational, agent-facing detail (how to build, test, and work in each component) lives in
the repo's AGENTS.md and .claude/ rules. This site is the shared reference; those are the
operating manual. They link to each other and deliberately don't duplicate.