iRacing Project

October 01, 2026 12:13am

Porting the old iRacing pages: two million result rows, multiclass and team races, and a few MySQL lessons along the way.

Overview

iRacing was the last of the old Horta Playing pages still living on the previous version of the site: a set of hand-written PHP pages I put together years ago, reading straight from a large database of iRacing results. This project ports them to the current site, with the same look and standards as every other module: pretty URLs, filters, pagination, share links, bound parameters and a mobile-friendly layout.

The dataset is by far the biggest on the site: about 45,000 sessions, 2.1 million result rows and 238,000 drivers, since every session stores the full field, not just my own result. My own share is 2,421 races since 2018.

Technical Stack

Area Technologies
Backend PHP 8.3, OOP (IRacingRepo / IRacingRender)
Database MySQL 8.0 via PDO, the original iRacing tables (read-only)
Frontend Server-rendered tables, CSS class badges, a small vanilla JS grid filter

Architecture & Design

Separate module, or part of Racing?

The first decision was whether iRacing should merge into the Racing module. It stayed separate, for three reasons:

  • The data has a different shape. One iRacing event holds several sub-sessions (practice, qualifying and race results together), multiclass and team races are common, and times are stored as integers in units of 1/10,000 of a second.
  • The scale is different. Folding 2 million rows into Racing's queries would have slowed down every Racing page.
  • The features barely overlap. Racing has championships, points and race reports; iRacing has track records, awards and career statistics.

What is shared: the page components, the table styles, the video table, and a new generic class badge that Racing can reuse once it stores car classes.

One query builder for every list

The session list, driver pages, car pages, track pages and series pages all show the same kind of list, so they share one filter set (driver, session type, year, series, car, track). Each page pins one of them: a car page, for example, always filters by that car and hides the car filter. With a driver selected, the query is based on results, one row per event showing that driver's main result. Without one, it's based on events.

Key Features

  • Session list: my races by default, newest first. The filters widen it to practice, qualifying, time trials, any Grip Racing driver, or everything stored.
  • Session results: one table per sub-session. Race gaps show time or laps down, other sessions show the gap to the fastest lap, and the fastest lap is highlighted. Multiclass races get a colour badge with the class position, and team races list each team with its drivers underneath.
  • Driver pages: a career summary per year (races, wins, top 3, top 10, average finish, poles, average start, laps and distance, including how many times around the Earth that is) plus all their sessions.
  • Cars and tracks: image galleries with a type-to-filter box, each with its best laps and sessions. Track pages list the other layouts and older versions of the same layout.
  • Series, records and awards pages, ported from the original site.

Challenges & Solutions

Challenge Solution
Numbers that didn't match The first career summaries disagreed with the original pages. Comparing year by year showed the development database simply had an incomplete copy of the results: the maintenance export never included these tables. It now has an opt-in switch to include them for a one-off refresh, and after that every year matched.
A subquery that took 2.5 seconds id_piloto IN (SELECT … FROM grip) made MySQL scan the whole results table. Binding the Grip driver ids as an explicit list lets it skip-scan per driver instead.
Counting sessions per track A correlated count per track took about 5 seconds, because the events table has no index on the track. One grouped subquery joined to the tracks brought it down to 0.2 seconds.
"No time" sentinels iRacing stores -1 or 9,999,999 for a lap that wasn't set. The same number is a perfectly valid race total after about 17 minutes, so the time formatter only treats it as "no time" for lap times.
Retired content iRacing prefixes old cars and tracks with [Legacy] or [Retired], and re-released tracks get new ids. Names are cleaned for display and sorting, and duplicate layouts are shown as "other versions".

Security & Best Practices

  1. Bound parameters throughout, including every filter. The original pages built SQL by string concatenation and relied on a keyword blocklist, which was the main thing to leave behind.
  2. Read-only by design: the module never writes, so it only ever uses the read-only database user.
  3. Validated input: filter values are checked against what they can be (ids, "all", "grip") before they reach a query, and everything rendered is escaped.
  4. Measured, not guessed: every heavy page was timed against the full dataset. Query changes were made only where the numbers showed a problem, and no indexes had to be added.

Results & Learnings

The old pages are fully replaced, the numbers match the originals, and the heaviest page now loads in under two seconds. The biggest learning was about how MySQL actually runs a query: two queries that look almost identical can differ by a factor of ten, and only measuring shows which is which.

It was also a good exercise in knowing when not to merge things. Keeping iRacing separate made it faster to build, safer for the existing Racing pages, and still let the two share what genuinely overlaps.

Porting the old pages was like moving a museum collection into a new building: the exhibits stay exactly as they were, but they get proper labels, better lighting, and a floor plan that makes sense.

Code Snippets

1. One event, one "main result"

// The resultado row that represents the event as a whole, per evento.id_tipoevento
// (race events also carry practice + qualifying rows; time trials are stored as "Lone Practice").
private const MAIN_RESULT = "((e.id_tipoevento = 5 AND r.tiporesultado = 'Race')
    OR (e.id_tipoevento = 3 AND r.tiporesultado LIKE '%Qualifying')
    OR (e.id_tipoevento IN (2, 4) AND r.tiporesultado LIKE '%Practice'))";

2. Binding the Grip drivers instead of a subquery

private function gripDriverPlaceholders(array &$params): string
{
    $ids = array_filter($this->getGripIds(), fn($id) => $id > 0) ?: [0];
    $names = [];
    foreach (array_values($ids) as $i => $id) {
        $names[] = ":g$i";
        $params["g$i"] = $id;
    }
    return implode(', ', $names);
}

3. Formatting iRacing times

public static function formatTime($t, bool $lap = true): string
{
    $t = (int)$t;
    if ($t <= 0 || ($lap && $t >= 9999999)) {
        return '';
    }
    $ms = intdiv($t, 10);
    $h = intdiv($ms, 3600000);
    $m = intdiv($ms % 3600000, 60000);
    $s = intdiv($ms % 60000, 1000);
    $frac = sprintf('%03d', $ms % 1000);
    if ($h) {
        return sprintf('%d:%02d:%02d.%s', $h, $m, $s, $frac);
    }
    if ($m) {
        return sprintf('%d:%02d.%s', $m, $s, $frac);
    }
    return sprintf('%d.%s', $s, $frac);
}

go to iracing results