SimStrategist is a Flask web app that displays and analyses live in-game telemetry from racing simulators. It supports F1 2018–2024 (Gen 2–5, via binary UDP packets), Le Mans Ultimate (LMU, via JSON over TCP/UDP), and Forza Horizon 4/5 (binary UDP, game version auto-detected from packet size). F1 2017 (Gen 1 legacy format) and Forza Horizon 3 are not supported.
A Claude-powered AI strategy analyser is built in, using real-time weather and tyre data to recommend pit strategies. The live telemetry dashboard also includes a Driver Inputs panel showing throttle, brake, clutch, and steering — switchable between a bar view and a scrolling graph.
Two independent processes must run simultaneously:
F1 game ──UDP:20777──▶ f1/server.py ──▶ f1/telemetry_state.py ──▶ app.py API routes ──▶ browser
(binary parse) (thread-safe singleton) (SSE / JSON)
LMU game ──TCP:5100──▶ lmu/server.py ──▶ lmu/telemetry_state.py ──▶ app.py API routes ──▶ browser
(JSON parse) (thread-safe singleton)
Forza Horizon ──UDP:20055──▶ forza_hrzn/server.py ──▶ forza_hrzn/telemetry_state.py ──▶ app.py API routes ──▶ browser
(FH4: 20044) (binary parse, (thread-safe singleton)
size-based version detect)
All listeners run as daemon threads inside app.py — you only need to start one process. However, f1/server.py can also be run standalone (useful when recording).
| File | Role |
|---|---|
app.py |
Flask app, starts background threads, defines all HTTP routes |
f1/server.py |
Parses binary F1 UDP packets; optionally records them |
f1/telemetry_state.py |
Thread-safe singleton with numpy circular buffers |
f1/config.py |
UDP settings, buffer sizes, UI colours |
f1/recorder.py |
Standalone UDP sniffer — saves packets to a .f1rec file |
f1/replayer.py |
Reads a .f1rec file and replays packets over UDP |
strategy/ai_strategy.py |
Calls Claude API to generate 3 race strategies |
strategy/weather_history.py |
Records weather samples every 10 s during a session |
lmu/server.py |
JSON-over-TCP/UDP listener for Le Mans Ultimate |
forza_hrzn/server.py |
Binary UDP listener for Forza Horizon 4/5; detects game from packet size |
forza_hrzn/telemetry_state.py |
Thread-safe singleton with numpy circular buffers (mirrors F1 interface) |
forza_hrzn/config.py |
UDP ports (FH4: 20044, FH5: 20055), buffer sizes, version map |
static/scripts/input-trace.js |
Driver Inputs panel — bar and graph rendering for throttle, brake, clutch, steering |
pip install -r requirements.txtOnly needed once (or to reset):
sqlite3 strategist.db < queries/create_users.sqlite3-query
sqlite3 strategist.db < queries/create_games.sqlite3-query
sqlite3 strategist.db < queries/create_sessions.sqlite3-queryexport ANTHROPIC_API_KEY=your-key-hereWithout this the /api/strategy/ai endpoint will return an error, but everything else works fine.
python3 app.pyOpen http://localhost:5051. Register an account, then go to /setup to configure your game.
Port: defaults to
5051. Override withPORT=8080 python3 app.py. F1 game setting: in-game go to Settings → Telemetry Settings and set the UDP IP to your machine's IP and port to20777.
| Method | Path | Description |
|---|---|---|
GET |
/api/telemetry |
Single JSON snapshot of current state |
GET |
/api/telemetry/stream?game=f1|lmu|forza_hrzn |
Server-Sent Events stream (~60 Hz) |
GET |
/api/weather/history?game=f1|lmu |
Weather history list for this session |
POST |
/api/strategy/ai |
Trigger AI strategy analysis (body: {"game":"f1"}) |
{
"connected": true,
"telemetry": {
"speed": 287, "throttle": 0.94, "brake": 0.0,
"clutch": 0, "steer": -0.12,
"gear": 7, "rpm": 11450, "drs": 1,
"tyre_visual_compound": 16,
"tyre_age_laps": 12, "fuel_in_tank": 28.4,
"fuel_remaining_laps": 14.2,
"engine_temp": 102, "tyres_surface_temp": [88, 89, 90, 91]
},
"lap_data": {
"current_lap": 5, "current_lap_time": 82450,
"last_lap_time": 83120, "best_lap_time": 82800,
"car_position": 3, "pit_status": 0,
"lap_distance": 1247.3
},
"session": {
"track_id": 10, "session_type": 10,
"total_laps": 57, "weather": 0,
"track_temperature": 38, "air_temperature": 24
}
}The recording system lets you capture a real game session and replay it later for development without needing the game running.
A compact binary format:
Bytes 0–15 : Header — magic b'F1REC\x00', version uint8, 9 reserved bytes
Per packet : float64 timestamp (seconds since start)
uint16 packet length
N bytes raw UDP data
Packets are stored as raw, unmodified UDP datagrams — the full parsing pipeline runs normally during replay.
Run f1/server.py directly with --record:
cd f1
python3 server.py --record ../example-data/myrace.f1recPress Ctrl+C to stop. The file is flushed and closed cleanly.
Use this if you want to capture packets without any processing, or if you want to record and forward to another machine.
python3 f1/recorder.py example-data/myrace.f1rec
python3 f1/recorder.py example-data/myrace.f1rec --port 20777Start the Flask app first (python3 app.py), then in a second terminal:
# Real-time replay (1x speed)
python3 f1/replayer.py example-data/myrace.f1rec
# Fast replay — great for testing state accumulation quickly
python3 f1/replayer.py example-data/myrace.f1rec --speed 4.0
# Maximum speed (no timing — floods all packets instantly)
python3 f1/replayer.py example-data/myrace.f1rec --speed 0
# Loop continuously — useful when developing the UI
python3 f1/replayer.py example-data/myrace.f1rec --loop
# All options
python3 f1/replayer.py example-data/myrace.f1rec --speed 2.0 --loop --host 127.0.0.1 --port 20777The replayer sends packets to the local UDP port, so the app processes them exactly as if the game were running live.
- F1 game sends binary UDP datagrams to port
20777at ~20 Hz. UdpListener(f1/server.py) receives each datagram and peeks at the first 2 bytes (packet_format) to determine the game generation, then selects the appropriate header layout before parsingpacket_idandplayer_car_index.- The parsed dict is passed to
state.update_telemetry()/update_lap_data()/update_session()inTelemetryState(f1/telemetry_state.py). TelemetryStateholds the latest values plus a numpy circular buffer (2400 points = 120 s at 20 Hz) for history charts.app.pyexposes/api/telemetry/streamas a Server-Sent Events endpoint. It pollsstate.last_update_timeevery 16 ms and pushes a JSON snapshot whenever new data arrives.- The browser receives SSE events and updates the live dashboard without polling.
- Forza Horizon broadcasts a binary UDP datagram at ~60 Hz to the configured IP and port.
UdpListener(forza_hrzn/server.py) receives each datagram and checks its length againstVERSION_BY_SIZE({324: 'fh4', 323: 'fh5'}). Packets of any other size are silently skipped.- The 311-byte Sled + Dash struct is unpacked with
struct.unpack_from. Speed (m/s) is multiplied by 3.6 for km/h; Accel/Brake/Clutch (uint8 0–255) are divided by 255; Steer (int8 −127–127) is divided by 127. - The parsed dict is passed to
state.update()inTelemetryState(forza_hrzn/telemetry_state.py), which stores the latest values and writes to a numpy circular buffer (7200 points = 120 s at 60 Hz). app.pystreams snapshots via SSE at/api/telemetry/stream?game=forza_hrzn.
The server auto-detects the F1 game generation from the packet_format field (first 2 bytes of every packet, equal to the game year). No manual configuration is needed.
packet_format value |
Generation | Header size | Notes |
|---|---|---|---|
| 2022–2024 | Gen 5 | 28 bytes | Includes overall_frame_id and secondary_player_car_index |
| 2018–2021 | Gen 2–4 | 23 bytes | Original modern header; player_car_index at field position 8 |
| ≤ 2017 | Gen 1 | — | Not supported; packets are silently skipped |
The correct header_size is computed once per packet and passed into each parser method (parse_car_telemetry, parse_lap_data, parse_session_data, parse_car_status) so that the offset into per-car data arrays is always correct.
Flask's reloader forks the process, which would start duplicate UDP listener threads. use_reloader=False prevents this.
TelemetryState is a singleton — TelemetryState() always returns the same instance regardless of where it is called. It uses a threading.Lock around all reads and writes.
The circular buffer uses pre-allocated numpy arrays instead of deque for ~50% lower memory usage. When the buffer is full, the oldest entry is overwritten. get_history_df() handles the wrap-around when constructing the DataFrame.
history_index ──▶ [ old | new | newest | oldest | ... ]
^write position; wraps to 0 when it reaches maxlen
/api/strategy/ai calls strategy/ai_strategy.py, which:
- Takes a snapshot of the current telemetry + weather history.
- Builds a structured prompt and calls Claude via the Anthropic SDK.
- Returns three strategies: Standard, Push, and Fuel-save, each with stop laps, compounds, and estimated time delta.
Requires ANTHROPIC_API_KEY to be set.
The telemetry page includes a Driver Inputs panel below the tyre temperatures. It shows throttle, brake, clutch, and steering in real time, with a toggle between two display modes:
| Mode | Description |
|---|---|
| Bars | Horizontal progress bars; steering uses a center-origin bar that fills left or right |
| Graph | Rolling canvas chart; maintains a 300-frame in-browser history, no extra API calls |
Channel colours: throttle = green (#00ff87), brake = red (#ff4757), clutch = blue (#4d9fec), steering = grey (#999).
The panel is driven by static/scripts/input-trace.js, which exposes a single function — inputTrace.update(throttle, brake, clutch, steer) — called from telemetry.js on every SSE frame. Clutch is normalized to 0–1 before being passed in (F1 sends 0–100, LMU sends 0.0–1.0).
SimStrategist uses exactly three ports. Only two require any network/firewall configuration.
| Port | Protocol | Direction | Purpose | Configurable? |
|---|---|---|---|---|
| 20777 | UDP | Game → app | F1 telemetry stream | Fixed — set in the F1 game's UDP settings |
| 5100 | TCP | LMU plugin → app | Le Mans Ultimate telemetry stream | Fixed — set in the LMU community plugin |
| 20055 | UDP | Game → app | Forza Horizon 5 telemetry stream | Default; override with FORZA_HRZN_PORT=xxxx |
| 20044 | UDP | Game → app | Forza Horizon 4 telemetry stream | Default for FH4; use FORZA_HRZN_PORT=20044 |
| 5051 | HTTP | Browser → app | Flask web dashboard | Yes — override with PORT=xxxx python3 app.py |
What you need to configure:
- F1: in-game go to Settings → Telemetry Settings, set IP to your machine's IP and port to
20777. - LMU: configure the telemetry plugin to connect to your machine's IP on port
5100. - Forza Horizon: in-game go to Settings → Gameplay & HUD → UDP Race Telemetry, set the IP to your machine's IP and port to
20055(FH5) or20044(FH4). - Firewall: open UDP
20777,20055/20044, and TCP5100inbound on the machine running the app.
VS Code users: the Ports panel in VS Code will show additional ports (e.g. 17415, 38000, and other high-numbered ports). These are VS Code's own internal processes (extension host, language servers, debugger) and are unrelated to SimStrategist. You can ignore them.
| Problem | Likely cause |
|---|---|
| Dashboard shows "Disconnected" | Game not sending to port 20777, or firewall blocking UDP |
Address already in use on startup |
Another process is on 5051; set PORT=xxxx env var |
| AI strategy returns error | ANTHROPIC_API_KEY not set or invalid |
| Replay has no effect on dashboard | App not running, or replaying to wrong port |
| Recording stops mid-session | Disk full, or Ctrl+C — recordings flush on clean exit |
| Forza shows "Disconnected" | Wrong port in-game, or packet size doesn't match FH4/FH5 (check game title) |