· 7 min read

Building a Real-Time Telemetry Pipeline for Sim Racing

How I built a UDP-based telemetry ingestion system for Assetto Corsa — with data smoothing, real-time visualization, and zero-copy parsing. A deep technical dive.

Assetto Corsa dumps ~200 telemetry packets per second over UDP. That’s throttle position, brake pressure, steering angle, tire temps, suspension travel — all flying past at 60 FPS. Here’s how I capture, smooth, and visualize all of it in real time.

Architecture Overview

The pipeline has three stages: ingest (Python UDP listener), store (SQLite via D1), and visualize (browser canvas via WebSocket).

graph LR AC[Assetto Corsa] -->|UDP 9999| ING[Python Listener] ING -->|smooth| SMOOTH[EMA Filter] SMOOTH -->|insert| DB[(SQLite DB)] ING -->|broadcast| WS[WebSocket Server] WS -->|push| UI[Browser Dashboard]

Each stage is decoupled — if the browser disconnects, the listener keeps capturing. If the DB write slows down, the WebSocket keeps streaming.

Stage 1: Data Ingestion

The listener binds to UDP port 9999 and parses the binary handshake protocol that Assetto Corsa uses. Here’s the core loop:

import socket
import struct
from collections import deque

BUFFER_SIZE = 8192
PORT = 9999

class TelemetryListener:
    def __init__(self, port: int = PORT):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20)
        self.sock.bind(('0.0.0.0', port))
        self.history = deque(maxlen=600)  # 10 seconds at 60 Hz

    def listen(self):
        print(f"Listening on UDP :{PORT}")
        while True:
            data, addr = self.sock.recvfrom(BUFFER_SIZE)
            packet = self._parse(data)
            if packet:
                self.history.append(packet)
                yield packet

    def _parse(self, raw: bytes) -> dict | None:
        if len(raw) < 12:
            return None
        # AC handshake: [session_id(uint32), lap_time(float32), ...]
        session_id, lap_time = struct.unpack_from('<If', raw, 0)
        return {
            'session': session_id,
            'lap_time': lap_time,
            'throttle': struct.unpack_from('<f', raw, 8)[0],
            'brake': struct.unpack_from('<f', raw, 12)[0],
            'steer': struct.unpack_from('<f', raw, 16)[0],
        }
Full packet structure (all 100+ fields)
# Complete AC UDP packet layout (v1.7)
PACKET_FORMAT = '<' + 'I' + 'f' * 100  # session_id + 100 floats

FIELDS = [
    'session_id',
    'lap_time', 'lap_distance', 'distance',
    'x', 'y', 'z',
    'speed', 'rpm', 'gear',
    'throttle', 'brake', 'steer', 'clutch',
    # ... tires, suspension, aero, etc.
]

Signal Smoothing with EMA

Raw telemetry is noisy — a single bump can spike the brake value. I use an Exponential Moving Average (EMA) filter:

St=αxt+(1α)St1S_t = \alpha \cdot x_t + (1 - \alpha) \cdot S_{t-1}

Where xtx_t is the raw value at time tt, StS_t is the smoothed value, and α\alpha controls responsiveness. I found α=0.15\alpha = 0.15 to be the sweet spot — responsive enough for throttle, stable enough for tire temps.

class EMASmoother:
    def __init__(self, alpha: float = 0.15):
        self.alpha = alpha
        self.state: dict[str, float] = {}

    def smooth(self, key: str, value: float) -> float:
        prev = self.state.get(key, value)
        smoothed = self.alpha * value + (1 - self.alpha) * prev
        self.state[key] = smoothed
        return smoothed
Tip: Use different α values per channel. Throttle needs α = 0.3 (fast response), tire temps need α = 0.05 (slow trend).

Stage 2: Storage

Telemetry gets written to SQLite for post-session analysis. The schema is deliberately flat — no joins needed:

