Free, open-source indoor cycling in the browser. Connect your smart trainer and ride an island world together with friends and bots.
Sports & fitnessJavaScript · RustGitHub @LethoniumOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Open-source indoor cycling with friends — and bots, so the road is never empty.
Taken from the repository README
Loading the full README.
Read the full README ↗Hook up a smart trainer, open the browser and ride a virtual route together with others. Every server is just a Docker container: run it at home for your LAN, on a VPS for your club, or join a public one. This repository contains the proof of concept: a Rust game server and a browser client (installable as an app) with a 3D and a 2D view.
Status: proof of concept. Expect breaking changes.
Requirements: Docker with Compose v2 and make. Rust and Node are optional.
make up
Then open http://localhost:8080 in Chrome or Edge, enter a name, connect your
trainer (or pick Simulated trainer) and join. The first make up copies
.env.example to .env; adjust it and run make up again to apply changes.
| Control | Action |
|---|---|
← / → or A / D | change lane |
↑ / ↓ or W / S | simulated trainer power ±10 W |
E / Q or + / − | shift up / down (virtual shifting) |
J | join a race during its countdown / leave it |
L | cycle the HUD layout: auto, cockpit, broadcast, training, race |
M / Esc | open the menu (overview, view, race, training, settings) / close panels |
V | switch between 3D and 2D view |
C / T | cycle camera (chase, close, drone, TV side) / time of day |
P | take a photo (find them under Menu → Finish ride) |
↑ / ↓ in ERG mode | target ±5 W, or intensity ±5 % during a workout |
On a phone or tablet the same controls are on screen: lane arrows at the sides and, with the simulated trainer, a power stepper.
No trainer? Phone paddles. Pick 📱 Phone paddles on the start screen and tap the left and right paddles in turn, like pedal strokes. A steady rhythm in the green band holds about 75 % of your FTP; tapping faster sprints up to 150 %, but drains your energy bar, and it refills when you ease off. Tapping the same side twice is a stumble. Swipe sideways above the paddles to change lanes. Paddle riders race with everyone and get a 📱 by their name. Paddle rides stay in OpenRadlr: no Strava upload and no ride files (photos and the summary card can be saved). Simulated-trainer rides can be downloaded but not uploaded to Strava.
.zwo import),
low-poly 3D view with chase camera, a 2D lane view, an elevation profile, a
minimap, nearby riders, race panel and chat; live player counts in the start
menu and during the ride; ride photos and a summary image; ride summary with
.tcx/.gpx/.fit download and Strava upload (as a virtual ride with its climbing), placed on the map in a virtual world off Mallorca;
power-only trainers and power meters (no resistance control) are supported.Web Bluetooth works in Chromium browsers (Chrome, Edge) on Windows, macOS, Android and ChromeOS — not in Firefox or Safari/iOS. Without Bluetooth you can still ride with the simulated trainer.
Run make to list everything. The most important ones:
| Command | What it does |
|---|---|
make up | Build and start in the background, wait until healthy |
make down | Stop and remove the containers |
make logs | Follow the logs |
make update | git pull, rebuild, restart, wait until healthy |
make test | All tests (Rust and JavaScript) |
make lint | rustfmt, clippy (-D warnings), ESLint |
make check | Everything CI runs |
make dev | Run the server with local cargo, no Docker |
make lock | Create server/Cargo.lock — run once and commit it |
If cargo or npm is not installed, the test and lint targets run them in Docker.
Full guide, including your own reverse proxy and Strava: docs/deploy.md.
git clone <your-repo-url> openradlr && cd openradlr
make setup # creates .env (later: adds new settings to it)
$EDITOR .env # set OPENRADLR_SERVER_NAME etc.
make up
Later, to deploy new commits: make update.
The server exposes aggregate Prometheus metrics at /internal/metrics for
connected riders, configured rider capacity and community distance totals. The
endpoint contains no rider names, IDs, credentials or request details. The
Monitoring stack scrapes it over the private openradlr-metrics Docker
network; its Grafana dashboard also probes the public /healthz URL.
HTTPS: browsers only allow Web Bluetooth on https:// pages or on
http://localhost. For a public server, point a DNS record at the machine and
set in .env:
COMPOSE_PROFILES=tls
OPENRADLR_DOMAIN=ride.example.org
make up then also starts Caddy, which obtains a Let's Encrypt certificate and
proxies to the game server. Ports 80 and 443 must be reachable.
Landing page (optional): the official server runs a static page on the
main domain and the app on a subdomain (openradlr.com and app.openradlr.com).
Add landing to COMPOSE_PROFILES and set OPENRADLR_LANDING_DOMAIN; see
docs/deploy.md. Without it nothing changes.
LAN parties: set OPENRADLR_HTTP_BIND=0.0.0.0. Other machines reach the page
via http://<your-ip>:8080, but their browsers block Bluetooth on plain HTTP.
Use the TLS profile with a real domain, or for quick tests enable
chrome://flags/#unsafely-treat-insecure-origin-as-secure for that address.
All settings live in .env; see .env.example for descriptions.
| Variable | Default | Meaning |
|---|---|---|
OPENRADLR_SERVER_NAME | OpenRadlr | Name shown to riders |
OPENRADLR_ROUTE | rolling-hills | Default route in routes/ (without .json); all routes there can be picked |
OPENRADLR_TARGET_RIDERS | 12 | Humans + bots kept on the road (per route) |
OPENRADLR_MAX_PLAYERS | 50 | Maximum human riders on trainers at the same time, all routes (phone riders not counted) |
OPENRADLR_MAX_PADDLE_PLAYERS | 20 | Maximum riders on phone paddles at the same time, on top of the above |
OPENRADLR_MAX_VISIBLE_RIDERS | 200 | Most riders in one state message; humans first, then the nearest bots |
OPENRADLR_PUBLIC_URL | – | Public address, e.g. https://ride.example.org (Strava, allowed origin) |
OPENRADLR_TRUST_PROXY | false | Client IPs from X-Forwarded-For behind a reverse proxy |
OPENRADLR_MAX_CONNECTIONS_PER_IP | 4 | Game connections per IP |
OPENRADLR_MESSAGES_PER_SECOND | 20 | Message rate limit per connection |
OPENRADLR_STRAVA_CLIENT_ID / _SECRET | – | Enable "Connect with Strava" |
OPENRADLR_REQUIRE_STRAVA | false | Riders must connect Strava to join |
OPENRADLR_ALLOW_SIMULATED_TRAINER | true | Offer the simulated trainer (set false in production) |
OPENRADLR_ALLOW_PADDLE_MODE | true | Offer phone paddles (tap left/right for power; no Strava, no ride files) |
OPENRADLR_PRIVACY_URL | – |
.
├── server/ Rust workspace
│ ├── crates/sim/ simulation core: route, physics, drafting, bots, races (no dependencies)
│ └── crates/server/ network adapter: config, protocol, game loop, HTTP/WebSocket
├── client/
│ ├── public/ browser client (plain ES modules, no build step)
│ └── test/ client unit tests (node:test)
├── routes/ route files (JSON), shared by server and clients
├── landing/ landing page for the main domain (Astro, static)
├── deploy/Caddyfile HTTPS reverse proxy
├── docs/ architecture, protocol, devices, decisions
├── Dockerfile, docker-compose.yml, Makefile, .env.example
└── .github/workflows/ci.yml
OpenRadlr is an independent project. It is not affiliated with, endorsed by or sponsored by Zwift, Wahoo, Strava, Van Rysel or any other company named here; their names only say which products it works with, and they belong to their owners.
| Link to your privacy policy in the start menu |
OPENRADLR_ANALYTICS_DOMAIN / _SCRIPT | – | Opt-in Plausible analytics for the client: your site name, and the script URL if you self-host Plausible (off when the domain is empty) |
OPENRADLR_STUDIO | true | Host the spin studio (group classes) next to the routes |
OPENRADLR_EVENTS | default | Scheduled events: default, off or the path of an events file (events) |
OPENRADLR_EVENT_LOBBY_S | 600 | Seconds an event's lobby opens before the start |
OPENRADLR_TICK_HZ / OPENRADLR_BROADCAST_HZ | 20 / 4 | Simulation and state rate |
OPENRADLR_RACE_COUNTDOWN_S | 30 | Race countdown (time to join) in seconds |
OPENRADLR_MAX_RACE_LAPS | 20 | Upper limit for race laps |
OPENRADLR_BOT_SEED | random | Fixed seed for reproducible bots |
OPENRADLR_AUTH_MODE | open | Only open (local profiles) in the POC |
OPENRADLR_HTTP_BIND / OPENRADLR_HTTP_PORT | 127.0.0.1 / 8080 | Where the web client is published |
COMPOSE_PROFILES, OPENRADLR_DOMAIN | – | Enable HTTPS via Caddy (tls), the landing page (landing) |
OPENRADLR_LANDING_DOMAIN / _URL | – | Domain(s) and address of the landing page |
RUST_LOG | info | Log level |