Skip to content

Race

This is the flow the app exists for. It crosses more boundaries than any other: two Dart isolates, two network paths (the race channel and HTTP uploads), and the backend’s per-race GenServer. It starts when the matchmaking flow navigates to /race/:id and ends on /race/:id/result.

Owner Isolate Kind File
RaceScreen main ConsumerStatefulWidget app/lib/features/race/race_screen.dart:23-159
RaceNotifier main FamilyAsyncNotifier<RaceState, String>, keyed by race id app/lib/features/race/race_notifier.dart:113-399
RaceChannel main typed client for race:<id> app/lib/data/socket/race_channel.dart:80-113
ForegroundRaceTracker main starts/stops the service, relays its messages app/lib/tracking/race_tracker.dart:26-71
LocationService main wraps flutter_foreground_task app/lib/tracking/location_service.dart:223-387
LocationTaskHandler service samples GPS, flushes every 2 s app/lib/tracking/location_service.dart:37-221
FixQueue + PositionUploader service durable queue and HTTP drain app/lib/tracking/upload/fix_queue.dart:38-145, app/lib/tracking/upload/position_uploader.dart:110-221
ResultScreen + raceResultProvider main result card, self-refreshing app/lib/features/race/result_screen.dart:21-79

On the backend: FootraceWeb.RaceChannel (backend/lib/footrace_web/channels/race_channel.ex), FootraceWeb.Api.RaceController (backend/lib/footrace_web/controllers/api/race_controller.ex), Racing.Positions.Ingest (backend/lib/racing/positions/ingest.ex) and Racing.RaceProcess (backend/lib/racing/race_process.ex).