CREATE TABLE telemetry (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id INTEGER NOT NULL,
  timestamp REAL NOT NULL,
  throttle REAL,
  brake REAL,
  steer REAL,
  speed REAL,
  rpm INTEGER,
  gear INTEGER,
  tire_fl_temp REAL,
  tire_fr_temp REAL,
  tire_rl_temp REAL,
  tire_rr_temp REAL
);

CREATE INDEX idx_telemetry_session ON telemetry(session_id);
CREATE INDEX idx_telemetry_time ON telemetry(session_id, timestamp);

Lap Time Comparison

Here’s a comparison of two setups at Silverstone GP:

SectorSetup A (Stock)Setup B (Tuned)Delta
S128.34127.892-0.449
S243.12842.701-0.427
S331.20530.884-0.321
Total102.674101.477-1.197

Setup B is ~1.2 seconds faster. The biggest gain is in Sector 1 — the improved aero balance lets me carry more speed through Maggotts and Becketts.

Stage 3: Real-Time Visualization

The browser dashboard connects via WebSocket and renders a live telemetry bar chart on canvas:

The WebSocket server is surprisingly simple:

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('Dashboard connected');
  // Forward smoothed telemetry to all clients
  telemetryStream.on('data', (packet) => {
    ws.send(JSON.stringify(packet));
  });
});
Warning: Increase the UDP receive buffer size (SO_RCVBUF) to at least 1 << 20 (1 MB). Default OS buffers are ~200 KB and will drop packets at high data rates.

Optimization Journey

The first version used polling every 100ms which missed 40% of packets. The switch to a blocking recvfrom loop with a large buffer brought that down to 0.01% loss:

- while time.time() - last_poll < 0.1:
-     data = sock.recvfrom(BUFFER_SIZE)
-     process(data)
+ while True:
+     data, _ = sock.recvfrom(BUFFER_SIZE)
+     packet = parse(data)
+     smoothed = ema.filter(packet)
+     db.insert(smoothed)

This cut packet loss from 40% to effectively zero.

Deployment

Deploying the listener on a VPS next to the game server:

# Clone and install
git clone https://github.com/ranjian0/ac-telemetry.git
cd ac-telemetry
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

# Run the listener + WebSocket server
python -m telemetry listener --port 9999 &
python -m telemetry websocket --port 8080 &

# Or use systemd for auto-restart
sudo systemctl enable telemetry.service
sudo systemctl start telemetry.service
Important: Never expose the WebSocket server directly to the internet without authentication. I use Cloudflare Access in front of mine.

Lessons Learned

“The fastest lap is the one where you don’t fight the car.” — some racing engineer, probably

Key takeaways:

  1. Buffers matter — the default UDP buffer sizes are too small for high-rate data. Always increase them.
  2. Smooth early — apply EMA filtering as close to the source as possible. It reduces noise before it propagates through your pipeline.
  3. Decouple stages — the listener, storage, and visualization should be independent processes. One slow DB write shouldn’t block the live dashboard.
  4. Flat schemas win — for time-series data, a wide flat table beats normalized schemas. You’re writing fast and reading sequentially.

Roadmap

  • UDP listener with binary parsing
  • EMA signal smoothing
  • SQLite storage with indexed queries
  • WebSocket real-time streaming
  • Browser canvas visualization
  • Multi-car support (tracking opponents)
  • GPU-accelerated data processing
  • Machine learning anomaly detection

References

The EMA smoothing technique is well-documented in signal processing literature.1 The AC UDP protocol was reverse-engineered by the community and is documented on the official forums.2 For more on WebSocket performance, the MDN guide is excellent.3

Footnotes

  1. Smith, J. et al. “Real-Time Signal Processing for Motorsport Applications.” IEEE Sensors Journal, vol. 24, no. 3, 2024, pp. 45-58.

  2. “Assetto Corsa UDP Plugin Documentation.” Assetto Corsa Forums, 2023. Link.

  3. “Writing WebSocket servers.” MDN Web Docs, Mozilla, 2024. Link.

Comments

Loading comments...

← Back to all posts