Convoii
Riders who end up near each other end up on the same channel. No pairing, no push to talk, no one deciding who is in the conversation.
A cross platform mobile app for real time proximity voice communication between motorcyclists, with open comms for nearby riders and private crew channels. I designed and built the whole thing: the app, the interface, the icon pipeline, and the product strategy around a cold start density problem.

- Client
- React Native 0.73, TypeScript
- State
- Zustand
- Voice
- LiveKit SFU over WebRTC
- Backend
- Node, Express and ws on one port
- Matcher
- Geohash index, union find, hysteresis
- Accounts
- Supabase auth and Postgres, RLS enforced
- Native
- Android Kotlin: BLE, IMU, foreground service
- Platform
- Android first, iOS to follow
- Role
- Solo founder and developer
- Stack
- React Native, TypeScript, LiveKit, Node.js
The idea
Motorcycle intercoms make you decide who you are riding with before you ride, then hold that decision in a pairing. Convoii inverts it. A rider opens the app, starts a ride, and from then on the group is whatever the road makes it: riders who fall in alongside you are on the channel, riders who peel off leave it, and nobody presses anything to talk.
That puts almost all of the difficulty in one place. Deciding who is in a conversation, continuously, from noisy GPS on moving vehicles, is the actual product, and most of the engineering below exists to make that decision stable enough to speak over.
Matching riders
Riders push presence to the backend, which holds it for 90 seconds and indexes it by geohash, at a cell size chosen so the ring of neighbouring cells covers the furthest distance a link can survive. Each tick, links between compatible riders are fed into a union find, and the connected components are the groups. A component of two or more becomes a channel.
Naive distance thresholding fails immediately on a real road, so three rules sit on top. Distance is hysteretic: riders pair at 150 m but stay paired out to 300 m, so ordinary spacing in traffic does not flap the channel open and closed. New links also need agreeing headings, but only when both riders are actually moving, which is what keeps a divided highway, a crossroads or a petrol station from putting you on a channel with oncoming traffic. Retained links deliberately skip that check, because an overtake or a weave should never drop audio mid sentence.
Channel ids are sticky. A group keeps its id as it moves, the older id wins when two groups merge, and on a split the id follows deterministically, so a crew riding together stays on one channel for a whole ride instead of being rejoined every few minutes. Group membership is recomputed at most every 500 ms and shared by every rider in that window, so a dense pack is solved once per tick rather than once per rider.
Blocking is enforced at the channel rather than the link. Severing the direct link is not enough, because a mutual friend in the middle would still pull a blocked rider into the same component and therefore the same audio, so a channel containing a blocked rider is declined outright.
Voice
Voice started as a WebRTC mesh, which capped a channel at about four riders before every phone was uploading its microphone to everyone else. It now runs through an SFU: a proximity channel maps one to one onto a LiveKit room, so a rider uploads their microphone once no matter how many people are on the channel, and the media never touches the backend, which only mints a token scoped to that one room.
That swap was survivable because voice sits behind a single interface. The ride orchestration only ever calls join, leave and mute against the current channel id, so replacing the mesh with an SFU was a new module and a token endpoint rather than a rewrite. Relay credentials are short lived and derived from a shared secret, so no permanent relay password ships inside the app.
Channel assignment is pushed to the client over its own socket rather than polled, and only when the assignment actually changes, which takes the polling round trip out of how long it takes to be able to hear someone.
On the phone
A rider is wearing gloves and a helmet at 100 km/h, so the phone side is mostly native. Kotlin modules advertise and scan a Bluetooth LE beacon carrying the rider's id, watch for the helmet connecting and for the audio route changing, and play the join chime through the voice communication path so it rides the Bluetooth link into the helmet rather than out of the phone speaker into the wind. A vibration pattern doubles it up for anyone riding without audio.
A foreground service holds the process, and with it GPS, BLE, the presence socket and the audio session, alive while the phone is in a pocket, and widens its declared service types as permissions are granted. A second native module streams accelerometer and gyroscope, which turns into a ride summary afterwards: a speed profile, and lean angle recovered from the accelerometer's roll.
Accounts and crews
Riders sign in with a phone number, Google or Facebook, and pick a callsign that doubles as their identity to the matcher, so the name on the channel and the key in the index are the same string. Auth and data are Supabase, reached through plain authenticated fetches rather than a client library, with row level security doing the access control rather than trusted client code.
Alongside open comms there are crews: a private channel that only links riders who share the same crew, invited by a QR code or a link that deep links straight into joining. It is the same matcher underneath, with membership as one more condition on whether two riders are allowed to link at all.
Want the detail behind this one? Just ask.