sequenceDiagram
    autonumber
    participant RS as RaceScreen
    participant RN as RaceNotifier
    participant RC as RaceChannel
    participant FT as ForegroundRaceTracker
    participant SVC as service isolate<br/>(LocationTaskHandler)
    participant Q as FixQueue + PositionUploader
    participant BCh as backend RaceChannel
    participant BC as backend RaceController
    participant RP as backend RaceProcess

    RS->>RN: ref.watch(raceProvider(id)), build()
    RN->>RC: RaceChannel.join(socket, id)
    RC->>BCh: join "race:id"
    BCh-->>RN: {token, race, competitors} (status countdown)
    RN->>RN: _applySnapshot, _schedule(startsAt)
    Note over RN: startsAt - 3 s
    RN->>FT: start(raceId, apiBaseUrl, token, fakeGps)
    FT->>SVC: saveData(upload target), startService
    RP-->>BCh: :race_started
    BCh-->>RN: push raceStarted
    RN->>RN: phase = racing
    loop every ~1 s
        SVC->>SVC: GPS fix into buffer
    end
    loop every 2 s (onRepeatEvent)
        SVC-->>FT: sendDataToMain {type: batch, fixes}
        FT-->>RN: messages stream, LocalDistance.add
        SVC->>Q: enqueue, drain()
        Q->>BC: POST /api/races/:id/positions (up to 50)
        BC->>RP: Ingest.record, put_positions
        RP-->>BCh: {:race_update, state}
        BCh-->>RN: push raceUpdate (opponent's progress)
        BC-->>Q: 200 {race, competitors, competitor}
        SVC-->>FT: sendDataToMain {type: upload, reply}
        FT-->>RN: _applySnapshot(reply)
    end
    RP-->>BCh: {:competitor_won, my id}
    BCh-->>RN: push competitorWon
    RN->>FT: stop()
    FT->>SVC: stopService, onDestroy
    RN->>RN: phase = finished
    RS->>RS: context.go(/race/:id/result)

RaceScreen.initState (app/lib/features/race/race_screen.dart:42-48) starts a one-second rebuild timer for the countdown and the elapsed clock, and enables the wakelock so the screen does not dim on its own while the race screen is showing (WakelockPlus.enable(), line 47). dispose releases both (lines 51-55).

build watches raceProvider(widget.raceId) (line 93). That is the first read, so Riverpod runs RaceNotifier.build(arg) with the race id as arg. Unlike the queue, this provider is not auto-disposed (app/lib/features/race/race_notifier.dart:397-399): the notifier for a race outlives its screen.

build (app/lib/features/race/race_notifier.dart:129-166):

  1. Register _teardown to run on dispose (line 130).
  2. Join race:<id> through RaceChannel.join (lines 136-140, app/lib/data/socket/race_channel.dart:91-94). On the server, RaceChannel.join/3 checks the user is authorised and a participant, refuses with race_over if the race is finished or aborted, makes sure the race process is running, and replies with the current engine state plus a competitor token (backend/lib/footrace_web/channels/race_channel.ex:20-46).
  3. Subscribe to typed events (channel.events.listen(_onEvent), line 140) and to the socket’s status stream, which flips socketDown (lines 141-143).
  4. Work out which competitor is me by matching user ids (_findMe, lines 168-174).
  5. Subscribe to the tracker’s messages (line 160). The tracker is not running yet; this only attaches to its broadcast stream.
  6. Return the first state through _applySnapshot(..., schedule: true) (lines 161-165).

If the join fails with not_a_competitor or not_found, the state is an error the screen shows with its own copy (app/lib/features/race/race_notifier.dart:148-150, app/lib/features/race/race_screen.dart:153-158). Any other join failure falls back to GET /api/races/:id and marks the socket down (lines 151-156); the race still works, because upload replies carry the server state (step 6).

While build is awaiting, raceProvider(id) is AsyncLoading and the screen shows a spinner (app/lib/features/race/race_screen.dart:107-108).

_applySnapshot (app/lib/features/race/race_notifier.dart:236-270) is the one function that folds a server snapshot into RaceState. While the phase is loading or countdown, it maps the server’s race status onto a phase. A countdown status sets RacePhase.countdown and, on the first call, _schedule(startsAt) (lines 250-252).

_schedule (lines 274-289) sets two timers:

  • GPS lead. gpsLead is 3 seconds (line 114). At startsAt - 3 s it calls _ensureTracking(), so the first fix is ready at the gun.
  • Start fallback. At startsAt it calls _startRacing(). The comment on line 284 says this is a fallback; the server’s raceStarted push is the primary signal.

The server side of the countdown: RaceProcess schedules :start_racing with Process.send_after/3 (backend/lib/racing/race_process.ex:325-327) and broadcasts {:countdown_started, starts_at} on start (line 267). When the timer fires, handle_info(:start_racing, ...) sets status :racing, persists it, and broadcasts :race_started (lines 330-336). The channel turns those into countdown and raceStarted pushes (backend/lib/footrace_web/channels/race_channel.ex:99-109).

On the screen, RacePhase.countdown renders _Countdown (app/lib/features/race/race_screen.dart:180-246): “Get ready”, “vs” and the opponent’s name (with “(bot)” for a bot), the seconds left computed from startsAt against the clock on every tick, and under it either “Starting the GPS…” or the tracking error in red (lines 235-241). At zero it fires one heavy haptic (lines 126-131).

_ensureTracking (app/lib/features/race/race_notifier.dart:300-317) is guarded by _tracking so the GPS-lead timer, the start fallback and raceStarted can all call it safely. It:

  1. Reads the token from TokenStore and the base URL and fakeGps flag from AppConfig.
  2. Writes the race id to ActiveRaceStore (SharedPreferences, app/lib/data/storage/active_race_store.dart:12-26), so a killed app can find its way back.
  3. Calls ForegroundRaceTracker.start(...).

On failure it records trackingError: 'GPS not available: ...' and resets _tracking.

ForegroundRaceTracker.start (app/lib/tracking/race_tracker.dart:41-58) registers _onData as the receiver for messages from the service (addTaskDataCallback, line 48), calls requestLocationPermissions() (which throws if location is off or denied, app/lib/tracking/location_service.dart:232-251), and then startBackgroundTracking(...).

startBackgroundTracking (app/lib/tracking/location_service.dart:318-362) is where the isolate boundary is crossed:

  1. _ensureInitialized() configures the plugin once: the notification channel and a repeat event every kFlushIntervalMs = 2000 ms (lines 289-308, constant on line 17).
  2. Ask for notification permission (line 326).
  3. If a service is already running, return (line 327). Keep this line in mind for Failure modes.
  4. Write everything the service needs with FlutterForegroundTask.saveData: the fake-GPS flag and pace, then a RaceUploadTarget of race id, base URL, token and the path of the sembast file (lines 329-348; keys in app/lib/tracking/upload/race_upload_target.dart:19-23).
  5. FlutterForegroundTask.startService(callback: startLocationCallback) (lines 353-358).

Why saveData and not a constructor argument: the service runs in a separate isolate, started by the plugin from a top-level entry point (startLocationCallback, lines 26-29, annotated @pragma('vm:entry-point') so the AOT compiler keeps it). It shares no memory with the main isolate and cannot read Riverpod, the keychain or AppConfig. saveData is a small key-value store both isolates can reach. In BEAM terms, you cannot pass a PID’s state to a new process by reference, only by copying it into the spawn arguments; this is the copy.

LocationTaskHandler.onStart (app/lib/tracking/location_service.dart:54-73) reads the fake-GPS flag, picks SimulatedLocationSource or RealLocationSource, opens the upload pipeline, subscribes to fixes, and starts the source.

_openUploadPipeline (lines 77-108) reads the four RaceUploadTarget keys. If any is missing (the GPS diagnostics screen starts the service without them) it returns and fixes are only displayed, never uploaded. Otherwise it opens the FixQueue at the saved path, prunes fixes uploaded more than a day ago, and builds an HttpPositionClient and a PositionUploader. The queue is sembast, a pure-Dart database, because plugin-backed storage such as SharedPreferences needs a platform channel the service isolate may not have (app/lib/tracking/upload/fix_queue.dart:33-37). Likewise the uploader uses package:http, not the app’s Dio, which lives in the main isolate with its interceptors (app/lib/tracking/upload/position_uploader.dart:19-20).

Each fix lands in _onFix (lines 110-129). It records the gap since the previous fix, adds the fix to _pendingEnqueue (for the durable queue) and a map version to _buffer (for the UI).

5. Every two seconds: a batch to the UI, a drain to the backend

Section titled “5. Every two seconds: a batch to the UI, a drain to the backend”

The plugin calls onRepeatEvent every 2 seconds (app/lib/tracking/location_service.dart:132-161). That timer is a flush cadence, not the sample rate; GPS arrives at about 1 Hz on its own. Each tick:

  1. Sends {'type': 'batch', 'fixes': [...], 'totalFixes', 'maxGapMs', ...} to the main isolate with FlutterForegroundTask.sendDataToMain (lines 138-150).
  2. Calls _persistAndUpload() (line 151).
  3. Rewrites the notification text with the current pace and fix count (lines 152-160).

_persistAndUpload (lines 165-202) moves _pendingEnqueue into the sembast queue and runs one drain at a time, tracked by _uploadInFlight. After each drain it sends {'type': 'upload', ...report} to the main isolate (lines 179-183). On raceOver it deletes the race’s fixes from the queue (lines 184-186).

PositionUploader.drain() (app/lib/tracking/upload/position_uploader.dart:140-214) loops: take up to 50 pending fixes, ordered by timestamp (FixQueue.pending, app/lib/tracking/upload/fix_queue.dart:74-87), POST them as {"positions": [{latitude, longitude, timestamp, accuracy, altitude, speed}, ...]} (QueuedFix.toPosition, lines 23-30), and on 200 mark them uploaded and continue. Fixes are marked only after a 200, so delivery is at-least-once. The non-200 cases (network failure, 409, 403, 401, 422, 5xx) are covered in Failure modes.

On the backend, RaceController.put_position/2 (backend/lib/footrace_web/controllers/api/race_controller.ex:50-89) checks you are a participant and the race is open, parses the batch, and calls Ingest.record/2. During the countdown that returns the state unchanged, dropping the fixes (backend/lib/racing/positions/ingest.ex:143-145). While racing it inserts a marker per fix and feeds them to the process (lines 147-153). RaceProcess.handle_call({:positions, ...}) applies each fix (backend/lib/racing/race_process.ex:353-360): apply_position adds the haversine distance from the last position, checks for a winner, checks whether everyone is done, persists, and broadcasts {:race_update, state} (lines 514-536). The reply is the race state from your side: {race, competitors, competitor}.

There is a second, unused path: RaceChannel.pushPositions pushes the same body as a position event (app/lib/data/socket/race_channel.dart:105-108, backend/lib/footrace_web/channels/race_channel.ex:54-79). Nothing in lib/ calls it. Uploads go over HTTP because only the service isolate has the fixes when the phone is locked, and the service has no socket.

Back in the main isolate, the plugin delivers each message to ForegroundRaceTracker._onData, which adds it to a broadcast StreamController (app/lib/tracking/race_tracker.dart:36-38). RaceNotifier._onTrackerMessage (app/lib/features/race/race_notifier.dart:201-234) handles two types:

  • batch (lines 203-213): feed each fix to LocalDistance.add and copy its total and last speed into the state. LocalDistance (app/lib/features/race/local_distance.dart:9-58) mirrors the server’s haversine sum but drops fixes worse than 50 m accuracy or implying more than 12 m/s. This is what makes the distance tick every two seconds without waiting for a network round-trip.
  • upload (lines 214-232): parse reply as a RaceSnapshot and fold it in with _applySnapshot. If the snapshot says the race is over or won, call _finishFromSnapshot (lines 221-224; see the caution in step 7). A stop of unauthorized or forbidden sets trackingError.

The race channel delivers the other side of the picture. parseRaceEvent (app/lib/data/socket/race_channel.dart:46-64) turns pushes into a sealed RaceEvent, and returns null for events it does not know so a newer backend never breaks an older app. _onEvent (app/lib/features/race/race_notifier.dart:178-199) switches over it:

Event Backend source App reaction
countdown backend/lib/footrace_web/channels/race_channel.ex:99-102 store startsAt, reschedule timers
raceStarted backend/lib/footrace_web/channels/race_channel.ex:105-109 _startRacing(): ensure tracking, phase racing
raceUpdate backend/lib/footrace_web/channels/race_channel.ex:119-122 _applySnapshot (this is how the opponent’s bar moves)
competitorWon backend/lib/footrace_web/channels/race_channel.ex:131-134 if it is me, _finish(won); otherwise nothing
competitorFinished backend/lib/footrace_web/channels/race_channel.ex:125-128 nothing; the following snapshot carries statuses
competitorAborted backend/lib/footrace_web/channels/race_channel.ex:137-140 if it is the opponent, mark opponentAborted
raceFinished backend/lib/footrace_web/channels/race_channel.ex:145-148 _applySnapshot, then _finishFromSnapshot

Your own distance has two sources, and _applySnapshot reconciles them: if the server’s distance for you is ahead of the local count, the local counter snaps up to it (app/lib/features/race/race_notifier.dart:243-247). The display picks the larger of the two (RaceState.displayDistance, lines 69-72). The local count never goes backwards because of a stale server value. The server stays authoritative for the opponent, the race status and the result; the phone is authoritative only for “how far have I gone right now”.

The _Racing view (app/lib/features/race/race_screen.dart:257-414) shows distance, current and average pace, both progress bars and the gap (“+40 m AHEAD”), with banners above for a down socket (“Reconnecting…”), a lost permission, a tracking error, or an opponent who left (lines 309-318).

When you cross the line, RaceProcess.check_for_winner/2 sets the race to :won, stamps you :won, and broadcasts {:competitor_won, id} (backend/lib/racing/race_process.ex:441-448). The app’s _onEvent sees its own id and calls _finish(RaceOutcome.won) (app/lib/features/race/race_notifier.dart:187-188).

If the opponent crosses first, the server status becomes :won with them as winner and the race goes on for you; when you cross, you become :finished, and once everyone is won, finished or aborted the race is :finished (backend/lib/racing/race_process.ex:434-439, 496-507). maybe_stop/1 then broadcasts :race_finished and stops the GenServer (lines 538-544). The app’s raceFinished handler works out the outcome from the final snapshot in _finishFromSnapshot (app/lib/features/race/race_notifier.dart:321-336).

_finish (app/lib/features/race/race_notifier.dart:338-343) sets phase: finished and the outcome, then _stopTracking() (lines 367-379), which calls ForegroundRaceTracker.stop() and clears ActiveRaceStore. stop() calls stopBackgroundTracking(), which stops the service and removes the upload target keys so the token does not outlive the race in the plugin’s plain storage (app/lib/tracking/location_service.dart:364-376). In the service isolate, onDestroy cancels the GPS subscription, awaits any drain in flight, and closes the HTTP client and the queue (lines 212-220).

Leaving early goes through abort() (lines 347-365): cancel the timers, push abort on the channel or fall back to POST /api/races/:id/abort, set phase: aborted, stop tracking. The on-screen control is a slide-to-confirm (app/lib/features/race/race_screen.dart:385-389) wired straight to abort(); the system back gesture asks first with a “Leave the race?” dialog (lines 70-89, 98-102). On the server both paths reach Racing.abort_race/1 (backend/lib/racing.ex:46), which marks you aborted and awards the opponent a win by forfeit if they are the last one running (backend/lib/racing/race_process.ex:380-396, 470-490).

RaceScreen calls _maybeLeave(st) on every data build (app/lib/features/race/race_screen.dart:57-63, called at line 114). Once st.isOver, it navigates with context.go(AppRoutes.raceResult(id)) after the frame. Between the state change and the navigation, _Racing shows “Fetching the result…” in place of the slider.

ResultScreen does not use RaceNotifier’s outcome. It watches raceResultProvider(id) (app/lib/features/race/result_screen.dart:21-29), which calls GET /api/races/:id/results (app/lib/data/repositories/race_repository.dart:39-46, backend/lib/footrace_web/controllers/api/race_controller.ex:33-41). If the server says the race is not finished yet (the opponent is still running), the provider schedules ref.invalidateSelf in three seconds, so it re-fetches until it is. invalidateSelf is Riverpod’s “throw away my value and rebuild me”; combined with autoDispose, the polling stops when the screen goes away. While provisional, the card says “Your opponent is still running. This updates by itself.” (lines 134-141).

The headline comes from outcomeFor (app/lib/data/models/race_result.dart:127-140), a switch over your own competitor status in the server’s results: “You won”, “You lost”, “Tie”, “You left the race”, “Opponent left the race”, or “Race in progress” (app/lib/features/race/result_screen.dart:186-193). Because it reads server statuses, the early-finish problem above would show “Race in progress” rather than “You lost” on this screen.

“Done” goes to /home; “Race again” goes home and pushes the queue for the same distance (lines 166-181).

Nothing on the result screen refreshes other screens. historyProvider and authProvider are both kept alive for the whole session, not auto-disposed (app/lib/features/history/history_notifier.dart:109-110, app/lib/features/auth/auth_notifier.dart:126-127), and nothing invalidates them after a race:

  • History: if the History tab has not been opened yet this session, its first build loads page one and the new race is on top. If it was already loaded, the old list stays until you pull to refresh (app/lib/features/history/history_tab.dart:54, which calls HistoryNotifier.refresh, app/lib/features/history/history_notifier.dart:75-87). footrace-flutter/docs/use-cases/history-and-profile.md step 6 expects the new row without a refresh; that holds only in the first case. The integration test opens History for the first time after its race, so it does not catch the second.
  • Profile: stats come from /api/me, fetched at sign-in. Pull to refresh on the Profile tab calls refreshMe() (app/lib/features/profile/profile_tab.dart:50). The use case says this explicitly (step 7).

The tracker’s internals, the permission gate and the simulator are in Tracking; the socket and channel layer in Realtime. Day 9, Day 10 and Day 11 walk the same code in sittings. What happens when any of this goes wrong is on Failure modes.