PRJ-001 / Project sheetShelved

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.

Open comms during a ride: riders in range placed around the live channel by distance, with the callsign, connection state and range of each one underneath.
Fig. 01Open comms during a ride: riders in range placed around the live channel by distance, with the callsign, connection state and range of each one underneath.
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
01

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.

02

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.

03

The cold start problem

A proximity app is only worth opening if someone else is nearby, and on launch day nobody is. The matcher is tuned for a road that already has riders on it, so early on a rider can complete a whole ride, never be matched with anyone, conclude the app does nothing and stop opening it. That is a trap that closes on itself: thin density produces silent rides, silent rides cost retention, and the retention it costs is exactly what density needed in order to grow.

The answer is to stop treating the matching radius as a constant. What matters to a rider is not the number in metres, it is whether the ride produced anyone to talk to, so the radius becomes the free variable and the target becomes a set number of matches per rider per ride. Where riders are sparse the radius opens out until it reaches them; as a region fills in, the same target is met at shorter and shorter range, and the radius contracts on its own without anyone tuning it.

It contracts to a floor rather than to nothing. Past a certain distance a match stops being worth making, because riders who are not genuinely near each other cannot see each other and have no road in common to talk about. So the radius is clamped at the steady state value the matcher would have used all along, and a dense city converges down onto it while a quiet highway keeps the wider search. The endpoint is the design that was always wanted; the widening is scaffolding for surviving long enough to reach it.

The shipped matcher runs on the fixed 150 m and 300 m thresholds, which is the baseline a density term would have been measured against. Turning those two numbers into a function of local density was the next piece of backend work, and it is the part the project never reached.

04

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.

05

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.

06

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.

07

The mark

The icon is the matcher, drawn. One filled dot for the rider holding the phone, one open dot for another rider in range, and a line between them for the link the matcher has just formed. It is the smallest true picture of the product: not a helmet, not a motorcycle, not a speech bubble, just the edge between two riders that every other decision in the app exists to make.

The name carries the same idea a second time. Convoii is convoy with the tail respelled, and the two i's set in orange are that pair of riders again, which is what lets the wordmark stand on its own without the icon beside it. At small sizes the pair reads as two bars, so it holds up as a home screen icon and as a badge on a notification.

There are two lockups. The dark one is the app's own ground, since a phone mounted on a bar in daylight runs dark; the light one is for everywhere the app is talked about rather than used.

08

Why it is shelved

Cardo reached the same ground first, and reached it from a much better position. They already make the intercom hardware sitting inside riders' helmets, so their users arrive wearing the device and grouped with the people they ride with. The problem this entire project was organised around is one an incumbent of that shape never has to solve: there is no cold start when the installed base is already on the road.

That is a market reason to stop rather than a technical one, which is the useful part to be clear about. The matcher works, the mesh to SFU migration held, and the native side does what it needs to on a moving bike. What the project could not get to was the density its whole design depends on, and the plan for reaching it, a radius that opens up while riders are thin and contracts as they are not, is a strategy for a market with room in it rather than one where the hardware vendor is already in the helmet.

It is left documented rather than deleted. The interesting work was never the voice plumbing, it was deciding who is in a conversation, continuously, from noisy GPS on moving vehicles, and that part is built and tested.

09

More

Convoii app icon and wordmark, in dark and light lockups
Fig. 02The identity: the app icon carrying the link between two riders, the wordmark with its pair of riders in the tail, and the light lockup underneath.

Want the detail behind this one? Just ask.