I built my own indoor cycling app. It holds my Wahoo KICKR at the watts the workout asks for, records every ride as a FIT file and uploads it to Garmin. It also lets more than one of us ride on the same computer. While you ride, the workout flies a small fleet of ships to a new star system.
It replaced the subscription app I was paying for. It's MIT licensed.
Site: https://opencycle.fleming.run
Every workout is a flight to a new star system. I wanted it to look like Interstellar and fly like Elite Dangerous. So every system has something huge in it: a black hole bending the starlight around it, a blue supergiant, or two stars orbiting each other. Each session opens with a jump through hyperspace.
- The workout is the mission clock. You get there by finishing the workout. How hard you push changes what you find when you arrive. It never decides whether you arrive.
- Legs. The workout is split into legs: launch, cruise, climb, burn, coast and approach. Intervals become burns. The sidebar on the left lists every leg and shows where you are in the workout.
- Surveys. Every cruise, climb and burn leg is a survey chance. Stay within 10% of the target for 85% of the leg and the survey locks. When you arrive, each locked survey lights up a ring, a moon or a station. Going over target doesn't count.
- Pursuits. Every burn is a chase. A raider warps in ahead of you, and while you're on target your ship shoots at it and the lock fills. Finish the burn clean and it goes down. Miss it and it gets away. Nobody takes damage.
- Your voyage. Each rider has a star map of every system they've reached. The next stop is already picked, so a planned workout already has a destination.
- Riding together. Everyone flies to the same destination and chases the same raider. Hold your zones together and a tether links the ships. If someone's cadence drops, someone else can cover by riding 10% over their own target for fifteen seconds.
- Free rides just cruise open space.
The game only reads the trainer and the workout. It never changes your targets, and the FIT file is the same either way.
- Cadence and torque. In ERG the watts stay put, so the ride screen shows cadence on a dial (green from 80 to 100 rpm) and torque per pedal stroke in Nm. Spin faster and the torque bar drops.
- Your own devices. Each profile picks its trainer and heart rate strap, and you can rename devices ("Sam's KICKR" beats "KICKR CORE 1A2B"). Pick the rider in Session and both fill in, with a live Connected chip.
- Dropouts pause the ride. If the trainer drops, the ride pauses with a red banner and picks up again when the trainer is back. No fake zeros go into the FIT file.
- Power numbers. Normalized power uses the standard 30-second method. The FIT file carries normalized power, IF, TSS, total work and average cadence.
- Weight in kg or lb.
- Trainer: I've only tested it on my own Wahoo KICKR CORE, over Bluetooth FTMS ERG. Other FTMS trainers might work. I just haven't tried one.
- Heart rate: any Bluetooth strap that uses the standard Heart Rate Service. A Garmin HRM 200 only bonds in pairing mode: hold its button for 4 seconds the first time.
- Computer: macOS and Node 24. The Bluetooth library is a native addon, so the server needs Node. Bun won't run it.
You don't need a trainer. The simulator fakes two trainers and two heart rate straps.
pnpm install
pnpm demoThat builds the app, starts it on http://localhost:4000 and opens your browser. Create a profile, pick a workout and start a session. The demo keeps its data in a temp folder, so it won't touch real rides.
You need Node 24 and pnpm 10.26 or later. The repo pins pnpm through packageManager.
pnpm appThis builds openCycle.app into ~/Applications. Open it and allow Bluetooth once when macOS asks. It starts the server, opens the browser and puts an openCycle item in the menu bar with Open, Show logs and Quit.
The first time, a short setup walks you through it: Bluetooth, your rider (FTP and weight in kg or lb), then your trainer and strap. Each device shows live watts, cadence or heart rate before you move on. If macOS hasn't given openCycle Bluetooth yet, the app stays up and shows a banner with a link to the right System Settings page.
A few things worth knowing:
- Quitting finishes any ride in progress first, so the FIT file is saved.
- Logs are in
~/Library/Logs/openCycle/server.log. If the server crashes, the app shows the last lines. - The app runs the code from this folder. After you pull changes, run
pnpm appagain. If you move the folder, rebuild. - Each rebuild re-signs the app, so macOS might ask for Bluetooth again. Say yes and it sticks.
- To run a one-off command with the app's Bluetooth permission:
open -a openCycle --args --exec "<command>".
pnpm app options: --sim (simulator, no Bluetooth), --out <dir>, --data-dir <path>, --no-build.
The app exists because macOS won't give Bluetooth to a process started from a terminal over SSH, or to anything without an app bundle. The permission belongs to the app, and the server it starts inherits it.
pnpm startIt builds the UI if needed and serves everything on port 4000. Add --ble (pnpm start --ble) to scan for real Bluetooth devices.
For development:
pnpm dev # server on :4000, Vite with hot reload on :5173
pnpm dev:server # server only
pnpm build # build the web UI into apps/web/dist
pnpm check # type-check every package
pnpm test # run the testsThe server listens on your whole network, so a TV or tablet can show the ride at http://<your-mac's-ip>:4000. There's nothing to set up for that. OPENCYCLE_HOST=127.0.0.1 keeps it on this computer.
There's no login. Anyone who can reach the server can start sessions, change profiles and read ride data. Run it on a home network you trust. Don't put it on the internet.
Rides and profiles live in ~/.opencycle. Set OPENCYCLE_DATA_DIR to put them somewhere else. OPENCYCLE_PORT moves the server off 4000, which lets a simulator copy run next to the real app.
packages/shared: schemas and types, the Bluetooth protocol constants, physics, the training load model and the voyage rulesapps/server: the Node server (Bluetooth, sessions, ERG, the FIT recorder, the API, Garmin sync and training plans)apps/web: the React and three.js apppackaging/macos: the Mac app launcher and iconassets/ships: the Blender pipeline that finishes the ship modelsdata/plans: workouts and plan templatesskills/workout-author: a Claude skill for writing workouts and plansdocs: the game design and the Bluetooth protocol notes
Workouts are JSON in data/plans/*.json, and multi-week plan templates are in data/plans/templates/*.json. The schemas and zone conventions are in skills/workout-author/SKILL.md. Check a file or the whole folder with:
pnpm workout:validate data/plansIt checks every workout and template against the schemas, and that every workout a template uses exists. Exit 0 means the server will load them.
The authoring skill ships in the repo. Symlink it so Claude Code picks it up:
ln -s "$(pwd)/skills/workout-author" ~/.claude/skills/workout-authorImporting old rides. Drop a Garmin account-export ZIP or loose .fit files into ~/.opencycle/import/.
- Put them in
import/<riderId>/to import them for that rider. The folder name has to be an existing profile id. - Files at the top level stay unassigned until you assign them in History.
- A file that imported something moves to
import/done/, with a.err.txtnext to it if some rides failed. A file that imported nothing moves toimport/failed/. - You can also upload a ZIP from the app, or
POST /api/garmin/import-zip?riderId=<id>. Imports are refused while a session is running. POST /api/garmin/pull/:riderIdpulls the rider's last 30 days from Garmin in the background.- If you import the same export twice, pick the rider in the import screen so it skips rides it already has. Unassigned re-imports make duplicates.
Uploading rides. Finished rides upload to Garmin Connect on their own when the rider's profile has auto-upload on and a saved login exists. If an upload fails, History shows the reason and a retry button. Garmin changes their login flow without warning, and when they do, uploads fail until this part gets fixed. It's kept separate so nothing else breaks with it.
Log in once per rider: press Connect Garmin on the rider's profile (or on the last setup step). A browser opens. Sign in and do the MFA by hand. The first time, openCycle downloads the browser it uses for this, which takes a minute. The login is saved to ~/.opencycle/garmin/<riderId>.json, never the database, and profiles don't store passwords. Connecting again replaces it.
The same login also works from a terminal: pnpm garmin:login --rider <profile-id>.
- Ship base meshes: Ultimate Spaceships Pack by Quaternius (CC0), re-finished for openCycle.
- Fonts: Barlow Condensed and Inter (SIL Open Font License), self-hosted through Fontsource.
MIT. See LICENSE.


