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).
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:
Where is the raw value at time , is the smoothed value, and controls responsiveness. I found 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
α 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:
| Sector | Setup A (Stock) | Setup B (Tuned) | Delta |
|---|---|---|---|
| S1 | 28.341 | 27.892 | -0.449 |
| S2 | 43.128 | 42.701 | -0.427 |
| S3 | 31.205 | 30.884 | -0.321 |
| Total | 102.674 | 101.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));
});
});
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
Lessons Learned
“The fastest lap is the one where you don’t fight the car.” — some racing engineer, probably
Key takeaways:
- Buffers matter — the default UDP buffer sizes are too small for high-rate data. Always increase them.
- Smooth early — apply EMA filtering as close to the source as possible. It reduces noise before it propagates through your pipeline.
- Decouple stages — the listener, storage, and visualization should be independent processes. One slow DB write shouldn’t block the live dashboard.
- 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
Loading comments...