Matchmaking
This page starts with a tap on a distance card and ends when the race screen takes over.
There are two ways in: blind matchmaking (queue for a distance, get paired with whoever
comes next) and the lobby (see who is waiting, pick one). Both end in the same place, a
/race/:id route with a race id.
| Owner | Kind | File |
|---|---|---|
RaceTab |
ConsumerWidget, the distance grid |
app/lib/features/race_tab/race_tab.dart:20-148 |
PermissionsSheet + PermissionGate |
modal sheet + AsyncNotifier<PermissionState> |
app/lib/features/permissions/permissions_screen.dart:9-173, app/lib/tracking/permission_gate.dart:108-197 |
QueueScreen |
ConsumerStatefulWidget, also a lifecycle observer |
app/lib/features/queue/queue_screen.dart:20-150 |
QueueNotifier |
AutoDisposeFamilyNotifier<QueueState, String>, one per race-type slug |
app/lib/features/queue/queue_notifier.dart:71-267 |
MatchmakingRepository |
HTTP: join, poll, leave | app/lib/data/repositories/matchmaking_repository.dart:34-94 |
WaitingChannel |
typed client for waiting:<slug> |
app/lib/data/socket/waiting_channel.dart:107-142 |
LobbyScreen + LobbyNotifier |
screen + polling AutoDisposeAsyncNotifier |
app/lib/features/lobby/lobby_screen.dart, app/lib/features/lobby/lobby_notifier.dart:33-99 |
All of it runs in the main isolate. The foreground service is not started until the race screen; see Race.
The sequence
Section titled “The sequence”sequenceDiagram
autonumber
participant RT as RaceTab
participant PS as PermissionsSheet
participant QS as QueueScreen
participant QN as QueueNotifier
participant MR as MatchmakingRepository
participant WC as WaitingChannel
participant MC as backend MatchmakingController
participant MQ as backend MatchRequests / Matchmaking
participant WCh as backend WaitingChannel
RT->>RT: tap card, _ConfirmSheet "Find opponent"
RT->>PS: ensureGranted()
PS-->>RT: true (granted or walked through)
RT->>QS: context.push(/queue/:slug)
QS->>QN: start()
QN->>MR: join(slug)
MR->>MC: POST /api/matchmaking/join {race_type}
MC->>MQ: create_competitor, maybe_match
alt someone already waiting
MQ->>MQ: handle_match: create race, start RaceProcess, broadcast_matched
MC-->>QN: {status: matched, competitorId, race}
QN->>QN: state = QueueMatched
else nobody waiting
MC-->>QN: {status: queued, competitorId}
QN->>QN: state = QueueWaiting
QN->>WC: WaitingChannel.join(socket, slug)
WC->>WCh: join "waiting:slug"
WCh->>MQ: Matchmaking.subscribe(user.id)
WCh-->>WC: {status: queued}
Note over MQ: later, another runner's join matches this request
MQ->>WCh: PubSub {:matched, race, competitor}
WCh-->>WC: push "match" {raceId, startsAt, me, opponent}
WC-->>QN: matches stream, _onMatchPush
QN->>QN: state = QueueMatched
end
QS->>QS: wait until startsAt - 5 s
QS->>QS: context.go(/race/:id)
Note over QN: autoDispose: leaves the channel
Step by step
Section titled “Step by step”1. The distance grid
Section titled “1. The distance grid”RaceTab.build (app/lib/features/race_tab/race_tab.dart:24-127) watches three providers:
raceTypesProvider for the list of distances, authProvider for the user’s name and per
distance stats, and unitsFormatterProvider for km versus miles. The distances come from
GET /api/dashboard (app/lib/features/race_tab/race_types_notifier.dart:7-24,
app/lib/data/repositories/race_type_repository.dart:21-25).
raceTypes.when(loading:, error:, data:) (line 61) is the idiom you will see on every
screen that loads something. AsyncValue is a sealed union of loading, error and data, and
when forces you to handle all three, much like a Kotlin when over a sealed class.
The same build also registers a listener on pendingRaceProvider (lines 32-35), which
sends you back into a race the app was killed during. That belongs to
Failure modes.
2. Confirm, then permissions
Section titled “2. Confirm, then permissions”Tapping a card calls _confirm (app/lib/features/race_tab/race_tab.dart:129-147). It
shows _ConfirmSheet (“Race 5 km?” with “Find opponent” and “Not now”,
lines 150-197) as a modal bottom sheet and awaits the result. showModalBottomSheet<bool>
returns a Future<bool?> that completes when the sheet pops; the sheet pops true or
false (lines 185, 189). Coming from iOS: this is a presented view controller whose
completion hands back a value, written as an await.
Before queueing, it calls PermissionsSheet.ensureGranted (line 143). The comment on lines
141-142 gives the reason: a race is worthless without tracking, so ask now rather than
during a countdown.
ensureGranted (app/lib/features/permissions/permissions_screen.dart:24-29) reads
permissionGateProvider.future. If canTrack is already true it returns at once and
nothing is shown. Otherwise it opens the sheet, which walks PermissionGate’s steps in the
order the platforms require: location services on, location granted, “Allow all the time”,
notifications, and an optional battery-optimisation exemption
(app/lib/tracking/permission_gate.dart:115-156). Each step has its own copy
(app/lib/features/permissions/permissions_screen.dart:123-172). The sheet re-checks every
time the app resumes (lines 51-56), since coming back from the system settings screen is
the only sign that something changed, and it pops itself with true once canTrack holds
(lines 63-67). The battery step counts as trackable (canTrack at
app/lib/tracking/permission_gate.dart:43-45) so it never blocks a race.
Only then does _confirm call context.push(AppRoutes.queue(type.slug)), for example
/queue/5k. Push, not go: the queue screen sits on top of the tabs and has a back button.
3. Joining the queue over HTTP
Section titled “3. Joining the queue over HTTP”QueueScreen.initState (app/lib/features/queue/queue_screen.dart:42-50) does three things:
registers as a WidgetsBindingObserver for app lifecycle events, starts a one-second timer
whose only job is to rebuild the “Waiting 0:42” label, and schedules
queueProvider(slug).notifier.start() in a microtask. The microtask is there because
modifying a provider synchronously inside initState, while the widget tree is being built,
is not allowed in Riverpod.
queueProvider is a family keyed by the slug and auto-disposed
(app/lib/features/queue/queue_notifier.dart:266-267). “Family” means one notifier instance
per argument, like a Registry keyed by slug; “autoDispose” means it is destroyed as soon as
no widget watches it, which here is the moment the queue screen leaves the tree.
start() (app/lib/features/queue/queue_notifier.dart:94-118) moves to QueueJoining and
calls MatchmakingRepository.join, which posts
{"race_type": "5k"} to /api/matchmaking/join
(app/lib/data/repositories/matchmaking_repository.dart:53-69). Note the snake_case key, one
of the backend’s leaks.
On the server, MatchmakingController.join/2
(backend/lib/footrace_web/controllers/api/matchmaking_controller.ex:50-92) creates a
competitor row and calls MatchRequests.maybe_match/1
(backend/lib/racing/match_requests.ex:83-105). If an unfulfilled request for that distance
exists, handle_match/2 (lines 123-152) creates the race inside a transaction, deletes the
request, starts the RaceProcess, and calls Matchmaking.broadcast_matched/1. Otherwise it
inserts a match request and replies queued.
Back in the app, the reply is parsed into JoinResult
(app/lib/data/repositories/matchmaking_repository.dart:9-23). Two branches:
- Matched at once (someone was waiting, or you asked for a bot):
_matchedpicks the opponent out of the race’s competitors and setsQueueMatchedwith the start time, name and bot flag (app/lib/features/queue/queue_notifier.dart:253-263). - Queued: set
QueueWaiting(competitorId, since: now)and call_listenForMatch()(lines 109-113).
There is a guard on lines 99-104: if the user hit Cancel while the POST was in flight, the
state is now QueueCancelled, and the competitor the server just created needs a second
DELETE /api/matchmaking/leave. Async races of this kind are the reason the state is a
sealed class that every await re-checks.
4. Listening on waiting:<slug>
Section titled “4. Listening on waiting:<slug>”The design choice, spelled out in the class comment
(app/lib/features/queue/queue_notifier.dart:64-70): queue over HTTP, listen on the socket.
The server keeps one queue that both paths share, so the HTTP request and the channel see the
same row.
_listenForMatch (lines 122-141) calls WaitingChannel.join
(app/lib/data/socket/waiting_channel.dart:117-124), which joins topic waiting:5k on the
shared socket. PhoenixSocketTransport.join waits up to 15 seconds for the socket to be
connected before adding the channel (app/lib/data/socket/phoenix_transport.dart:101-124),
because joining on a closed socket leaves the channel in an errored state in the
phoenix_socket package.
On the server, WaitingChannel.join/3
(backend/lib/footrace_web/channels/waiting_channel.ex:21-31) does not queue anything. It
subscribes the channel process to the user’s PubSub topic, matchmaking:user:<id>
(backend/lib/racing/matchmaking.ex:28-31), and replies with where you stand: idle,
queued, or matched with the race. WaitingJoinReply parses that
(app/lib/data/socket/waiting_channel.dart:72-89). If the match happened in the gap between
the HTTP reply and the channel join, the reply already says matched, and the notifier
handles it as if a push had arrived (app/lib/features/queue/queue_notifier.dart:134-136).
Otherwise the notifier subscribes to channel.matches
(app/lib/data/socket/waiting_channel.dart:127-129), a Stream<MatchInfo> filtered to
match events. A Dart Stream here is a Kotlin Flow that is already hot: the channel
pushes into it whether or not anyone listens.
WaitingChannel also has joinQueue and leaveQueue methods that push join/leave on the
channel (app/lib/data/socket/waiting_channel.dart:131-139, matching
backend/lib/footrace_web/channels/waiting_channel.ex:38-66). Nothing in lib/ calls them
today; the notifier uses HTTP for both.
5. The match
Section titled “5. The match”When a second runner’s join finds your request, the server’s handle_match calls
broadcast_matched/1 (backend/lib/racing/match_requests.ex:141), which publishes
{:matched, race, competitor} to each human competitor’s topic
(backend/lib/racing/matchmaking.ex:157-165). Your WaitingChannel process receives it in
handle_info/2 and pushes match (backend/lib/footrace_web/channels/waiting_channel.ex:73-76).
_onMatchPush (app/lib/features/queue/queue_notifier.dart:154-164) checks the state is still
QueueWaiting, stops any polling, and sets QueueMatched(raceId, startsAt, opponentName).
The start time is now + race_starts_after_ms, 30 seconds by default
(backend/lib/racing/match_requests.ex:222-227, backend/config/config.exs:13). The race
process starts its countdown immediately.
6. Hand-off to the race screen
Section titled “6. Hand-off to the race screen”QueueScreen.build (app/lib/features/queue/queue_screen.dart:93-149) switches over the
sealed state (lines 114-144). QueueMatched renders _Matched: “Matched with Runner Two”
and a big seconds counter (lines 225-278).
Each rebuild also calls _maybeHandOff (line 99, body at lines 73-85). Once the start is
handoffLead (5 seconds, line 26) or less away, it navigates with
context.go(AppRoutes.race(raceId)) in a post-frame callback. go, not push: the race
screen replaces the stack, so there is no back button into a finished queue. Navigation from
inside build has to wait for the frame to finish, hence addPostFrameCallback.
So for most of the 30-second countdown you are on the queue screen; the race screen appears for the last five seconds. The race screen then starts GPS 3 seconds before the gun (see Race).
When the queue screen leaves the tree, nothing watches queueProvider(slug) any more, and
autoDispose runs the onDispose registered in build
(app/lib/features/queue/queue_notifier.dart:84-87): stop polling and leave the waiting
channel. There is no explicit “leave queue” on success because the server already deleted
the match request in handle_match.
Cancel
Section titled “Cancel”The “Cancel” button and the system back gesture both end up in _cancel
(app/lib/features/queue/queue_screen.dart:87-90; the PopScope on lines 101-105 turns a
back gesture into a cancel while waiting). QueueNotifier.cancel()
(app/lib/features/queue/queue_notifier.dart:179-190) sets QueueCancelled first, so the
screen never shows a stale spinner, then leaves the channel, then calls
DELETE /api/matchmaking/leave. On the server that is
MatchmakingController.leave/2 → Competitors.cancel_pending/1
(backend/lib/footrace_web/controllers/api/matchmaking_controller.ex:104-115).
The bot shortcut
Section titled “The bot shortcut”With DEV_TOOLS=true the waiting view shows “Race a bot (dev)”
(app/lib/features/queue/queue_screen.dart:122-127, 207-212). It cancels the real request
and calls start(bot: true), which adds "opponent": "bot" to the join body
(app/lib/data/repositories/matchmaking_repository.dart:63). The backend’s bot clause of
join/2 (backend/lib/footrace_web/controllers/api/matchmaking_controller.ex:15-48) calls
Bots.start_bot_race/3 (backend/lib/racing/bots.ex:51-62), which builds the race, starts
the bot runner, and replies matched directly. With bots disabled the reply is 422 “Bot
opponents are disabled”, shown on the queue error view.
The polling fallback
Section titled “The polling fallback”If the channel join fails (ChannelException: socket down for 15 seconds, or the server
refused), the notifier does not give up on the queue. _fallBackToPolling
(app/lib/features/queue/queue_notifier.dart:143-152) flags the state polling: true and
starts a two-second Timer.periodic calling GET /api/competitors/:id/poll
(app/lib/data/repositories/matchmaking_repository.dart:72-83). On the server,
CompetitorController.poll/2 runs maybe_match again
(backend/lib/footrace_web/controllers/api/competitor_controller.ex:23-35).
_pollOnce (app/lib/features/queue/queue_notifier.dart:229-251) skips a tick while one is
in flight, treats a 404 as “your place in the queue expired”, and treats anything else as
transient.
The screen also pauses polling when the app has been in the background for a minute
(app/lib/features/queue/queue_screen.dart:61-71, backgroundGrace on line 29). On resume,
resumePolling (app/lib/features/queue/queue_notifier.dart:202-217) polls once to learn
whether a match happened while away, then rejoins the channel if it is not joined. A dropped
socket does not dequeue you. That is step 8 of
footrace-flutter/docs/use-cases/pick-distance-and-queue.md, which also notes that the
backend prunes requests older than ten minutes (I did not trace that pruning in the backend).
The lobby
Section titled “The lobby”sequenceDiagram
autonumber
participant LS as LobbyScreen
participant LN as LobbyNotifier
participant LR as LobbyRepository
participant LC as backend LobbyController
participant MQ as backend MatchRequests
participant Other as other runner's WaitingChannel
LS->>LN: ref.watch(lobbyProvider), build()
LN->>LR: list()
LR->>LC: GET /api/lobby
LC-->>LN: {lobby: [...]}
loop every 2 s while open
LN->>LR: list()
end
LS->>LS: tap row, _ConfirmJoin sheet, PermissionsSheet
LS->>LN: stopPolling()
LS->>LR: join(entry.id)
LR->>LC: POST /api/lobby/:id/join
LC->>MQ: join_match_request(request, user)
MQ->>Other: broadcast_matched, "match" push
LC-->>LS: {status: matched, race}
LS->>LS: context.go(/race/:id)
“Browse lobby” on the Race tab pushes /lobby
(app/lib/features/race_tab/race_tab.dart:116-121).
LobbyNotifier.build (app/lib/features/lobby/lobby_notifier.dart:43-48) loads
GET /api/lobby once and starts a two-second poll. The backend lists pending match requests
across all distances (backend/lib/footrace_web/controllers/api/lobby_controller.ex:12-20).
A failed poll keeps the last good list and flags it stale after two misses rather than
blanking the screen (lines 60-82). Like the queue, the provider is autoDispose, so the timer
dies with the screen (line 44).
Tapping a row runs _join (app/lib/features/lobby/lobby_screen.dart:48-82): a confirm sheet
(“You’ll go straight into the countdown.”), the same PermissionsSheet.ensureGranted gate,
then stopPolling() and POST /api/lobby/:id/join
(app/lib/data/repositories/lobby_repository.dart:32-37). On the server,
LobbyController.join/2 (backend/lib/footrace_web/controllers/api/lobby_controller.ex:26-60)
calls MatchRequests.join_match_request/2 (backend/lib/racing/match_requests.ex:68-78),
which runs the same handle_match as blind matchmaking. So the runner who was waiting gets
the same match push on their waiting:<slug> channel and their queue screen hands off as in
step 6.
The joiner skips the queue screen and goes straight to /race/:id. Errors become snackbars
with their own copy: 404 is “Someone else got there first.”, 400 is “That is your own
request.” (app/lib/features/lobby/lobby_screen.dart:84-88), and polling restarts.
Where this hands over
Section titled “Where this hands over”At the end of this page the app has a race id and is on /race/:id. The RaceScreen
watches raceProvider(id), whose build joins race:<id>. The Race page
starts there. For the channel plumbing on its own, see Realtime and
Day 8.