Pitwall is a self-hosted telemetry app for the F1 games, currently built against F1 26. The in-game HUD is a bad instrument panel (tire temps, brake temps, and damage are all buried or too small to read while driving), and comparing laps after a session meant switching between a third-party dashboard and EA RaceNet. The games already broadcast everything over UDP, so there was no real reason those two problems needed two separate tools.
The architecture
Four pieces:
Receiver → Processor → Storage → Display
The receiver pulls packets off the wire. The processor decodes them and builds the app’s own data model. Storage splits high-frequency telemetry from session/lap metadata. Display covers the live dashboard and post-session analysis, both fed from the same pipeline.
Decoding vs. modeling
The game sends session data, lap data, car telemetry, car status, motion, damage, and more, spread across different packet types at different rates. Writing all of that straight into a database the way it arrives doesn’t work: weather changes once a session, throttle updates 60 times a second, and storing both at the same grain is a mess. The packet format is also shaped around how the game broadcasts state, not around the questions I want to ask later. It answers “what did the game send,” not “how did lap 12 compare to lap 15.”
So decoding and modeling stay separate. The decoder (backend/udp/decoder.py) turns bytes into structured packets and nothing else. Everything downstream (backend/ingestion/pipeline.py) builds the actual application model: sessions, laps, canonical telemetry samples. A CanonicalPlayerState object merges the latest telemetry, lap, and status data into one sample per car-telemetry packet (~60Hz), and lap boundaries are detected by watching current_lap_num change in the lap-data stream. Buffering happens per active lap in memory; Postgres and Parquet only get touched at session start and lap boundaries, not per packet.
One wrinkle specific to this game: flashbacks. Rewinding time mid-lap is normal in practice sessions, and if you don’t account for it, a rewound lap’s telemetry corrupts the original attempt. Pitwall compares two frame counters the game provides, frame_identifier (which can rewind after a flashback) against overall_frame_identifier (which never does). If the first goes backwards while the second keeps climbing, that’s a flashback, and the in-progress lap gets finalized as superseded before a new attempt starts.
Data model
Grains, not one wide table:
- Session: track, weather, session type, setup context
- Lap: times, sectors, validity, tire compound, one row per attempt (including invalid and superseded ones)
- Telemetry trace: the 60Hz stream: speed, throttle, brake, steering, temps, and more
Each gets its own shape instead of getting flattened into one table, which would mean repeating session-level fields across thousands of telemetry rows. One grain I originally planned for, stints spanning multiple laps on the same tires, hasn’t made it in yet. Tire compound just lives on each lap for now.
Storage: Postgres + Parquet
Sessions, laps, and lap summaries live in Postgres. Honestly, part of that was familiarity: it’s what I use at work, so it’s what I reached for. But it also fits the access pattern: that data is small, relational, and queried constantly, things like “what sessions exist” or “what’s the fastest valid lap.”
The raw 60Hz telemetry is a different problem. A single lap can carry 25+ signals (tire temps, tire pressures, brake temps, G-forces, ERS state, and more), and most analysis only touches four or five at once. A basic lap comparison mostly needs distance, speed, throttle, brake, steering. That’s what pushed me toward Parquet, stored one file per lap under session_id=/lap_id=/telemetry.parquet. It’s columnar, so a query only reads the columns it needs, and because each lap is its own file, comparing two laps means reading exactly two files instead of scanning a shared table.
Postgres answers “what data exists.” Parquet answers “what exactly happened during this lap.” The object store sits behind a small interface with one implementation today (local filesystem), so an S3 or R2 backend can drop in later without touching callers.
Comparing laps by distance, not time
This was the most interesting problem in the project. Comparing two laps sample-by-sample sounds trivial until you remember laps almost never take the same amount of time. At 60Hz, comparing sample 192 from lap A to sample 192 from lap B just tells you what each car was doing about 3.2 seconds in, which stops meaning anything the moment one car brakes half a second later than the other.
The fix is aligning by distance instead: what were both cars doing when each had traveled 850 meters around the lap? Every trace gets resampled onto a fixed distance grid (5-meter bins by default). Continuous fields like speed and throttle are linearly interpolated between the two nearest raw samples; discrete fields like gear and DRS carry forward the last known value instead of interpolating, since “gear 3.4” isn’t meaningful. Once both laps sit on the same distance axis, the delta between them is a plain join on distance bin.
The live dashboard
Telemetry gets pushed to the frontend over WebSockets as the session runs, on a channel per packet type (telemetry, lap data, session, car status, and so on), so clients subscribe to only what they need. Gear and RPM weren’t in the original scope, since the in-game HUD already handles those fine, but once the pipeline existed for the things I actually cared about, adding a couple more fields was nearly free.
There’s still some perceptible latency in the live view that I want to chase down, somewhere between packet receive, processing, serialization, and the WebSocket hop. Haven’t isolated exactly where yet.
Developing without driving
Testing the dashboard by launching F1 and actually driving got old fast. There’s a synthetic UDP sender (backend/scripts/demo_udp_sender.py) that builds real F1-shaped packets, same wire format the game uses, and streams a fake lap with braking zones, DRS zones, tire temp drift, and fuel burn. From the app’s side, there’s no difference between that and a real session. A second script feeds synthetic telemetry through the ingestion pipeline directly to seed a realistic multi-lap demo session (pit stop, an invalid lap, a mid-lap flashback) for testing the historical views without a live stream at all.
The stack
Backend (FastAPI): UDP receiver, packet decoder, ingestion pipeline, WebSocket broadcaster, and the AI race engineer, all async. SQLAlchemy + Alembic for Postgres, PyArrow for Parquet.
Frontend (React + TypeScript): Live dashboard, lap comparison view, and chat UI for the race engineer, built with Vite. Frontend isn’t close to my day job, so Claude did a lot of the UI implementation while I focused on the data model and architecture.
AI (Claude API): A live race engineer that answers questions during a session using a TelemetryContext built from the same packet stream feeding the dashboard.
Current state
The live dashboard and distance-aligned lap comparison both work end to end, backed by real ingestion (flashback handling included) rather than a demo path. An early version of the AI race engineer answers questions about the current session but doesn’t yet look back over a whole session and summarize it. Stints aren’t fully modeled as their own entity yet. Dashboard latency is noticeable and unprofiled.
What’s next
Post-session analysis is the next real feature: instead of raw 60Hz telemetry going straight at a model, run a feature-extraction step first, braking points relative to a reference lap, throttle reapplication timing, corner minimum speeds, tire temp windows, and summarize that. That’s the same lesson as the Postgres/Parquet split applied one layer up: don’t hand a model more than it needs. It opens up questions a chart alone can’t answer: where did I lose the most time, why was Turn 6 slower, what changed after my tires warmed up.
Longer term I’d like the race engineer to be conversational, even live during a session, but that’s gated on fixing the latency problem first. Push-to-talk plus transcription plus an LLM plus TTS doesn’t feel like a race engineer on the radio if it’s slow.
Pitwall is open source on GitHub if you want to dig through the implementation or run it yourself.