Skip to content

How the game talks to the platform

A Trackmania 2020 server doesn't call the backend directly. When you cross the finish line, the news travels as a message — dropped onto a queue, picked up whenever the backend is ready. That queue is Redis Streams, and it's the one road between the game servers and the platform.

The controller running next to each server is kontrol (a MiniControl fork with Kacky plugins). It watches the game, and when something happens it publishes a message. The backend reads those messages and writes them down. Neither side talks to the other directly.

Subjects: every message has a name

Messages are namespaced by subject — a short name that says what happened. They group by topic:

  • player.*player.connect, player.finish, player.sync, nickname changes.
  • server.* — server roster sync, map start/end/switch, the event map list.
  • map.* — karma votes, map records, analytics.
  • plus admin.*, leaderboard.get, notification.*, achievement.*.

Each subject has exactly one handler on the backend. player.finish is written down as a finish; server.sync proves a server is alive. (Finishes aren't the same as records — see Records vs Finishes for why.)

Most messages flow one way, game → backend. A few need an answer: subjects like map.getRecords, leaderboard.get, and server.getEventMaps carry a correlationId and a replyTo, so the backend can send its reply back to the exact kontrol instance that asked.

Following a finish

sequenceDiagram
    participant TM as Trackmania server
    participant K as kontrol
    participant R as Redis Streams
    participant API as Backend
    participant C as Website / Discord bot

    TM->>K: you crossed the line
    K->>R: publish player.finish
    R->>API: backend consumes it
    API->>API: write the finish to PostgreSQL
    API-->>C: rankings over REST, live status over SignalR

Why a queue instead of a phone call

The game servers and the backend live on their own schedules. Servers restart, redeploy, and drop offline; the backend deploys whenever it deploys. A queue lets them ignore each other's downtime — kontrol drops the message and moves on, the backend picks it up when it's back. Streams have no expiry, so a message waits until it's read.

The catch: the contract is loosely typed

Here's the part that bites. A stream message is just JSON. The backend checks its shape, not its meaning. There's no shared type system spanning the two codebases — kontrol writes fields by name, the backend reads fields by name, and nothing forces them to agree.

A renamed field fails silently

Rename a field on one side and forget the other, and nothing errors. The message still arrives, still looks valid — the value just quietly goes missing. No exception, no log, no red flag. It only shows up as wrong numbers on the website, later. Treat the message shapes like a versioned API: kontrol and the backend change together, or not at all.

The same trap hides in values. gameType on player and server messages is read case-insensitively and falls back to TRACKMANIA when it doesn't recognise the string — so a typo doesn't crash, it just files the data under the wrong game.

The bigger picture — who owns what, and how the pieces connect — is in the Architecture overview.