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.