Skip to content

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.