Fun

Things I build outside of work — nothing related to my day job.

Cycle Simulator

Web app Bluetooth
Cycle Simulator screenshot 1 Cycle Simulator screenshot 2 Cycle Simulator screenshot 3 Cycle Simulator screenshot 4

I have been riding bikes for 20 years — starting with cross-country mountain biking on the trails around Edmonton, Canada, then moving to Woodhill Forest in Auckland, and eventually settling into road cycling. Mostly commuting, but with the occasional longer adventure: I have ridden Seoul to Busan (500+ km) twice.

Winter and rainy seasons are the catch. I tried a few indoor cycling platforms but none of them let me ride the routes I actually wanted. So I built my own.

What you need

  • A Wahoo KICKR or any FTMS-compatible indoor trainer
  • Chrome or Edge browser (required for Web Bluetooth)
  • A GPX route file — download one or plan your own
  • Optional: a Bluetooth heart rate monitor (Wahoo TICKR or similar)

What it does

  • Real-time grade simulation sent to a bike trainer via Bluetooth
  • Heart rate monitoring via Bluetooth
  • Live dashboard — power, cadence, speed, heart rate, grade, elevation, and distance
  • 2D map (Leaflet / OpenStreetMap), 3D map (CesiumJS), and Mapbox GL JS 3D map
  • GPX route upload with elevation profile and lookahead chart
  • Switch between riding and simulation mode
  • Ride virtually anywhere in the world

Architecture

The system is split into five areas: infrastructure (Railway + PostgreSQL), auth and billing (Supabase, Resend, Stripe), maps and media providers (Mapbox, Cesium, Google Maps, YouTube), browser-side hardware integration, and app product subsystems.

A deliberate constraint: the entire hosted stack runs for under $5 a month. That shaped every infrastructure choice — Railway for hosting and PostgreSQL, Supabase for auth (free tier), and Stripe in sandbox mode until there's a reason to go live.

How it fits together

  1. The browser loads the app from Railway.
  2. The user authenticates with Supabase Auth.
  3. Supabase sends auth emails through Resend when needed.
  4. The backend verifies Supabase JWTs and maps them to app users in Railway PostgreSQL.
  5. Billing actions go through Stripe — webhooks call back into the Railway app to update plan and entitlement state.
  6. Route data is stored in Railway PostgreSQL, account-backed per user.
  7. Map and media features use Mapbox, Cesium Ion, Google Maps, and optional YouTube or local video.
  8. Trainer and HR hardware connect directly from the rider's browser via Web Bluetooth.

Why the browser owns the Bluetooth connection

The app runs in the cloud, but the trainer and heart rate monitor are physically next to the rider. A cloud server cannot reach those devices directly — so the browser owns the Bluetooth connection and sends control commands to the trainer locally, while staying in sync with the hosted app. This is what makes remote hosting actually work for a real rider setup.

Cycle Simulator architecture diagram

Sailing Trainer

Android iOS
Sailing Trainer screenshot 1 Sailing Trainer screenshot 2 Sailing Trainer screenshot 3 Sailing Trainer screenshot 4 Sailing Trainer screenshot 5

I was first introduced to sailing many years ago by a friend, and something about it immediately stuck with me. Years later, a conversation with a colleague nudged me to finally give it a proper try — I completed Level 1, took a break, then came back for Level 2. That's when things started to get challenging.

Understanding wind direction, sail position, and the tiller all at the same time was surprisingly confusing. My colleague and I even built a small toy yacht to try to visualise how it all worked.

Toy yacht we built to visualise sailing concepts

The toy yacht we built trying to get our heads around it.

It helped a little — but not enough. So I built an app instead. What started as a personal learning tool turned into something I hope helps other beginners too.

What you need

  • An Android or iOS phone
  • The Expo Go app to run it (no install from a store required)
  • No internet needed once loaded — runs entirely on device

What you'll learn

  • Points of sail — close-haul, beam reach, broad reach, running
  • Sail trim — sheet in or ease out for maximum speed
  • Tacking upwind — zigzagging to reach a destination into the wind
  • Heel angle — how wind speed and sail trim affect stability
  • VMG — Velocity Made Good, and why it matters upwind
  • Wind shifts — how to recognise and respond to changing conditions

Built with

  • React Native + Expo — single codebase for Android and iOS
  • TypeScript (strict mode throughout)
  • react-native-svg for all physics rendering
  • expo-av for ambient ocean and wind audio
  • No backend — the entire app runs on device

The app is currently in closed testing — if you'd like to participate as a tester and share your feedback, I'd love to hear from you. Get in touch.

Sailing Trainer QR code

Download Expo Go, then scan to try it on your phone.

Mesh

Android WebRTC Voice
Mesh channel list screen Mesh add channel dialog with display name and room ID Mesh call screen showing connected channel with participant list, mute and hang up

Riding with friends is more fun when you can actually talk to each other — but on the road, anything beyond shouting distance is a lost conversation. Motorcyclists solve this with dedicated helmet intercoms like Cardo or Sena. I wanted the same thing for group rides, without buying radio hardware for everyone.

So I built Mesh: an open-line voice intercom for cyclists riding together. Two or more phones join the same voice channel for the duration of a ride, and everyone just talks — no push-to-talk, no taking your hands off the bars. It's intentionally small: built for me and a few friends, with no sign-up, no accounts, and no growth plans.

It's had its first real road test: a ride with my brother, both of us on bone-conduction earphones. That combination turned out to be the sweet spot — an open line to each other the whole ride, while our ears stayed uncovered to hear traffic and everything else around us.

About the name: the initial vision was a true mesh network — riders' phones relaying for each other, the way Cardo's radio intercoms do. The implementation landed somewhere more modest: a star topology through an SFU. By then the name had stuck, and I didn't bother changing it. The off-grid mesh remains the long-term dream.

What it does

  • Open-line group voice — everyone in a channel hears everyone, like a helmet intercom
  • Channels are just shared room IDs — pick a name on first launch, add a channel, tap to join
  • Survives screen lock and backgrounding — a foreground service keeps the call alive in your pocket
  • Participant list with live mic state; tap Mute to go quiet, Hang up to leave
  • Echo cancellation, noise suppression, and auto gain via the WebRTC audio pipeline
  • Works for 3+ riders — group channels behave the same as a pair

How it works

Two data planes. A tiny token service on Railway is the control plane: when a phone joins a channel, it mints a short-lived access token for the voice room. The media plane is WebRTC — each phone streams its audio once to LiveKit's SFU, which forwards everyone's audio to everyone else in the channel. The backend never touches the audio.

Why there's no server-side state

The backend's only job is to hold the LiveKit API secret and issue room tokens — no database, no user accounts. Each phone stores the rider's name and channel list locally. For a tool built for a handful of friends, every piece of server-side state you don't have is a piece you never have to secure, migrate, or pay for.

Mesh architecture diagram: two phones exchanging WebRTC audio through LiveKit Cloud, with a token service on Railway as the control plane

Built with

  • Flutter (Android) with the LiveKit client SDK
  • LiveKit Cloud — WebRTC SFU and TURN out of the box
  • Node + Fastify + TypeScript token service, deployed on Railway
  • A foreground service to hold the connection open while the phone is pocketed

One honest limitation: it's not an off-grid solution — every phone needs cellular or Wi-Fi coverage. In urban riding that's nearly always true; on remote trails, buy the Cardo.