Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
Multi-class standings, an on-track relative, fuel strategy, a corner-by-corner coach and a driving cluster. Each one its own window over iRacing.
Translation pending
GamesTypeScript · PythonGitHub @panyu1512OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
iRacing telemetry dashboard built with Tauri v2, React and a Python sidecar bridge
Taken from the repository README
Loading the full README.
Read the full README ↗Each overlay lives in its own frameless, always-on-top window rather than inside one monolithic dashboard. Lock a window and it becomes click-through and immovable — mouse input passes straight to iRacing — with Ctrl+Shift+L toggling the focused one. Position, size and lock state are persisted per window and restored exactly on the next launch.
Every lap you complete is lined up against your quickest clean lap, metre by metre, and split into corners. Each corner gets the time it cost and the reason — braked 14 m early, coasted off both pedals, over-slowed the apex, back on the throttle late — and the Coach overlay says the one or two that matter, with a "4 of the last 6 laps" when it is a habit rather than a one-off. The corner losses always add up to the lap delta, laps through the pits or with a reset are never compared, and an off-track lap is compared but never becomes your reference. Corners driven in traffic are marked as traffic, not blamed on you. Under that, your lap is also held against the session best — the fastest clean lap anyone in your class has set, timed by the bridge from every car's lap distance — corner by corner, so you can see where the quickest driver out there is finding the time (how much, not why: iRacing publishes nobody's pedals but yours). It all runs locally in the bridge, on the telemetry iRacing already exposes.
On a single monitor the car in your blind spot is a voice, not a picture. The Radar widget is two LED boxes and nothing else — an L at the left of its window and an R at the right, each a square dot matrix in a carbon frame, like the LED letter boxes on a sim rig. When iRacing's own spotter (CarLeftRight) calls a car alongside, that side's letter lights up red, and stays lit while that car still overlaps you even if the spotter drops out; when the side is clear its LEDs sit dark. Size the window to sit either side of your car, or narrow it to bring the boxes together.
When race control flags a car, Standings marks it straight after the driver's name: a black flag for a penalty to serve in the pits, the meatball for a car damaged badly enough that it must pit for repairs, a gauge with its needle down when the driver has been told to slow down after cutting the track, and DQ once they are disqualified. The marks come from iRacing's own per-car flags (CarIdxSessionFlags) and go the moment race control lowers the flag.
Selecting an overlay in the manager shows its entire configuration on one page — Appearance, Visibility, Window — next to a preview that reflects every change as you make it. Edits propagate to an already-open overlay window in real time over a cross-window event bus. There is no apply button because there is nothing to apply.
The bridge multiplexes several channels over a single WebSocket, each in a versioned envelope with its own cadence, so the heavy driver roster never ships at frame rate. Stateful channels replay on connect, so a client joining mid-session renders the current world immediately instead of waiting for the next tick. See Wire protocol.
iRacing and pyirsdk are Windows-only, so the repo ships a mock bridge that synthesizes a full multi-class field. docker compose up --build brings up the mock bridge and the Vite dev server together, and the standings, relative, fuel and coach screens can be built end to end on macOS or Linux. The mock drives the player's car round a synthetic lap of Spa with known mistakes, so the coach has real laps to analyse.
The pure logic on both sides of the socket is covered by fast, isolated tests — no sim, no socket, no DOM — plus an integration test that drives the bridge service with a fake source. The bridge sits at ~97% line coverage behind an 85% gate; the frontend covers the formatters, the tyre-heat scale, the wire parser, the full fuel-and-strategy solver and the coach's tip selection. Every push and PR runs both. See Testing.
Download TelemetryLab-setup.exe and run it. It installs for the current user only — into %LOCALAPPDATA%, with no Administrator prompt and no wizard to click through — and opens the app when it finishes.
That link always resolves to the newest release, because the build publishes a copy of the installer under a name that does not carry the version. The versioned …-setup.exe and a .msi sit beside it on the release page; the .msi is the same application packaged for per-machine or managed deployment, and most people do not need it.
You will see "Windows protected your PC" with the publisher listed as unknown. That is expected and it is not a false alarm: the installer is not code-signed, so SmartScreen has no publisher to attribute it to and blocks it by default. Choose More info → Run anyway.
You do not have to accept that on trust. Every release is built by build.yml and publishes a SHA256SUMS.txt beside the installer, so you can confirm the bytes you downloaded are the bytes that were built:
certutil -hashfile .\TelemetryLab-setup.exe SHA256
A build-provenance attestation — which proves the file came from this repository's own workflow rather than from whoever handed it to you — is wired up in the same workflow but only runs while this repository is public. GitHub bills artifact attestations for private repositories under Enterprise Cloud, so the step is gated rather than left to fail a release. If the repo is public, the release notes carry the gh attestation verify command too.
Signing the installer would put a name in that dialog instead of "unknown publisher". It would not remove the warning: since 2024 neither OV nor EV certificates bypass SmartScreen, and reputation accrues over time either way. The only route that removes it outright is distributing through the Microsoft Store as an MSIX, recorded as an open question in the roadmap.
The fastest way to bring up the dev environment — mock telemetry plus the React app — is a single command:
docker compose up --build
# → open http://localhost:1420
This runs the mock bridge (ws://localhost:8765) and the Vite dev server with hot reload. Edit files under src/ and the browser updates live. To simulate iRacing connecting and disconnecting, set MOCK_DISCONNECT_EVERY in docker-compose.yml.
⚠️ Docker only runs the development stack. The Tauri desktop app is a native binary and the real bridge needs iRacing's Windows shared memory — neither runs in a Linux container. Those are produced by CI (
build.yml).No sim handy? Turn on Mock Data in the Overlay Manager → Global Settings and the whole UI runs on built-in synthetic telemetry — no bridge required.
You develop the UI against the mock bridge and run the frontend in a normal browser — the full Tauri shell isn't used here because the sidecar exe is Windows-only.
# 1. Install frontend deps
npm install
# 2. Start the mock bridge (only needs `websockets`)
pip install websockets
npm run bridge:mock # → ws://localhost:8765 streaming fake telemetry
# 3. In another terminal, start Vite
npm run dev # → http://localhost:1420
Open http://localhost:1420 for the live dashboard, driven by the mock bridge's synthetic field.
Useful tweaks:
# Simulate iRacing connecting/disconnecting to test the UI states
MOCK_DISCONNECT_EVERY=5 python bridge/mock_bridge.py
# Point the frontend at a bridge on another machine (e.g. a Windows PC
# running the real bridge) without touching code
VITE_WS_HOST=192.168.1.50 VITE_WS_PORT=8765 npm run dev
# 1. Install frontend deps
npm install
# 2. Install + run the real bridge
pip install -r bridge/requirements.txt
python bridge/bridge.py # reads iRacing shared memory, serves ws://localhost:8765
# 3. Run the full Tauri app
npm run tauri dev
In a packaged build, the Tauri app launches iracing-bridge.exe automatically and kills it on exit. During tauri dev it tries to spawn the sidecar from src-tauri/binaries/ — either build it once (below) or just run python bridge/bridge.py manually and ignore the "failed to spawn sidecar" log line.
Build the sidecar locally (optional):
cd bridge
pip install -r requirements.txt
pyinstaller bridge.spec
# → dist/iracing-bridge.exe
# Copy it where Tauri expects it (note the required target-triple suffix)
copy dist\iracing-bridge.exe ..\src-tauri\binaries\iracing-bridge-x86_64-pc-windows-msvc.exe
Build the full installer:
npm run tauri build
# → src-tauri/target/release/bundle/msi/*.msi
A static download page lives in site/ — plain HTML in English and Spanish (/es/) and three stylesheets, no build step. Serve the folder:
python3 -m http.server 4173 --directory site # → http://localhost:4173
Its product screenshots are generated from this app on the mock feed; see site/README.md to re-shoot them after a UI change.
TelemetryLab is a Tauri v2 desktop app that supervises a Python sidecar. The sidecar owns everything sim-specific; the frontend only ever speaks WebSocket, which is what makes browser-only development on macOS and Linux possible.
iracing-telemetry.exe
├── WebView → React dashboard (native window)
└── Sidecar → iracing-bridge.exe (Python, reads iRacing shared memory via pyirsdk)
↕ pyirsdk
iRacing shared memory (Windows)
On Windows the real bridge talks to iRacing; on macOS and Linux a mock bridge generates synthetic data so you can develop the UI without a sim.
.
├── site/ # Static marketing / download page (no build step)
├── src/ # React frontend (Vite + TypeScript)
│ ├── components/
│ ├── telemetry/ # Wire protocol, typed models, WS connection
│ │ └── protocol.test.ts # Wire-envelope parser specs
│ ├── stores/ # Zustand stores (session/telemetry/standings/bridge)
│ ├── lib/ # Pure helpers (format/scales/fuelStrategy/fuelHistory/coaching/radar) + *.test.ts
│ ├── hooks/
│ │ ├── useBridge.ts # Owns the WS connection; feeds the stores
│ │ └── useTelemetry.ts # Single-car compatibility selector
│ ├── config.ts # Configurable WS host/port (no hardcoded localhost)
│ └── App.tsx
├── src-tauri/ # Tauri (Rust)
│ ├── src/main.rs # Spawns + supervises the bridge sidecar
│ ├── binaries/ # Sidecar exe goes here (built in CI)
│ ├── capabilities/
│ ├── icons/
│ ├── tauri.conf.json
│ └── Cargo.toml
├── bridge/ # Python sidecar
│ ├── telemetrylab/ # Shared core: protocol, models, repos, service
│ │ ├── coaching/ # Lap recorder, corner detection, per-corner causes, session best
│ │ ├── radar.py # Places the spotter's call: who is alongside, where
│ │ └── mocktrack.py # Synthetic Spa lap with known mistakes (mock + tests)
│ ├── tests/ # pytest suite (unit + service integration)
│ ├── bridge.py # Real bridge source (Windows, pyirsdk)
│ ├── mock_bridge.py # Synthetic multi-car field source (Mac/Linux dev)
│ ├── bridge.spec # PyInstaller config (onefile, console)
│ ├── pyproject.toml # pytest / ruff / coverage config
│ ├── requirements.txt # runtime deps (requirements-dev.txt = test/lint)
│ └── requirements-dev.txt
└── .github/workflows/
├── ci.yml # Quality gates: lint + typecheck + tests (every push/PR)
├── release.yml # Cut a release: bump + changelog + tag (manual)
└── build.yml # Build: Windows installer on version tags
The main app window is a dedicated Overlay Manager — it configures, previews, opens, closes and tracks overlays, and never behaves as an overlay itself. The architecture is deliberately flat:
Overlay Manager (main window) → individual overlay windows
A sidebar lists every overlay; selecting one shows its entire configuration inline on one page (Appearance / Visibility / Window) with a live preview that reflects every change instantly — no save or apply step. Each overlay is opened and closed directly from the manager into its own frameless, always-on-top window; the manager tracks which overlays are open, and edits propagate to an open overlay window in real time over a cross-window event bus.
Each overlay window remembers its own position, size and lock state and is restored exactly on the next launch — closing the manager closes every overlay window, so nothing is orphaned. Internally a single view is selected with a URL param (?overlay=<id> / ?widget=<id>); a hash form (#overlay=<id> / #/<id>) is also accepted. During npm run dev these open as browser tabs.
Locking. Any overlay window can be locked: it becomes click-through (all mouse input passes to iRacing) and immovable. Lock state is persisted per window and propagates to the open window immediately. Press Ctrl+Shift+L to toggle the focused overlay window. The manager window is never an overlay and is never locked.
The bridge multiplexes several channels over the single WebSocket, each in a versioned envelope so the frontend can route by type and pick a different cadence per channel:
{ "v": 1, "type": "telemetry", "ts": 1719936000123, "seq": 4211, "payload": { … } }
| Channel | Cadence | Payload |
|---|---|---|
telemetry | ~60 Hz | Player car only: speed/rpm/gear, inputs, tyres, current lap, radar. |
standings | ~5–10 Hz (on change) | Computed field order: positions, gaps, per-car timing, pit state, who is on the radio, race control's flags on each car. |
session | on change / ~1 Hz | Driver roster, session/track/weather metadata, flags, SOF. |
bridge | on change | { iracingActive } — replaces the old {connected:false} heartbeat. |
coaching | once per lap | The last lap against your reference (loss and cause) and the session best (loss), corner by corner. |
Stateful channels (session / standings / bridge / coaching) are replayed on connect, so a client that joins mid-session renders the current world immediately instead of waiting for the next tick.
The models are defined once and mirrored on both sides:
bridge/telemetrylab/models.py (dataclasses) + protocol.py.src/telemetry/types.ts + protocol.ts.On the frontend each channel feeds a Zustand store (useTelemetryStore, useSessionStore, useStandingsStore, useBridgeStore); the standings store is normalized (order + byIdx) so 100+ rows only re-render when their own numbers change. useTelemetry() remains as a single-car compatibility selector for the existing dashboard widgets.
The mock bridge synthesizes a full field (MOCK_CARS, MOCK_MULTICLASS) so the session/standings/relative screens can be built entirely on macOS/Linux.
TelemetryLab is open source under the MIT licence, and contributions are welcome — bug reports, design arguments and pull requests alike.
CONTRIBUTING.md is the full guide. The short version:
| Found a bug? | Open an issue. The template asks two questions worth answering first: does it still happen with Mock Data on, and what kind of session were you in? Between them they usually say whether the bug is in the overlay or in the data reaching it. |
| Want a feature? | Open an issue describing what you are trying to find out mid-session, rather than the widget you have in mind. These surfaces are read at speed, so the bar for adding to them is high — that is the case a good request argues against. |
| Sending a patch? | Open an issue first for anything large. This app has a written design system, and the most common reason a good patch gets sent back is that it solved the problem in a way the surface had already argued against. |
You do not need iRacing, or Windows, to work on almost any of it:
npm install
npm run dev # → http://localhost:1420
Then switch on Global Settings → Mock Data for a full twelve-car, two-class field at Spa — lap times, sector splits, fuel burn and pit windows, no sim required. The Mock Session switch beside it (Race / Qualifying / Practice) is the only way to see how the timing screens change outside a race.
Two files are worth reading before you change anything visual:
design.md — the design system, and unusually it records the arguments rather than just the rules, including the ones that were later reversed. If your change contradicts it, that is allowed: say so in the PR and update the file in the same change.CHANGELOG.md — anything user-visible gets a line under ## [Unreleased], written for someone deciding whether to update.Commits follow Conventional Commits (fix(standings): …, feat(inputs): …); the changelog and release notes are written from them.
The pure logic on both sides of the WebSocket is covered by fast, isolated unit tests — no sim, no socket, no DOM required — plus a small integration test that drives the bridge service end-to-end with a fake source.
Python bridge (bridge/) — pytest + ruff:
cd bridge
pip install -r requirements-dev.txt
pytest # unit + integration suite, coverage gate at 85%
ruff check . # lint
ruff format --check . # formatting
Tests live in bridge/tests/ and cover session parsing, SOF, the CarIdx ingest, the derived-sector timer, the stateful standings engine, the coach — checked against synthetic laps with known, injected mistakes — the repositories/event bus, and the async service loop (~97% line coverage).
Frontend (src/) — Vitest + tsc:
npm install
npm run typecheck # tsc --noEmit (strict)
npm test # vitest run
npm run test:coverage # with coverage thresholds
Specs sit next to the code they exercise (*.test.ts) and cover the value formatters, the tyre-heat colour scale, the wire-protocol parser, the full fuel-and-strategy solver, the per-lap fuel history, and the coach's tip selection and wording.
Three workflows split the fast feedback loop from the release build.
ci.yml is the quality-gate pipeline. It runs on every push to main/claude/** and on every pull request, across two parallel ubuntu-latest jobs:
ruff check, ruff format --check, and pytest with the coverage gate.npm run typecheck, npm run test:coverage, and npm run build.Both roll up into a single ci status so branch protection can require one check. Nothing should merge with a red gate.
release.yml is the release pipeline — the decision to ship. Run it from the Actions tab (or gh workflow run release.yml -f bump=minor) and pick patch/minor/major, or custom with an exact X.Y.Z. It re-runs the frontend gates, then:
package.json, package-lock.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml and Cargo.lock — all five must move together, since Tauri stamps the .msi from tauri.conf.json.## [Unreleased] section into a dated ## [X.Y.Z] section and refreshes the compare links. An empty [Unreleased] aborts the release: there is nothing to ship.chore(release): vX.Y.Z, tags vX.Y.Z, pushes both, and dispatches build.yml against the new tag.The bump/extract logic lives in scripts/release.mjs (bump and notes modes) so it can be run and tested outside CI.
build.yml is the build pipeline. It runs on windows-latest and:
iracing-bridge.exe with PyInstaller.src-tauri/binaries/iracing-bridge-x86_64-pc-windows-msvc.exe (the exact name Tauri requires for a sidecar: {name}-{target-triple}.exe).npm run tauri build to produce the installer.So a release is one button: run release, and the installer and release notes follow from the tag it pushes.
No. Turn on Mock Data in the Overlay Manager → Global Settings and the entire UI runs on built-in synthetic telemetry, with no bridge and no sim.
Not as a product — iRacing and pyirsdk are Windows-only. As a development environment, yes: the mock bridge and the Vite dev server run anywhere, which is how most of the UI is built.
The installer is not code-signed, so SmartScreen has no publisher to attribute it to. Choose More info → Run anyway, and verify the SHA-256 against the SHA256SUMS.txt published with the release if you want proof of what you downloaded. Details in Install on Windows.
Yes. Nothing hardcodes localhost — set VITE_WS_HOST and VITE_WS_PORT, for example to run the UI on a second monitor machine while the bridge sits on the sim PC.
pyirsdk already handles iRacing's shared-memory layout and session YAML, which is the fiddly part. The sidecar keeps that dependency isolated behind a WebSocket, so the frontend has no sim-specific code at all and can be developed and tested off Windows.
MIT — use it, fork it, ship it, sell it. Keep the copyright notice.
The name and the artwork are the project's; the code is yours to do what you like with.