Racing Games Project

October 01, 2026 12:13am

From GTR2 log files to AI-written race reports: how the Racing module imports, classifies and reports on thousands of sim-racing sessions.

Overview

The Racing module is the home for every sim-racing session I kept a log of since 2008 that isn't iRacing, DiRT or GP2: GTR2, Automobilista 1 and 2, rFactor 1 and 2 and Assetto Corsa. It started as a plain list of results and, over a few rounds of revision, became the most connected part of the site: log importers for two different sims, championships with configurable points systems, full standings grids, embedded videos and, most recently, AI-written race reports in the Formula Duude style, embedded right into the results page.

Today it holds over 8,800 sessions, 42,000 result rows and 1,500 drivers, most of them AI opponents from offline GTR2 races.

Technical Stack

Area Technologies
Backend PHP 8.3, OOP (RacingRepo / RacingRender)
Database MySQL 8.0 via PDO, two-connection model (read-only / admin)
Importers GTR2 result logs (INI-style text), Automobilista 2 result files
AI Anthropic Messages API (structured JSON output, prompt caching), n8n pipeline with a local model
Frontend Server-rendered HTML, vanilla JS for share links and table sizing

Architecture & Design

Same Repo/Render split used across the site: RacingRepo owns every query and write, RacingRender turns arrays into HTML. The data model is small but dense:

  • racing_race: one row per session (race, qualifying, practice), with simulator, track, optional championship, optional points system and an optional 1:1 link to a race report article.
  • racing_result: one row per driver per session, with position, class position, grid, laps, race time and best lap. It is UNIQUE on (race, position) and (race, driver), so the database itself refuses a shared position or a driver listed twice.
  • racing_driver, racing_team, racing_track, racing_champ: lookup entities, created on the fly by the importers.
  • racing_points_systems / racing_points_positions / racing_points_extra: reusable scoring rules, plus extra points awarded by hand for things the simulator can't report on its own, such as pole position, fastest lap or most laps led.

Import pipeline

A GTR2 log mixes session metadata with one [SlotNNN] block per car. The importer reads it into slots, cleans them up, estimates the grid and sorts the classification (laps descending, then race time, then distance travelled) before writing anything. Drivers, teams and tracks are resolved or created along the way.

Results page

Each session page is assembled in a fixed order: quick stats, videos, the race report (when one exists and is published), the classification, championship standings as they stood after that round, and finally the car liveries and track map in a lightbox gallery.

Key Features

  • Filterable session list: simulator, championship, track and session type, with pagination and a Share button that turns the current filters into a GET link.
  • Championships: driver and team standings, plus a full standings grid (one column per round, colour-coded podium, points, DNQ and non-participation cells).
  • Adaptive classification: the Grid and Points columns only appear when at least one row has a value, and nationality flags only when a flag image exists for someone in the session.
  • Race Reports: an admin button generates a Formula Duude-style article for any session. It can also link an existing hand-written article, or unlink one. Reports are drafts by default.
  • n8n integration: an external workflow can create reports through a token-gated endpoint, passing the session id so the article is linked on creation.

Challenges & Solutions

Challenge Solution
GTR2 logs have no starting grid The grid is estimated from qualifying times. When qualifying is skipped every AI gets the same made-up time, so ties keep the log order, which is exactly how GTR2 lines them up. The player lands on pole in that case and is fixed by hand in the admin.
Ghost slots in online logs Multiplayer logs sometimes repeat a driver as an empty slot (0 laps, DNF). Those duplicates are dropped before the driver lookup.
Two AI drivers with the same name The (race, driver) unique key would reject the second one. Instead, the importer creates NAME (2), NAME (3)… as their own drivers, aliased to the original and reused across races.
Editing positions by hand Creating, moving or deleting a result shifts everyone else's position and grid (an edit closes the old slot and opens the new one), so the unique keys never collide.
LLMs are bad at arithmetic The report prompt never asks the model to calculate. Margins, fastest-lap gaps, places gained, non-starters and missing positions are pre-computed in PHP and handed over as plain facts.
Two writers linking the same race The link is a conditional UPDATE that only succeeds if the link is still what was read a moment before, so concurrent requests can't both win.

Security & Best Practices

  1. Least privilege: public pages read through a read-only database user; only the admin and the importers use the write connection.
  2. Prepared statements for every value that comes from a log file, a form or the API.
  3. Machine-to-machine auth: the n8n endpoint uses a shared secret token instead of a login session, validates every category and block id it receives, and never publishes unless explicitly asked.
  4. Fail-soft AI: the API client never throws. An outage or refusal becomes a message on the admin screen, never a broken page.

Results & Learnings

The biggest lesson was that the data model should say no before the code does. Once the unique keys were in place, every importer bug turned into a clear error instead of a silently wrong classification. It also forced proper solutions for duplicate AI names and position shifting, rather than workarounds.

The second lesson came from the race reports: an LLM is a fantastic writer and an unreliable accountant. Splitting the job, facts from PHP and prose from the model, made the reports both funny and correct. (Mostly correct: the model still doesn't know about car classes, so a class win can read like a disappointing tenth. That's on the roadmap.)

The importer works like a race steward with a stack of timing sheets: it discards the phantom entries, works out who actually started where, and only then signs off the official classification. The press corps gets the results afterwards, and is free to be as dramatic as it likes.

Code Snippets

1. Estimating the GTR2 starting grid

private function estimateGTR2Grid(array $slots): array {
    $order = [];
    foreach (array_keys($slots) as $logOrder => $key) {
        $qual = self::timeToSeconds($slots[$key]['QualTime'] ?? null);
        $order[] = [
            'key' => $key,
            'tier' => $qual !== null ? 0 : 1,
            'time' => $qual ?? 0.0,
            'log' => $logOrder,
        ];
    }
    usort($order, fn($a, $b) => [$a['tier'], $a['time'], $a['log']] <=> [$b['tier'], $b['time'], $b['log']]);
    foreach ($order as $i => $row) {
        $slots[$row['key']]['grid'] = $i + 1;
    }
    return $slots;
}

2. Linking a report without race conditions

public function linkRaceArticle(int $raceId, int $articleId, $expectedCurrent): bool {
    $stmt = $this->pdoAdmin->prepare("UPDATE racing_race SET article_id = ? WHERE id = ? AND article_id <=> ?");
    $stmt->execute([$articleId, $raceId, $expectedCurrent]);
    return $stmt->rowCount() === 1;
}

3. Handing the model facts instead of arithmetic

$winner = $finishers[0];
$facts[] = "{$winner['driver']} won" . ($winner['grid'] ? " from grid position {$winner['grid']}" : '') . '.';
if (isset($finishers[1]) && $finishers[1]['gap_to_winner_s'] !== null) {
    $facts[] = "Winning margin over {$finishers[1]['driver']}: {$finishers[1]['gap_to_winner_s']}s.";
}

go to racing results