Skip to content

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.

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

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.

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.

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): _matched picks the opponent out of the race’s competitors and sets QueueMatched with 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.

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.

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.

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.

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).

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.

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).

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.

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.