Skip to content

Architecture

This document describes how the codebase is actually organized. It is the source of truth for current behavior. ../PROJECT_PLAN.md is the original vision document, kept for context — treat its ideas as goals, not status.

Layers

The repo is a pub workspace: the Flutter app at the root plus one package per mini game under packages/.

pubspec.yaml            # workspace root: shared resolution for app + packages
packages/
  game_core/            # the plugin contract: MiniGame, GameMetadata,
                        # GameSession, GameRegistry, Player, SessionOptions,
                        # GameCategory, TurnTimerLevel/Paces, DrawDeck
                        # (the pure-Dart shuffle-bag dealer behind every
                        # content pool), and the SessionLink transport
                        # seam (ADR 0005)
  game_mechanics/       # shared pass-and-play mechanics: the design
                        # tokens (AppSpacing/AppRadius/AppSizes),
                        # SessionScope + the session providers,
                        # drawDeckProvider,
                        # SessionScaffold,
                        # SessionTurnHeader, CountStepper, PassDeviceGate,
                        # TurnTimer, PlayerAvatar, PlayerChipRow,
                        # player palette, ShareActions, SpeakQueueButton,
                        # VoteRound + VoteScoreboard + VoteResultsView
                        # (ballots + scoring + the results screen),
                        # SessionResultsView + PlayerScoreboard
                        # (scoring without a ballot),
                        # TeamAvatar + TeamScoreboard (team identity
                        # and ranked team results), player utils,
                        # MechanicsStrings
  game_content/         # shared curated vocabulary: all-ages-safe word
                        # pools, the template + phrase composers, the
                        # pool-key/deal helpers bridging pools to DrawDeck,
                        # the syllable-based nonsense-word generator, the
                        # shared venue (restaurant) generator, and
                        # the guess card machinery (GuessWord, GuessCard),
                        # and the confusable-family lens over the noun
                        # pools (confusables.dart)
  desert_island_poetry/ # a mini game package (the reference implementation)
  dictionairheads/       # a mini game package (fake definitions + vote)
  draw_core/             # the reusable drawing toolkit behind the drawing
                        # games: DrawingDocument (an ordered op history of
                        # DrawStroke/DrawFill ops + compact wire codec),
                        # DrawingController (pen/bucket tools, stroke
                        # widths, the 7-second auto-split, last-line-only
                        # undo budget), the flood-fill raster, the
                        # DrawingBoard editor and DrawingView renderer —
                        # both walking the same midpoint-quadratic stroke
                        # curve so painted ink and fill barriers can't
                        # diverge — the FadingInkBoard variant whose
                        # committed strokes dissolve over five seconds
                        # (per-op commit stamps on the controller feed
                        # per-stroke alphas in DrawingPainter; fills are
                        # off its toolbar), the pointer-pace probe that
                        # warns when a browser feeds moves slower than a
                        # drawing cadence — strict by design (skips the
                        # warmup stroke, needs two consecutive coarse
                        # windows above ~25 fps) and limited to one
                        # banner per session via BoardPaceHintMemory —
                        # the shared paintColorPool +
                        # luminous dreamInkPool + pickPalette,
                        # DrawCoreStrings
  draw_what_now/         # a mini game package (the first drawing game:
                        # secret "orders" drawn on a 4-color palette, then
                        # reviewed with cover-story guesses + a ballot —
                        # network-only via requiresNetwork)
  infinite_monkeys/      # a mini game package (Shakespeare mad libs)
  island_of_doctor_maroon/ # a mini game package (forbidden-word
                        # creature rescue — the describer holds the
                        # device while the room guesses)
  unfocused_group/      # a mini game package (Megacorp survey ratings —
                        # secret consumer rating, secret researcher
                        # guesses, teams huddle; the first teams
                        # consumer)
  acquired_taste/       # a mini game package (influencer fiction —
                        # a dealt secret rating, typed calibration
                        # picks as evidence, follower decodes, fandom
                        # huddles in teams mode)
  buildboard/           # a mini game package (billboard painters —
                        # shared fake clients get slogans from one half
                        # of the crew and 4:3 art from the other, the
                        # work is shuffled and dealt back out, and each
                        # seat assembles a billboard per client; the
                        # vote rides the shared RemoteBallotView (art
                        # in its entryMedia slot, "you hung this one"
                        # as the own tag) under the same forced-pick
                        # rule — a seat with fewer than two boards it
                        # didn't curate is react-only, and a locked
                        # ballot always names a board (no abstaining);
                        # network-only — the easel letterboxes inside
                        # portrait rather than rotating phones)
  dreamweavers/         # a mini game package (Draw What Now? in
                        # vanishing ink — each weaver draws a secret
                        # dream for an assigned dreamer on the night
                        # canvas where lifted lines dissolve, then the
                        # room reviews each piece with the dreamer named;
                        # the dreamer never writes a cover story, every
                        # seat with an unforced pick votes (a thin
                        # ballot drops a forced reader to emoji-only),
                        # and the dreamer's pick pays its target double.
                        # network-only via requiresNetwork)
  this_or_that/          # a mini game package (would-you-rather
                        # dilemmas — the catalog's lightest: one card at
                        # a time, a rotating asker, no secrecy and no
                        # scoring; the first 1-player game, and the only
                        # one whose deck names seats when the roster is
                        # 3+)
  sketchy_sources/       # a mini game package (drawing telephone — every
                        # seat is dealt a wire headline and draws it, then
                        # each story hops to a freshly-matched source who
                        # captions it or draws the caption, alternating
                        # until everyone has touched every story; a seat
                        # sees only the one artifact it's interpreting.
                        # The reveal paces each story's chain out beat by
                        # beat and the ballot stars links — adjacent
                        # headline↔drawing pairs paying both endpoint
                        # authors. network-only via requiresNetwork)
  off_by_one/           # a mini game package (hidden-role convention
                        # crashers — every seat is dealt a con program and
                        # a few hold the wrong floor's, with nobody told
                        # which side they're on; a deranged ask-around
                        # mingle, a secret accusation ballot, then the
                        # outed seats' five-option steal guess)
  session_link/         # the socket transport behind the game_core
                        # SessionLink seam: WsHostSessionLink (embedded
                        # WebSocket/HTTP server, lobby, join ticket) and
                        # WsClientSessionLink, plus JoinTicket (the QR
                        # payload) and LinkAddresses (private-LAN
                        # interface picking). `session_link.dart` is the
                        # dart:io barrel; `session_link_client.dart` is
                        # the dart:io-free client barrel the web build
                        # imports (its WebSocket rides the conditional
                        # `link_socket_*` files)
lib/
  main.dart             # entrypoint: bootstrapApp() -> UncontrolledProviderScope
  app.dart              # MaterialApp.router root: theme, routing, l10n
  core/
    app_messenger.dart  # the app-wide scaffoldMessengerKey (snackbar route
                        # for service-level reports like a dead TTS engine)
    bootstrap.dart      # shared startup: builds the ProviderContainer
    build_info.dart     # BUILD_TIMESTAMP dart-define + package info
    keep_awake.dart     # the wakelock wrapper (wakelock_plus)
    lan_discovery.dart  # bonsoir/mDNS lobby beacon + browser — see
                        # "Local multiplayer"
    controller/         # non-touch input layer: controller_shortcuts/
                        # intents (Escape/gamepad-B back, X/I details,
                        # Start/F1 host tools), ControllerInputBridge
                        # (gamepad commands → intents), FocusRingOverlay,
                        # ControllerLegend, VerticalFocusEscape,
                        # FocusCenteringScroll, TvModeScope,
                        # controllerTraversalPolicy
    theming/            # AppTheme (M3 seed-color builder),
                        # AppTypography (Fredoka)
    routing/            # go_router configuration + rootNavigatorKey
    widgets/            # design-system components (AppScaffold, AppSection,
                        # AppIconButton, AboutTile, MotifBackdrop,
                        # GameSticker, CountdownSplash, QrScannerView,
                        # the exit-app dialog)
  data/
    repositories/       # persisted state over SharedPreferences
                        # (SettingsRepository, PlayerHistoryRepository,
                        # DrawDeckRepository, GameStatsRepository,
                        # RemoteDraftRepository)
    services/           # platform wrappers behind interfaces
                        # (ScreenPinningService, TvDetectionService,
                        # GamepadInputService + GamepadCloneFilter,
                        # the Speaker/SoundPlayer/MusicPlayer engines,
                        # RoomPresenceService, HotspotService,
                        # WifiJoinService, WirelessDisplayService,
                        # WindowControlService, AppShareService,
                        # TextShareService, LoggingService,
                        # LinkDiagnostics, piper voice downloads)
  features/             # one folder per user-facing feature; each owns its
                        # screens and its riverpod providers
    home/               # the game picker + about dialog + per-card info
                        # dialog (the game's description lives there, not
                        # on the setup screen), plus the picker's sort and
                        # filter machinery: picker_query.dart holds the
                        # pure sort/filter logic, picker_providers.dart the
                        # persisted sort mode, the manual order
                        # (picker_sort / picker_order keys), the per-launch
                        # shuffle seed, and the session-only filters,
                        # picker_sheets.dart the sort / arrange / filter
                        # sheets. HomeScreen also mounts the room
                        # surface: the RoomBanner, the invite dialog
                        # (both in multiplayer/), and the ReconnectBanner
    multiplayer/        # the Play Together screens: host lobby, join
                        # screen + QR scan route, RemoteGameScreen (the
                        # seated client's in-call view), the host toolbox
                        # bubble/sheet, PausedOverlay + WrapUpChip, the
                        # embedded FallbackWebScreen, and hotspot
                        # credential minting
    session/            # game catalog, session arming (SessionSetup),
                        # GameSessionHost
    settings/           # the settings screen + its providers, and the
                        # log viewer at /settings/logs
    setup/              # GameSetupScreen — the single metadata-driven
                        # setup every game shares (roster, bot player,
                        # teams, networked seats, round settings,
                        # common settings, rules dialog)
    stats/              # the play-statistics harvest (CallStatsRecorder,
                        # GameCallRecord mapping) and the /stats roll-up
                        # screen
  l10n/
    app_en.arb          # source-of-truth English strings
    generated/          # generated by `flutter gen-l10n` — never hand-edited
  web_client/           # the browser client's own entrypoint + no-router
                        # shell (join -> room -> remote view), built by
                        # `flutter build web` into assets/
test/                   # mirrors lib/ (incl. the hardcoded-strings guard)
packages/*/test/        # each game package owns its tests
tool/                   # build.sh, verify.sh, signing + icon generators, hooks
linux/packaging/        # desktop file, metainfo, fastforge configs, rpm script
flatpak/, snap/         # non-store Linux packaging manifests + scripts
metadata/               # store listing copy + icon (fastlane/F-Droid format)
ci/, .gitlab-ci.yml     # builder image + pipeline
.githooks/              # versioned hooks; install via tool/install_git_hooks.sh

Why this shape

  • game_core is the "plugin" seam. A mini game is a package that implements the MiniGame contract (metadata + buildSession). The app's GameRegistry (built in lib/features/session/game_catalog.dart) lists every compiled-in game. "DLC" means a new package added to the workspace and registry, shipped as an app update — plugin loading is compile-time on purpose: it keeps the app fully offline, avoids downloaded-code App Store rules, and keeps every game type-checked.
  • game_mechanics holds the shared pass-and-play machinery from the plan: PassDeviceGate ("Pass the device to X" / "I am X"), TurnTimer (optional per-turn countdown, with a pulsing warning state near zero), PlayerAvatar + playerPalette (player identity = name + emoji + color), and the reusable vote machinery: VoteRound — the post-game ballot where the device passes to each voter behind the same gate, every voter stars one favorite and drops emoji reactions on others' entries (authorship stays hidden on the ballot; an optional subject shows what's being voted on), and the round resolves to a VoteResult whose helpers (pointsFor, winnerIds, mostReactedIds…) implement the app's scoring scheme: favorite = 100 points, each emoji reaction = 10, all emoji scored positively. VoteScoreboard renders the result — ranked players, each followed by their scored entries with the reactions flying through them. Entries authored by the app itself use the botAuthorId convention: votable like any other, sorted into the scoreboard, but never eligible for a player callout. Every session screen wraps its phase content in SessionScaffold — the shared app bar with the game's title and the close button — so no game hand-rolls its own chrome or forgets the exit. Mid-session, the close button and system back both route through the game's themed exit gate (SessionScaffold.onExitRequested → showExitGate): a stay/leave confirmation that gains a "see the standings" action once a full round has been scored, which calls the controller's endSessionEarly() to jump to the results phase. Games pass null on phases where exit is already safe — the results screen stays ungated. drawDeckProvider is the odd provider out: unlike the four session providers it is not overridden by SessionScope — it resolves in the root container, so every game and every session shares the one deck, and bootstrapApp hydrates it from disk. Authored content draws go through it (deck.deal, dealPool, composePhrase/ composeGuessCard take the deck); anything that is game logic rather than authored content — bot guesses, hand shuffles, voice rolls — stays on sessionRandomProvider. The recurring phase widgets are shared too: SessionTurnHeader (avatar, label, turn timer, progress), CountStepper (−/+ pickers — also the shared control for count round options on the setup screen), SpeakQueueButton (read-aloud — one utterance queue, a voice per entry, icon-button variant for prompts), and VoteResultsView (callouts over the scoreboard plus closing actions). VoteResultsView is itself a thin vote shell over SessionResultsView — the scoring-agnostic results layout (headline, callouts, scrollable score area, closing actions) that points-based games fill with PlayerScoreboard instead of a VoteScoreboard — or TeamScoreboard when a session runs in teams mode (team identity mirrors player identity: TeamAvatar is name + emoji + palette color); PlayerChipRow covers the "pick a player" moment (Doctor Maroon uses it to credit whoever guessed a creature). ShareActions is the copy/share button pair for reveal and results screens — sharing falls back to the clipboard when the platform has no share sheet. Games compose these rather than rebuilding them.
  • game_content holds the shared vocabulary. Curated all-ages-safe word pools (adjectives, nouns, names, creatures, quirks, clauses, partitives…) plus pick/articleFor/articleForPhrase/fillTemplate — the machinery that composes them into titles and prompts — PhraseTemplate + ComposedPhrase + composePhrase (a second composer flavor: a phrase recipe is noun-pool slots plus a compose function; both the recipe pick and every slot draw go through the shared DrawDeck, so no recipe or word repeats until its pool wraps), plus poolDeckKey + dealPool — the helpers that give any pool a stable identity (a content fingerprint for value types, or an authored constant for opaque entries like template closures) and deal straight from it — plus curated_pool.dart: CuratedPool + dealCuratedOrElse, the convention that pairs every generator with a hand-written list dealt at a per-pool share (10% by default) through the same deck — and nonsense_words.dart: a syllable composer (randomNonsenseWord, configurable minSyllables/maxSyllables) that mints pronounceable fake words with a fake part-of-speech tag, plus botDefinition — the app-written "bot" entry a game can slip into a vote — and guess_cards.dart, the forbidden-word deck machinery: NounEntry.forbidden gives every guess-target bank entry a ranked ban list (most-obvious clue first, so a game bans the first few or the whole list), GuessCard carries the card's words and their bannedWords, and composeGuessCard is composePhrase plus the rule that every component word must carry authored bans — for Doctor Maroon and any future guess-the-thing variant — and confusables.dart, the confusable-family lens: NounEntry.families tags a word into one or more sets of near-identical things ("rodents", "dumplings"), NounEntry.definition carries the one-line differentiating gloss, and NounEntry.generators allowlists which content generators may draw the word (NounGenerator.blank/guessCard/confusable) — so a confusable word without authored bans feeds Off by One and mad-libs but never surfaces as a guess-card target. NounEntry.kind is the semantic lens slots pick by: thing is reserved for concrete objects so an "a {thing}" slot can't mint "a bowling", while activity, phenomenon, landform, style, and concept keep sports, weather, terrain, hairdos, and abstracts available to templates that want them; singularThings narrows further to article-carrying objects for singular-subject slots. Homonym senses split across pools (the safeNouns "Mullet" is the hairstyle, the animals "Mullet" is the fish) rather than one entry compromising its clues. Every entry must be safe under every combination (the contract is documented in word_pools.dart); a future "spicy" pack contributes its own pools rather than appending to these. The flip side of that curation is ADR 0004: app-produced content is curated, but player-entered content — names, poem lines, custom emoji — is never filtered. These are face-to-face games; what players write is their own business.
  • Features never import platform plugins directly. Platform calls go through a data/services interface (ScreenPinningService, FlutterTtsSpeaker) so tests and desktop builds can substitute no-op fakes.
  • Session state is game-owned. GameSession hands the game a List<Player> (seating order), resolved SessionOptions, a Speaker, a SessionLink transport seam (see Local multiplayer below), an onExit callback, and the session-scoped extras (actions, onOutcome, turnPing, debugLog, seatDriver, selfProjecting). Each game wraps its session in SessionScope, which seeds the shared providers from it — sessionPlayersProvider, sessionOptionsProvider, sessionSpeakerProvider, sessionLinkProvider, sessionActionsProvider, sessionOutcomeProvider, sessionTurnPingProvider, sessionDiagnosticsProvider, sessionSeatDriverProvider, sessionSelfProjectingProvider — so state lives and dies with the session and a game can't leak state into the shell. (sessionRandomProvider is the deliberate exception — it is not seeded, since a fresh Random is already the right default; tests override it for determinism.) The controller's @Riverpod annotation must declare dependencies: on every shared provider it reads — otherwise it resolves in the root container, the seeded overrides never reach it, and the session crashes on start. Games never define their own players/options/speaker/random providers: the shared names are the whole contract.

Code generation

After pulling, or after editing anything the generators cover, run:

flutter gen-l10n              # lib/l10n/app_en.arb -> lib/l10n/generated/
dart run build_runner build   # app: freezed, riverpod_generator
(cd packages/<pkg> && dart run build_runner build)   # per package

tool/verify.sh runs the whole pipeline (format check → gen-l10n → build_runner everywhere → format → analyze → tests).

  • freezed (*.freezed.dart): immutable models and game state (Player, SessionSetup, PoetryGameState, PoemLine).
  • riverpod_generator (*.g.dart): provider boilerplate from @riverpod annotations.
  • flutter gen-l10n: AppLocalizations from lib/l10n/app_en.arb.

Generated files are committed, excluded from analysis, and must never be hand-edited.

Theming

core/theming/app_theme.dart builds ThemeData from a single seed color via ColorScheme.fromSeed — AppTheme.light() / AppTheme.dark() are the only theme builders. The dark scheme blends 8% primary into every surface level — the stock near-black read as a void — and scaffoldBackgroundColor follows it. MaterialApp.themeMode comes from the persisted theme_mode setting (Settings > Appearance); the web client has no settings screen but reads the same key — its browser-chrome night-mode button writes it, so a TV's pick persists on that origin. Typography comes from AppTypography, which applies the bundled Fredoka variable font with per-style wght FontVariations and the shared emojiFontFallback chain on every style — the browser client's bundled Twemoji face is what that chain lands on where no OS emoji font exists (see "The web client" below and design-system.md's Typography section). Spacing, radii, and sizes are tokens in game_mechanics' app_spacing.dart — shared with the app so lib/ and packages/ use the same scale. UI code must not hardcode raw numbers.

Per-game GameMetadata.accentColor tints picker cards and in-game highlights; it does not replace the app theme — except on the game's own setup and session screens, where gameBackgroundFor blends the accent into the page surface and backdropTintFor contrasts the wallpaper against it (see design-system.md). GameMetadata.motifEmoji is the decorative-only sibling: a small emoji set per game that wallpapers the home screen (all motifs mixed, each in its game's own tint), the setup screen, and the session via GameBackdropScope — see "Motion" below and design-system.md.

Motion

Motion splits into two contract classes (full widget list in design-system.md):

  • One-shot — entrances, stamps, squishes. They settle under pumpAndSettle and render their final state instantly when MediaQuery.disableAnimations is on.
  • Ambient — loops (EmojiBackdrop drift, Wiggle, tagline cycling). They may only run under an AmbientMotion InheritedWidget permit; AmbientMotion.of(context) is false with no ancestor and under reduced motion. The app root (MaterialApp.router's builder in app.dart) and the web client root install AmbientMotion(enabled: driven by ambientMotionEnabledProvider — a runtime-only keepAlive setting, default on, which full-app tests override to false.

The ambient permit also degrades itself: AmbientGovernor (lib/data/services) watches SchedulerBinding frame timings and trips ambientMotionDegradedProvider — the latch ambientMotionEnabled reads — when a rolling four-second window shows sustained over-budget frames (half of ≥20 frames past a five-second startup warm-up). A device that can't paint the backdrop in budget — a TV browser on a stick SoC — loses the loops silently while everything else keeps them; one-shot choreography never reads the flag. The trip is permanent for the app's run: cheap frames afterward can't prove the backdrop would have been fine, because it isn't running.

wrapGameSession also wraps every session in GameBackdropScope (the game's motifEmoji + accentColor); SessionScaffold reads maybeOf(context) and paints an EmojiBackdrop behind the session body. Absent scope — e.g. widgets pumped bare in tests — no backdrop. The wallpaper stacks (SessionScaffold, home, setup) all pin StackFit.expand so a shrink-wrapping phase child can never collapse the wallpaper to its content height — the field always covers the whole body.

Localization

All user-facing text flows through lib/l10n/app_en.arb — never hardcode UI strings. Unlike the sibling project, source strings use natural casing.

Game packages can't see the app's AppLocalizations, so localization crosses the package boundary via string scopes:

  • Each game/mechanics package defines a <Name>Strings class with English defaults plus a <Name>StringsScope — a three-line subclass of GameStringsScope<T> (game_mechanics) — and a <Name>Strings.of(context) accessor.
  • wrapGameSession in lib/features/session/game_catalog.dart wraps every session in MechanicsStringsScope (built from ARB) plus the game's own scope, keyed by GameMetadata.id.
  • localizedGameInfo in the same file maps game ids to ARB-backed name/tagline/description — the picker card shows the tagline and its info button opens the description in a dialog. localizedPlayersTitle, localizedPreviousPlayersTitle, localizedBeginLabel, localizedBotPlayer, localizedRoundOptions, and localizedRules feed the setup screen the game's flavor.

When a new locale ships, only app_<locale>.arb is added — game packages stay untouched.

Session settings

Gameplay knobs live at three levels and merge at session start, all edited on the single GameSetupScreen:

  • Globals in SettingsRepository: scoring_enabled (default on), bot_plays_enabled (default on), inspiration_enabled (default on), typos_enabled (default on — the plausible-typo seasoning on app-filed ballot text), turn_timer_level (off/quick/standard/extended, default off), theme_mode (system/light/dark, default system — not a gameplay knob, but it lives in the same store and drives MaterialApp.themeMode).
  • Common settings (per-game overrides): game_scoring_<gameId>, game_inspiration_<gameId>, game_typos_<gameId>, and game_timer_<gameId>; absent means "inherit the global". The setup screen's always-present "Common settings" tile (starts collapsed, showing the effective values) edits these.
  • Round options: round_option_<gameId>_<optionKey> — the stored scalar for each RoundOption the game declares on GameMetadata.roundOptions (count, choice, switch, or slider — the app supplies the control, the metadata supplies label, flavor, and order). The setup screen's "Round settings" tile (starts expanded, collapses to a per-option summary) edits these. Every pick writes through to storage immediately, so the next setup opens on the last configuration rather than the defaults.

resolveSessionOptions merges the three with what the game declares in GameMetadata (supportsScoring, supportsInspiration, timerPaces — the game's own durations per TurnTimerLevel, so the UI can show real seconds next to each level — and bot, the BotPlayer persona) plus the roster and configured team split from the setup screen: a roster below GameMetadata.minScoringPlayers never scores — a vote of one is a coronation, and two-player votes just grade each other — regardless of the scoring settings, and the setup tile notes it under the control. The resulting SessionOptions — including the typed roundValues map the game reads back via SessionOptions.roundValue<T>(key) — is baked into SessionSetup when the begin button is tapped, so changing settings mid-session never mutates a running game. The session mounts straight into play: each game's session screen calls beginFromOptions() once, which resolves those values into the state machine — there is no in-game options step.

A game opts in to a setting by declaring it on its metadata — a game with supportsScoring: false never sees the switch, and timerPaces: null never gets a countdown. The vote round itself lives in game_mechanics, so scoring is free for any game that produces a list of authored entries.

GameMetadata.bot declares a seatable computer player — Board-O-Bot in Dictionairheads, Bard-O-Bot in Infinite Monkeys, Bottle-O-Bot in Desert Island Poetry (the -O-Bot suffix is the app's bot-persona convention, so the seat reads as the CPU player at a glance). On the setup screen the bot moves in and out of the roster like a regular player: seated it gets a roster row — persona emoji on a grey avatar, name in italic, the usual remove button — and unseated it waits in the quick-seats card as a tap-to-add chip next to the returning players. It never joins the roster as a Player and never lands in player history. The seat is the single global bot_plays_enabled flag — carried between games like the roster itself, not per game. Games without a bot persona show neither row nor chip. inspirationEnabled is independent: it gates the per-answer "inspire me" affordances (Ask the Muse / the Focus Group / Bard-O-Bot) whose suggestions join the vote flagged VoteEntry.assisted and bank half points. Island of Doctor Maroon wears the same flag differently: the describer's "Consult the field guide" deals a clue angle ("tell them what it eats") rather than an answer — one per card, secret on the describer's private wire view, and a field-guided rescue halves the describer's share (CardRecord.inspired; +50 of +100 solo, +100 of +150 for the side) while guessers keep their whole cut.

GameMetadata.supportsTeams declares that a game can split the roster into competing Teams — a platform feature, not a per-game construct. The setup screen shows a Teams tile for those games: a "Play in teams" switch (game_teams_<gameId>, off by default), a side-count stepper, tap-to-cycle member chips, and per-side name/emoji/color editing. GameMetadata.minTeamSize is the fewest humans a side may field — it gates the offered team count and blocks Start while any side is short. Dealing counts off in seating order so neighbors land on different sides; any roster change re-deals, so a stale assignment can never reach a session. At start the split is baked in two ways — each Player.teamId points at its Team.id, and SessionOptions.teams carries the resolved Team list — so a game reads its teams without touching settings. The split persists in team_state (PlayerHistoryRepository) bound to a roster signature: the sorted ids of the humans it was picked for. The same humans keep their teams across games — including games that never read them, which leave the record dormant — while any seat joining or leaving invalidates the signature and the group picks teams again. TeamScoreboard in game_mechanics renders the ranked result for team-scored games.

GameMetadata.category (writing, guessing, bluffing, …) is the grouping vocabulary shown on picker cards.

Read aloud (TTS)

Games read text aloud through Speaker (game_core), which every GameSession carries — the app injects FlutterTtsSpeaker (data/services, wrapping package:flutter_tts) via speakerProvider, and tests or engine-less platforms substitute NoOpSpeaker. flutter_tts has no Linux implementation at all, so on Linux the provider hands out ProcessSpeaker instead: it shells out to the first usable backend — piper when its binary plus at least one voice model plus a wav player (paplay/pw-play/aplay) all resolve, then the desktop engines spd-say → espeak-ng/espeak — kills the prior process when a new utterance replaces it, and feeds the voice picker from whatever the winning backend offers.

Piper is the neural engine every package format bundles (release binary + en_US-l2arctic-medium + en_US-libritts_r-medium — two multi-speaker models covering ~24 accented and ~900 native English voices). Its pieces differ from the flag-table engines, so it gets its own path: voices are files — any <name>.onnx + <name>.onnx.json pair found in the scan order (app download dir → ~/.local/share/ piper-voices → /usr/{local/,}share/piper-voices → $LIBREBASH_PIPER_VOICES → <exe-dir>/piper-voices, first dir wins per basename) — and each speaker_id_map entry becomes a voice entry ({engine: piper, model: <basename>, speaker: <id>}, capped at ProcessSpeaker.maxSpeakersPerModel = 50 since piper orders speaker ids by training data). The binary resolves via $LIBREBASH_PIPER, then <exe-dir>/piper/piper, then PATH. Speaking is a pipeline: piper reads text on stdin, synthesizes a temp wav (--model, --speaker, --length_scale — the inverse of our rate multiplier, clamped to 0.5–2.0), then the player plays it; temp wavs are swept on any end.

Every spawned helper — piper, the wav player, the flag-table engines — runs without the AppImage's usr/lib LD_LIBRARY_PATH entries (ProcessSpeaker.childEnvironment). Those libraries exist for the app's own closure and are copied from whatever the build host's ldd resolved; a foreign-built glibc-private stub like librt.so.1 makes ld.so abort a spawned engine instantly, which presented as piper silently failing on hosts whose glibc differs from the build machine's. Bundled piper is self-contained via $ORIGIN and host binaries want host libraries, so the scrub is safe everywhere — not just AppImage.

A helper dying mid-utterance is never silent: ProcessSpeaker captures each spawned process's stderr, and a non-zero exit, a failed spawn, or a watchdog timeout writes a one-line autopsy (stage, exit code, stderr tail) to the debug log and pings Speaker.onError. speakerProvider wires that to a snackbar through the app-wide scaffoldMessengerKey (core/app_messenger.dart) so a dead engine can't be mistaken for a muted speaker. Stopped or replaced utterances stay quiet — the killed process's late exit lands on a stale token and never reports.

The flag-table engines keep the design assumption that process lifetime == utterance — spd-say therefore always speaks with --wait, since its default only enqueues and exits (an early exitCode races the queue ahead and each new message preempts the one still playing). Engine probing is likewise hardened for real desktops: all isAvailable callers share one probe future (a mid-probe read used to report "no engine" and cache an empty voice list), every filesystem check is async + timed so a stalled mount can't freeze the scan, and a candidate whose probe answers with empty output — a spd-say whose daemon lists no voices — fails over to the next engine, since it could speak but never populate the picker. The contract degrades silently: a device with no TTS engine stays quiet rather than throwing, so games never need to feature-check. FlutterTtsSpeaker.getVoices also drops voices that can't actually render the text: usableEngineVoice removes not_installed and network-only entries that fail offline, and speaksLanguage keeps only voices whose locale matches the device language — a foreign-language synthesizer drops unverbalizable text and can emit near-silence for a whole line. Its completion funnel treats engine errors, refused utterances, and a stall watchdog the same as a finished read so callers can never be left waiting on a callback that isn't coming.

Settings are global and silliness-friendly: voice and speech-rate multiplier each default to "random each round" — SessionOptions. ttsVoice/ttsSpeechRate carry the user's pick, where null means roll. A game resolves the concrete pair once per round (poetry stores them on PoetryGameState.roundVoice/roundSpeechRate when the reveal is reached and clears them on play-again) so rebuilds and repeated button presses never re-roll. The dice live on Speaker itself so the engine tunes them: rollSpeechRate rolls 0.5–1.5 by default but piper narrows to 0.8–1.2 (its speakers already vary widely in pace — random mustn't stack speed on speed), and rollVoice picks a model group first, then a speaker inside it, so libritts_r's capped 50 can't drown l2arctic's 24. Speaker.onComplete is how a read-aloud button learns its utterance finished (or was declined). Two optional capabilities extend the contract for the display path: synthesize renders a line to WAV bytes without playing it (null when the engine can't — the default, and engines like spd-say or Web Speech stay text-only), and acceptsClips marks a speaker that plays shipped WAVs instead of synthesizing text itself. DuckingSpeaker delegates both — synthesis renders silently so it never ducks.

Beyond the user-tapped SpeakQueueButton and the per-seat RemoteVoiceChorus, two shared widgets announce unprompted — the session's speaker does the room-facing courtesy the chorus does per phone: PassDeviceGate speaks "Pass the device to {name}" on mount (and again when its seat swaps mid-mount, and stops on dispose so a fast confirm can't bleed into the next phase), and SessionResultsView reads its callout — or the title on a crown-less board — plus the secondary callout a beat after calloutDelay, so the fanfare lands first. Ambient announcements use the configured ttsVoice/ttsSpeechRate verbatim (nulls mean engine default — the random-roll character voices belong to the chorus, not the PA system). Both read through sessionSpeakerOrNoOp/ sessionOptionsOrNone (session_scope.dart): bare mounts without a ProviderScope degrade to silence/defaults rather than throwing.

The Settings screen's picker shows engine voices as readable labels — "English (United States) – Samantha" — produced by TtsVoiceLabelService/TtsVoiceDisplay (pure Dart in game_core, so tests cover the formatting), plus a preview that speaks with the configured-or-rolled settings. The dropdown caps multi-speaker models at the first 24 speakers (ttsVoiceDisplaysProvider) so the picker stays browsable; the random pool keeps the larger cap. On piper the screen also hosts the voice downloads manager (data/services/piper_voices.dart): a curated Hugging Face catalog with per-entry progress, and downloaded models with preview + delete. Downloads stage via .part files and invalidate speakerProvider on completion so new voices appear without a restart.

Sound effects

Phase 2 of "party mode": a shared one-shot cue vocabulary wired through the same seam pattern as read-aloud — the audio analogue of Speaker:

  • The contract (game_core/lib/src/sound_effects.dart): SoundCue enumerates the fourteen cues; SoundPlayer is the seam — play is fire-and-forget, overlapping plays mix, and every implementation degrades silently (a missing engine, a failed load, or a muted setting is a no-op, never an exception — a party game must not crash because a beep didn't happen). NoOpSoundPlayer is the const default; FilteredSoundPlayer wraps a player and only forwards an allow-list; RecordingSoundPlayer (game_core/lib/src/testing/) is the test double.
  • The scope (game_mechanics/lib/src/sound_scope.dart): SoundScope is the InheritedWidget and context.playSound(cue) the fire line — beside PartyHaptics in spirit. SoundScope.of falls back to a const NoOpSoundPlayer, so widgets pumped bare in tests stay silent rather than needing a fake engine. Cues ignore MediaQuery.disableAnimations — sound is not motion; the reveal choreography's onArrived callbacks are tracked timers (not animation listeners) precisely so a reduced-motion scoreboard still lands its ticks.
  • The engine (lib/data/services/sound_player.dart): SoLoudSoundPlayer wraps flutter_soloud (pinned 5.1.2, F-Droid-safe no_xiph_libs: true hook — see pubspec.yaml). It boots lazily on the first cue: the engine comes up through ensureSoLoudEngine (see the Music engine bullet — an unguarded SoLoud.init reinits a live engine) and the cue preloads ride one shared future, so concurrent early cues queue on the same load instead of racing a second engine boot, and a boot or preload failure flips the player into permanent silence rather than retrying a dead engine per cue. A refused single play (device churn, a foreign engine re-init invalidating the cached source) is logged once but no longer mutes the channel — the next cue retries. The volume setter lands per-cue at play time — never setGlobalVolume, which would make the effects slider fight the music channel's gain. The web client never builds this — soundPlayerProvider picks per platform (native_audio_players.dart vs web_client/web_audio_players.dart, bound by dart.library.js_interop), and the browser build gets WebSoundPlayer instead: one preloaded <audio> template per cue (fetched during the page's idle load — autoplay policy gates play(), never load()), cloned into an independent voice per cue so overlapping stings still mix. SoLoud-on-web serially fetch-decoded every cue asset inside a WASM mixer on first use — seconds of silence before the first tap on a TV stick's CPU; elements skip the engine entirely.
  • The bundle: assets/audio/sfx/<cue>.<ext> — short cues are 44.1 kHz WAV (no codec padding, taps land on the frame) and long stings are MP3, all peak-normalized to −1 dBFS; SoLoudSoundPlayer.assetFor owns the cue→file mapping. Declared as a pubspec asset directory; assets/audio/ATTRIBUTIONS.md credits every source (CC0 unless noted). tool/import_audio.sh re-runs the normalize+transcode for future picks.
  • Scope policy: the app root (app.dart's MaterialApp builder) installs the raw player — the host device plays the full vocabulary. RemoteGameScreen and player seats in the web client each wrap their shell in a FilteredSoundPlayer allowing tap, turnPing, timerTick, and timeUp — "phones small": a remote seat hears its own taps, the turn ping, and its own countdown (a seat's last-five-seconds tick is their call to act, not host fanfare), never the host's scoreboard. A web-client observer plays the full vocabulary instead — the /d display IS the room's speaker, so it gets soundPlayerProvider unfiltered. While an observer is attached the app shell itself drops into that filtered menu — the same roomDisplayPresentProvider roster stream that hands the music over swaps the root SoundScope — so the display owns the scoreboard cues in the lobby and in-call alike, the host keeps taps, the turn ping, and its countdown, and the full player restores when the last display leaves.
  • Settings: sfx_enabled / sfx_volume keys in SettingsRepository (default on, default 1.0, volume clamped 0–1). The SfxEnabled/SfxVolume notifiers persist, then write through to soundPlayerProvider — a mid-party mute silences the very next cue.
  • Browser autoplay policy: with cues on plain <audio> elements there is no AudioContext to suspend — a refused play() just drops that one cue, and the join form's real gestures (typing the URL, pressing join) have already unblocked audio for any seat that navigated there by hand. The cast Presentation WebView opts out entirely (mediaPlaybackRequiresUserGesture = false). Music adds one belt-and-suspenders layer for the gap in between: WebMusicPlayer parks a refused track on one-shot key/click/touch listeners so a remote's first input starts the soundtrack instead of the room sitting silent until the next track change.
  • Where cues fire: tap on picker cards, the setup start button, and remote forward actions; passWhoosh when the pass gate mounts and confirm on its identity button; countdownBeep/countdownGo on the splash; timerTick inside the turn timer's final five seconds and timeUp at expiry; stamp when a ballot star slams; turnPing on a remote seat's actionable edge; leaderboardRow as each scoreboard row lands (rimshot on the lowest zero-point row); revealSting when a results screen mounts; drumroll started so its ~3 s tail lands on the winner's row; winnerFanfare at the crowning (the Yo Frankie! sting carries its own applause) — single winners only, a tie gets nothing.

Music

Phase 3 of "party mode": a looping soundtrack layered over the cue vocabulary — one track at a time, owned by the widget tree:

  • The contract (game_core/lib/src/music_player.dart): MusicPlayer is the seam — request(slug) opens a stacked request for a bundled track (assets/audio/music/<slug>.mp3) and returns a token; the newest open request is the target and release drops back to whatever asked before it. duck()/unduck() form a refcounted dip for speech and celebrations, pause()/resume() cover app backgrounding, and enabled/volume mirror the settings. Every implementation degrades silently — a missing track or dead engine means silence, never a crash. NoOpMusicPlayer is the const default; RecordingMusicPlayer (game_core/lib/src/testing/) is the test double. MusicPlayer.menuSlot is the shared base layer.
  • The scope (game_mechanics/lib/src/music_scope.dart): MusicScope is the InheritedWidget; MusicScope.of falls back to a const NoOpMusicPlayer so bare test widgets stay silent. Three widgets ride it: MusicRegion holds a request for its slot while mounted (re-requesting if the scope or slot changes), MusicDuck holds a time-boxed dip (self-releasing so a lingering screen can't strand it), and MusicLifecycle observes the app lifecycle: real backgrounding (paused/hidden/detached) pauses immediately, but inactive — a transient dip like a system sheet, pinning prompt, or soft keyboard — only starts a short grace timer that resumed cancels, so a focus flicker can't kill the soundtrack. Like cues, music ignores MediaQuery.disableAnimations — sound is not motion.
  • The engine (lib/data/services/music_player.dart): SoLoudMusicPlayer shares the flutter_soloud singleton with SoLoudSoundPlayer, and both boot it through ensureSoLoudEngine (lib/data/services/soloud_engine.dart) — SoLoud.init on a live engine deinits and reinits it, which kills every voice and unloads every cached source, so the helper checks isInitialized and joins one in-flight boot rather than racing a second init. A refused play (e.g. a stale source after a foreign re-init) is logged once, the source is evicted from the cache so the next request reloads it, and the player keeps going — only an engine-boot failure mutes it. Track changes are crossfades — the outgoing handle starts fading the instant the target changes (before the new source decodes, so a slow MP3 load can't trail into the next screen) and stops a beat later while the new one fades in — and a generation counter keeps a mid-load switch from ever starting the stale track. Decoded sources cache per slug so revisiting a track never re-pays decode. Gain is the settings slider times the duck factor; ducks, volume drags, and the enabled toggle all push onto the live handle's fade. pause() mutes the handle before suspending its voice so buffered audio doesn't leak into the background; resume() un-suspends and fades back to the effective gain. The web client builds WebMusicPlayer instead (lib/web_client/ web_music_player.dart, via the same dart.library.js_interop conditional the sound provider uses): one looping <audio> element per track streaming its URL through the browser's progressive fetch and native decoder. SoLoud-on-web fetched each whole MP3 off the room server and decoded it inside the WASM mixer — a slow lead-in plus a mixer thread a TV stick's CPU can't keep fed, which is what made a Fire TV's lobby music stutter. The semantics mirror the engine player's: request stack, 800 ms element-swapping crossfades on 40 ms volume ramps, the same duck factor, and a generation guard so a mid-play() switch can't start a stale track.
  • Wiring: app.dart installs the raw player plus MusicLifecycle and a permanent MusicRegion for menuSlot around the navigator — home, setup, lobbies, and settings all sit on the menu loop. Inside wrapGameSession each session adds a MusicRegion for its GameMetadata.musicSlug, so the game's theme starts the moment the session subtree mounts and hands the menu back on exit. SessionResultsView wraps itself in MusicDuck for the reveal — the track dips but keeps playing under the sting. speakerProvider wraps the real engine in DuckingSpeaker, which dips the music for every utterance's duration.
  • Scope policy: remote app seats (RemoteGameScreen) and player seats in the web client each install a NoOpMusicPlayer — "phones small" means no music off the host, and the browser bundle still never stages assets/audio/music/ (~14 MB stays out of the staged assets). The lobby counts too: the app shell swaps its root MusicScope to the no-op player while the adopted link is a non-presenting client (deviceOwnsRoomAudio — host or link.presents keeps it), so a joined player's phone doesn't carry the menu loop between calls. A web-client observer — the /d display — flips the policy: it owns the room's soundtrack, so it scopes the real musicPlayerProvider and the unfiltered soundPlayerProvider — the providers' platform pick lands the <audio>-element builds here — and RoomStaticContent falls back to the host's native asset tree for /assets/ paths the staged bundle lacks — the display's assets/assets/audio/music/… requests resolve to the MP3s the app already ships, so nothing gets bundled twice. While any LinkRole.observer peer is attached — whoever's device is actually presenting, host or client — the room's soundtrack belongs to the display: roomDisplayPresentProvider streams the roster check so a display attaching or leaving mid-room (lobby included) flips the app-shell scopes live, deviceOwnsRoomAudio's displayAttached hands the music over, and the host's SoundScope drops to the remote-seat menu — taps, timer ticks, time-ups, and turn pings stay local while stings and reveals play where the room is watching. The presenter needn't be the host at all: a friend's device casts the same /d observer page, and the host's own phone quiets exactly the same way — control stays with the host, sound goes to the display.
  • The bundle: assets/audio/music/<slug>.mp3 — MP3 only (the plugin's source-compiled decoders, no prebuilt codec libraries), loudness-normalized to −18 LUFS by tool/import_audio.sh --music. Slugs match the game ids (acquired_taste, buildboard, desert_island_poetry, dictionairheads, draw_what_now, dreamweavers, infinite_monkeys, island_of_doctor_maroon, off_by_one, sketchy_sources, this_or_that, unfocused_group) plus menu and wrap_up (the wrap-up countdown's wrapUpMusicSlot). assets/audio/ATTRIBUTIONS.md credits every track.
  • Settings: music_enabled / music_volume keys in SettingsRepository (default on, default 0.5, clamped 0–1). The MusicEnabled/MusicVolume notifiers persist, then write through to musicPlayerProvider — a mid-party mute fades the track out and re-enabling resumes whatever the current screen requests. The volume sliders (music and effects alike) snap to 5% detents, show their percentage while dragged, live-preview through previewVolume, and only hit SharedPreferences on onChangeEnd — writing every drag frame was I/O for nothing.

Orientation

LibreBashApp locks handsets to portrait — the pass-and-play hand-off is a one-hand thing — via PhoneOrientationLock (lib/core/widgets/phone_orientation_lock.dart), the app's single SystemChrome.setPreferredOrientations caller. It applies the policy in didChangeDependencies so a metrics change (a foldable opening mid-session) re-evaluates it. The gate is defaultTargetPlatform Android/iOS and MediaQuery.shortestSide < 600: tablets and desktops keep every orientation — a landscape spare tablet is a perfectly good "Present this room" display. Televisions are the deliberate exception: a 1080p Android TV reports a ~960x540 logical surface — under the handset line — so LibreBashApp passes tvModeActiveProvider in as PhoneOrientationLock(television:) and a detected TV is never portrait- locked (a TV can't leave landscape anyway; the lock would be pure foot-gun).

A game subtree can override the handset policy by wrapping itself in OrientationRequest (game_mechanics) — the machinery exists for any subtree that truly wants a rotation, though no game requests one today (Buildboard's easel tried landscape in playtest; the rotated toolbar proved worse than letterboxing the wide canvas). Requests land in the orientationRequestsProvider stack (newest wins, released on dispose); the root lock watches it and falls back to portrait when the stack empties. Games must never call SystemChrome themselves — a second caller races the lock on every rotation-induced metrics change.

Controllers and TV mode

Everything here serves one scenario: a person driving the host device — an Android TV box or a Linux PC on a television — with a controller, TV remote, or keyboard, while players join from phones. The navigation layer is always on (a keyboard works on a phone too); TV mode adds the couch-distance presentation tweaks.

Key bindings (lib/core/controller/controller_intents.dart) extend WidgetsApp.defaultShortcuts/defaultActions on MaterialApp.router — framework defaults (arrows → DirectionalFocusIntent, Enter/Space/ gamepad-A → activate) keep working:

Input Effect
Arrow keys / D-pad Directional focus move (framework default)
Enter / Space / select / gamepad A Activate (framework default)
Escape, gamepad B Dismiss a modal route first, otherwise pop one level via rootNavigatorKey.currentState?.maybePop() — PopScope gates (the session exit gate) still run; Escape additionally refuses while a TextField holds focus
gamepad X, I ShowGameInfoIntent — the focused widget's details action (picker cards open their info dialog); I refuses while a TextField holds focus, the pad binding doesn't
gamepad Start, F1 Open the host toolbox — only with a live host link and no modal route up

LogicalKeyboardKey.goBack is deliberately unbound — Android routes the remote's Back key to the system back dispatch; binding it would pop twice. Both "back" keys ride a PrioritizedIntents(DismissIntent, AppBackIntent) pair so dialogs and sheets keep dismissing exactly as the stock Escape binding did.

Traversal reveal (lib/core/controller/focus_reveal_policy.dart): a FocusTraversalGroup in the MaterialApp builder installs controllerTraversalPolicy — a ReadingOrderTraversalPolicy subclass with a topped-up requestFocusCallback and a layout guard on inDirection. The framework's keep-visible guards only scroll toward their own edge (keepVisibleAtStart refuses to scroll down), so a directional move whose destination sits below the fold — focus starting on a page-level node hops straight there — would focus an invisible control. The callback runs the framework reveal, then a frame later runs a plain Scrollable.ensureVisible if the node is still outside its scrollable's viewport. It changes nothing about where focus lands, only that the destination is on screen. One carve-out: a landing inside FocusCenteringScroll gets requestFocus only — no framework keep-visible and no post-frame fallback. The framework reveal is a zero-duration snap to the fold edge; followed by the center-lock animate it reads as a jump-then-glide bounce on every row-crossing move, so inside the picker the animated reveal owns visibility end to end. Both the directional scan and the reveal reads also tolerate mounted-but-unlaid-out nodes — a card mid-entrance under CascadeIn (or an offstage focusable) throws from FocusNode.rect; inDirection catches it and drops the move rather than crashing the press, and every RenderBox measurement in the callback is wrapped the same way. The throw arrives as two different types — assert(hasSize) fires first where asserts run (debug, tests) and raises AssertionError, while release builds reach the explicit StateError — so every site funnels through isNotLaidOutError (lib/core/controller/not_laid_out_error.dart), which matches both. The policy guard alone isn't the whole seat belt: focusInDirection resolves whichever FocusTraversalGroup owns the focused node, so ControllerInputBridge wraps its intent dispatch and its scope-seeding focusInDirection in the same guard — a press that hits an unlaid-out candidate dissolves no matter which policy answered, and a held direction's 120 ms repeat lands once the tree settles.

Button legend (lib/core/controller/controller_legend.dart): a docked strip under the whole app while TV mode is on — Xbox-style face glyphs (self-drawn colored circles) plus the D-pad and Start marks and the action each performs. Docked rather than overlaid, so it takes real space and never floats over content; dialogs still draw inside the Navigator above it. The legend owns the bottom bezel outright: the app column strips MediaQuery bottom padding off the expanded content in TV mode (MediaQuery.removePadding), so the shelf runs right up to the legend's top edge — when the inset was claimed twice (once by the content SafeArea, once by the legend's), a dead scaffold-colored strip showed between them, about 20px at 1080p — and the legend's own fill paints outside its SafeArea, so the bar reaches the screen's physical bottom edge and only its labels are inset. The strip is contextual, not a fixed promise: D-pad/A/B always show (they always do something), while X "details" only appears when the primary focus's context resolves an enabled ShowGameInfoIntent action — the widget listens to FocusManager and re-checks Actions.maybeFind on every focus change — and Start "host tools" only while OpenHostToolsAction could actually run, i.e. a live host link and zero modal routes (appModalRouteDepthProvider feeds the hostToolsAvailable flag from app.dart).

Focus ring (lib/core/controller/focus_ring_overlay.dart): mounted as the outermost layer of the MaterialApp builder — above the host-toolbox chrome so it draws over dialogs too, inside an IgnorePointer. While FocusManager.instance.highlightMode == FocusHighlightMode.traditional and a real FocusNode (not a bare scope) holds focus, it paints a rounded colorScheme.primary stroke (AppSizes.focusRingWidth, inflated by AppSizes.focusRingGap, radius AppRadius.md) with a thin colorScheme.surface halo outside it for contrast on game-colored backgrounds. The painter reads FocusNode.rect at paint time — the earlier design stored the rect from a persistent frame callback, which runs after paint, so the ring trailed a card sliding under the center-lock scroll by a frame. A persistent frame callback still diffs the rect each frame and bumps a ValueNotifier repaint trigger on change only — no ticker, no animation (snap is also the reduce-motion behavior). The rect read goes through the same isNotLaidOutError guard — a focused node that is not laid out yet (mid-entrance, offstage) simply paints no ring that frame.

VerticalFocusEscape (lib/core/controller/vertical_focus_escape.dart) is a Shortcuts wrapper that re-maps arrow up/down to DirectionalFocusIntent(ignoreTextFields: false). A single-line TextField's own DirectionalFocusAction.forTextField ignores the framework default (which sets ignoreTextFields: true to protect the caret), so without the wrapper a D-pad user is trapped in the field. Wrap every host-path text field a controller user must pass through; never a multiline field where up/down is a caret.

TV mode resolution: SettingsRepository.tvMode persists a TvModePreference (auto/off/on, absent/unknown → auto) under tv_mode; TvModeSetting exposes it and tvModeActiveProvider resolves it: on → true, off → false, auto → isTelevisionDeviceProvider. That provider is overridden once in bootstrapApp with the TvDetectionService result (lib/data/services/tv_detection_service.dart — the dev.johnkneev.librebash/tv_detection method channel answers isTelevision from UiModeManager.currentModeType / FEATURE_LEANBACK on Android; every other platform gets NoopTvDetection, so on Linux "auto" is off and the explicit "On" pick is the way in).

TV mode effects: TvModeScope (lib/core/controller/tv_mode_scope.dart) sits in the MaterialApp builder while active and scales MediaQuery.textScaler by tvTextScale (1.2) — stepping down to 1.0 when the surface is shorter than tvTextScaleMinSurfaceHeight (800 logical px, i.e. 720p-class panels where the bump costs more room than couch distance demands) — adds the 5%-overscan insets (AppSizes.tvOverscanHorizontal/tvOverscanVertical) to both padding and viewPadding — backgrounds stay full-bleed, SafeArea content moves in — and pins FocusManager.instance.highlightStrategy = alwaysTraditional, restoring automatic on deactivate/dispose (a TV has no touch layer to flip the mode back from). PhoneOrientationLock also receives the flag (see Orientation).

Gamepad events (lib/data/services/gamepad_input_service.dart): the gamepads package streams normalized pad events on Linux; a pure mapper (GamepadCommandMapper) turns button edges and stick/D-pad holds into ControllerCommands — moves fire on press and repeat (400 ms, then every 120 ms) until release, with 0.5/0.3 press/release hysteresis on the stick. Before events reach the mapper they pass through GamepadCloneFilter (lib/data/services/gamepad_clone_filter.dart), which removes the virtual duplicate Steam Input (and similar drivers) mirror a physical pad onto — every press would otherwise arrive twice on different gamepad IDs a few milliseconds apart. Detection is at the controller level, not the press level: pads that connect in the same burst (the plugin's initial enumeration, or a ~500 ms hot-plug chain) form a suspect cohort. One catch: the native plugin enumerates and emits connected for already-plugged pads at registration — before main() runs, let alone before the service's broadcast-stream subscription exists — so those events never arrive. The service therefore seeds the filter from Gamepads.list() at construction (same enumeration, same /dev/input/jsN ids), and still listens for live events so hot-plugs keep working. Press edges inside a cohort correlate by time only — any input counts, so a Steam remap that moves buttons can't sneak the mirror past. The first edge in a cohort delivers normally; a second pad pressing inside echoWindow (100 ms) pairs the two and is itself eaten — that gesture never doubles. The pad whose name reads virtual (virtualNameHints: "x-box 360", "steam" — the names Steam's Microsoft X-Box 360 pad clones carry) becomes the canonical one so Steam button mappings keep working; with no name hint the later presser is canonical since mirrors lag. The duplicate is quarantined immediately — every later event dropped — while its edges are still watched: each correlated pair confirms the read, and an unpaired edge from either member inside pairWindow dissolves the pair and releases the quarantined pad, so a real second controller that arrived in the same burst self-heals instead of staying muted. Quarantine also emits synthetic releases for inputs the canonical still holds, so the mapper's held state never sticks. ControllerInputBridge (lib/core/controller/controller_input_bridge.dart) sits in the MaterialApp builder and invokes each command through the same intents the keys use (Actions.maybeInvoke on the primary focus's context — moves pass ignoreTextFields: false since a pad has no caret to protect). Because synthesized intents don't flip the focus-highlight mode, the bridge pins alwaysTraditional on a gamepad command and restores automatic on the next pointer-down — in TV mode it leaves the strategy alone (TvModeScope owns it). Because the plugin reads /dev/input whatever the window state, the service gates raw events on window focus: WindowControlService.windowFocused streams the runner's focusChanged pushes (the GTK side combines is-active for WM deactivation with focus-in-event/focus-out-event for keyboard grabs — an override-redirect overlay like Steam's can steal focus without touching WM activation). While unfocused every event is dropped — before the clone filter too, so an overlay-side press can't pair or unpair a cohort — and the blur itself calls the mapper's releaseAll, cancelling repeat timers and forgetting held inputs so a button let go inside the overlay can't ghost-repeat after focus returns. The bridge keeps its own lifecycle guard as a second layer, ignoring commands while not resumed — and when resumed with nothing focused (FocusManager parks the context-less root scope then) it takes focus-free paths: moves seed the topmost route's scope, back pops the top route, activate is a no-op. Android needs none of this: its gamepads already arrive as Flutter key events (gameButtonA/B/Start, D-pad → arrows), and the plugin's Android side stays inert — the GamepadsCompatibleActivity hook is deliberately not implemented. Every other platform gets NoopGamepadInputService, so the mapper only ever runs on Linux.

Host path in TV mode: the floating toolbox bubble is unreachable by D-pad (it mounts above the Navigator's focus scope), so in TV mode it's hidden and the same sheet is reachable through focusable entries — a "Host tools" button on RoomBanner's host actions, an IconButton (Icons.handyman_outlined) in the session chrome's actions (GameSessionHost), plus the Start/F1 binding. All three call the shared openHostToolbox(link) in host_toolbox.dart, which mounts the bottom sheet through rootNavigatorKey. The host lobby seeds _presenting from tvModeActiveProvider — a TV host opens the room as the presenter by default — and the lobby's start button and the setup screen's begin button carry autofocus, so a controller lands on the action that matters. No host-path text field autofocuses (that would pop a TV IME).

Home picker under a pad: each _GameCard contributes exactly one focusable — its InkWell; the nested info button sits under ExcludeFocus (still tappable) because a second focusable per card breaks directional geometry — "up" would land on the card above's button rather than the card. The card hosts a ShowGameInfoAction so gamepad-X/I opens its info dialog from the pad instead. In TV mode the top chrome row (room/stats/settings/about/exit) is docked above the scroll view instead of floating over it — controls can no longer scroll beneath it — and the shelf itself runs a couch-picker scroll: FocusCenteringScroll (lib/core/controller/focus_centering_scroll.dart) listens for focus landings inside the picker and scrolls by three regimes: a node that fits inside the first screenful (room banner, hero, sort row, top card row) snaps the scroll to the top — the banner and hero stay reachable by steering toward the top row, and centering a top-row card would shove them offscreen with no way back; a node straddling that first fold (top in, bottom out) scrolls just far enough to reveal its whole height — snapping it home would leave the row half-clipped at the fold; anything deeper centers under the selection point — the content moves, the selection point stays put. The scroll view's cache extent is half a viewport in TV mode (ScrollCacheExtent.viewport(0.5)): a directional move can only land on a mounted node, so the row past the fold has to exist before the press or the move dead-ends — the fixed-pixel cache that keeps touch scrolling tight left the second row unmounted at 1080p. The shelf's tail pads by half the viewport minus a row, so the last card row can rise to center and clear the docked legend instead of clipping at the bottom edge. In TV mode RoomBanner is a horizontal strip — room name, code, roster chips, status, and actions on the left, a compact (128dp) invite QR at the end for presenter surfaces — where the stacked card ate most of the 1080p surface and pushed row two past the fold. The invite dialog is a Dialog.fullscreen: a Scaffold with an AppBar back affordance and the QR scaled to ~70% of the shortest screen side (clamped 180–720dp) — it has to read from across the room. The banner's sliver measures itself; once the scroll offset passes its height, CompactRoomBar docks under the chrome row (room name, code, invite/host-tools/end) so room controls never fully leave a pad's reach, and it folds away when the full banner returns. Touch layouts mount neither — a finger drags its own scroll.

Exiting in TV mode (lib/core/widgets/exit_app_dialog.dart): TV mode and fullscreen take the OS's leave affordance out of reach, so quitting lives inside the app behind one shared confirm dialog — showExitAppDialog — which runs SystemNavigator.pop (close-window on desktop, background on Android TV). Three paths land on it: the host toolbox's Quit the app tile, an exit AppIconButton on the home screen's chrome row (both gated on tvModeActiveProvider || isDesktopPlatform), and the couch-idiomatic back on the last route. That last one lives in backOrExhausted (controller_intents.dart), the shared core of AppBackAction and the bridge's no-focus fallback: Navigator.canPop counts routes without consulting popDisposition, so a PopScope-gated top route still counts and the pop reaches its gate — only a genuinely empty back stack fires onBackExhausted. LibreBashApp injects the exit prompt there only while TV mode is on; outside TV mode a root back remains a no-op, and on Android the system back already backgrounds the app natively.

Android TV packaging is in ci-and-packaging.md: the manifest adds LEANBACK_LAUNCHER, a android:banner asset (320x180, generated by tool/generate.dart like the launcher icons), and marks touchscreen / camera / Wi-Fi / location features required="false" so Play doesn't filter the app away from television devices.

Window control (lib/data/services/window_control_service.dart): on Linux, MethodChannelWindowControl calls setFullscreen, setMaximized, getGeometry, and setGeometry on the dev.johnkneev.librebash/window channel into the runner's GTK window (gtk_window_fullscreen/maximize/move/resize in linux/runner/my_application.cc); other platforms get NoopWindowControl. Fullscreen has two routes in: the persisted fullscreen_on_launch pick (the settings tile — Linux-only — pushes the state live too) and the --fullscreen argv flag, which reaches Dart via dart_entrypoint_arguments and forces fullscreen for that launch only.

Launch posture otherwise follows precedence: a Steam/gamescope environment (steamGameModeEnvironment — XDG_CURRENT_DESKTOP contains "gamescope", any GAMESCOPE_*/STEAM_GAMESCOPE_* var, or SteamEnv/SteamGameId) maximizes, since gamescope presents whatever size the window asks for as the whole surface and a default-sized window reads as a small floating box on the TV; a remembered window_geometry (x,y,width,height,maximized,fullscreen) restores the previous session's rectangle; and a first launch with no saved geometry maximizes by default — a couch app shouldn't open as a postage stamp. The runner reports and applies the normal rectangle (setGeometry unmaximizes and unfullscreens first so a stored windowed rect actually lands), and pushes debounced (350 ms) geometryChanged events over the channel; Dart persists them through the same WindowControlService.geometryChanges stream. One timing wrinkle: the runner only shows the window at first_frame_cb, and bootstrap's call lands while it is still unmapped — a pre-map maximize/fullscreen/move is silently dropped by the compositor. Every request is therefore recorded on the app struct (requested_maximized/requested_fullscreen/the geometry fields) and re-applied in first_frame_cb right after gtk_widget_show — geometry first, then fullscreen over maximized. Persistence is deliberately suppressed under the fullscreen override and in game mode — both are temporary postures that shouldn't overwrite the remembered desktop rectangle. Malformed stored geometry parses as absent and falls back to maximizing. bootstrapApp applies all of it through the provider so tests can substitute the service, and takes an injectable environment map for the same reason.

The runner also pushes focusChanged over the same channel — the combined reading of is-active (WM activation) and the window's focus-in/out events (keyboard grabs, which an override-redirect overlay can take without the WM ever marking the window inactive). The push is seeded from the real state once the window maps — a tri-state sentinel in the runner guarantees the first reading always reports — and the service's windowFocused stream is what the Linux gamepad gate listens to (see Gamepad events above).

Linux fractional-scaling quirk — measured on a Steam Deck, KDE Plasma Wayland, three outputs at mixed scales (4096x2160@125%, 2400x1350, 1600x1000): with a monitor at a fractional scale, the embedder's surface-size negotiation breaks. stderr logs Timed out waiting for OpenGL frame of size 6426x3360 (have 2560x1440) — a buffer request the size of the whole scaled virtual desktop — and every GTK redraw then burns the embedder's 100 ms frame timeout (kRenderTimeoutMicroseconds in fl_view_renderer_opengl.cc) before rendering anyway, so the render loop spins at ~100% of a core while a large or fullscreen window is up. It reproduces identically under GDK_BACKEND=x11 and wayland, and identical builds (baseline, controller work, ambient-off, atlas-optimized) all sit in the same ~98% band at 125% — and all idle at ~0% at integer scale. This is an upstream Flutter-on-Linux defect in the fractional-scaling path (the same signature as flutter/flutter#191775 and the resize-path fixes in

187589/#190059), not app code, and there is no app-side mitigation.

When profiling or field-testing Linux TV builds, test at integer scale or in gamescope (which never fractionally scales); point reporters at the upstream tracker. The player-facing version of this lives in docs/USER_GUIDE.md under "Known issues".

Screen pinning

During a session, GameSessionHost calls ScreenPinningService.pin() on init and unpin() on dispose — but only when the user has opted in. On Android this maps to startLockTask()/stopLockTask() on the activity via the dev.johnkneev.librebash/screen_pinning method channel — the "auto app pinning" from the plan, keeping the current player's turn private. It is best-effort (users can still unpin via the system gesture) and a no-op on other platforms; iOS Guided Access cannot be enabled programmatically.

Pinning is opt-in, off by default, and only ever applies to games whose GameMetadata.isPassAndPlay is true — simultaneous-multiplayer games (future LAN play) must not pin. The persisted setting (screen_pinning_enabled, nullable bool in SharedPreferences) backs the Settings toggle; null means unanswered, which is what triggers the one-time offer dialog on the first pass-and-play session start. The service exposes isSupported so the prompt and the Settings tile hide on platforms where pinning can't work.

Play statistics

Every played call leaves a durable record, and the /stats screen rolls the pile into an "Overall" totals card (games played, sessions logged, players seen, playtime, and a most-played per-game list — each counted once per call, not per seat), per-player cards (games, wins, win rate, average and best score, current and best streak, playtime), a per-game split, a head-to-head list, and per-room leaderboards.

The reporting seam is GameSession.onOutcome (CallOutcome in game_core): a game calls it at each terminal standing — the final scoreboard, the last reveal of an unscored run (an empty outcome means "completed, nobody crowned"), or an early "see the standings" jump when a standing already exists. A call abandoned mid-play reports nothing; the recorder marks it abandoned. Replays inside one call legitimately report several outcomes. Controllers take the sink through sessionOutcomeProvider (declared in their dependencies: list), which SessionScope overrides with the session's — clients never report: only the host harvests.

The harvest lives in CallStatsRecorder, armed by GameSessionHost per armed SessionSetup. buildCallRecord snapshots each seat into a CallSeat keyed by a person id — pass-and-play seats keep their player_history id, the local network seat resolves to self (or a name-matched known player's id), and other seats become remote:<lowercased name> — peer ids are per-call credentials and never persist. The recorder parks the record under game_stats_live and heartbeats it every fifteen seconds; app lifecycle transitions suspend and resume the play clock so a backgrounded phone stops accruing. On finalize the record lands in game_stats as completed (it reached a standing) or abandoned, and on a host link it broadcasts to the room as a call_record link message. Clients receive on the link itself — ActiveLinkController for native, WebClientShell for the browser — re-resolve the seats against their own identity with resolveCallRecordForPeer (their seat becomes self, the rest become remote: names), and file it locally.

Crash safety is the live entry's whole job: a killed process leaves a stale heartbeat, and the next bootstrapApp launch reconciles it into an abandoned record whose endedAtMs is start+banked-play, never the sweep time — an abandoned record never claims the hours the phone sat dark. summarizeCallStats in lib/features/stats/stats_model.dart is the pure roll-up the screen reads; only finalized records count, ordered by startedAtMs. The store caps at 500 records, oldest dropped first.

Local multiplayer

Decided — see ADR 0005 for the full decision. In short: the host device runs an embedded WebSocket server and owns the game state (host-authoritative star); clients join over whatever shared IP network exists — an existing LAN, or an Android local-only hotspot the app creates and clients join app-scoped via WifiNetworkSpecifier. Joining is a single in-app QR scan carrying {ssid, psk, ip, port, token}. Session roles are orthogonal: host, network provider, player (private view + input), and observer (public view only — the presenter-on-TV case, including a Linux desktop).

The transport seam exists in game_core: every GameSession carries a SessionLink, defaulting to the LocalSessionLink loopback pass-and-play runs on, and Player.peerId binds a roster seat to a network peer. LinkEnvelope is the versioned wire frame ({v, type, gameId?, payload?}) socket transports serialize; link.* types are transport-reserved, everything else is game-owned.

The socket transport lives in packages/session_link: WsHostSessionLink binds an embedded WebSocket server to one LAN interface — never a wildcard, which the Phase 0 spike showed also answers on cellular — and WsClientSessionLink connects with the token-gated hello/welcome handshake. The package is split so the client side stays web-safe: session_link.dart is the dart:io barrel (host + native client), while session_link_client.dart exports only the io-free surface — the socket itself resolves through link_socket_io vs link_socket_web conditional imports (dart:io WebSocket vs web_socket_channel). The QR carries the ticket as a URL — JoinTicket.joinUri, http://<host>:<port>/?v=1&t=… with ssid/psk/compat/build riding as extra params — so a phone with no app scans it with any camera and lands on the host-served web client, while the app's scanner parses the same URL back into a JoinTicket via JoinTicket.parse (which also accepts the canonical JSON encode() form — the persisted contract). The token is a 4-letter SessionCode the lobby also shows as text so friends can join by typing address:port + the code instead of scanning. The token itself is nullable — a null gate is an open room that welcomes tokenless joins — and mutable on the bound link, so toggling the room's lock slides the gate on or off without disturbing peers already inside.

The single-interface bind has one hazard: a host Wi-Fi flap that drops the interface's address strands the listener — the socket is still open, just bound to an IP that no longer exists, and nothing faults to say so. WsHostSessionLink watches its own bound address on a three-second wall-clock poll (LinkAddresses.local): an empty scan means the radio is mid-flap and waits, one missing scan is treated as a scan racing the flap, and two consecutive misses rebind the server to a replacement address on the same port — the mDNS record advertises only the port, so discovery resolves the new IP at lookup and the beacon never needs re-announcing. The seat credential map, token, roster metadata, and room state all survive the swap; the peer sockets do not — everything dialed to the dead address is gone whether TCP has admitted it yet — so the rebind drops them honestly rather than letting them ghost the roster until silence limits trip, and a survivor reclaims its seat over the new listener. A failed rebind retries on the next poll, and hotspot-hosted rooms never watch: the AP interface's lifecycle is the hotspot's own story. The honest limit: a browser client whose page URL still carries the old IP can only reconnect if the host keeps its address (the common DHCP-lease case) — a genuinely changed IP needs discovery or a fresh scan.

A bound link is a room, not a lobby with a countdown: it persists across games and keeps taking joins the whole time (closeLobby() only locks the door to new arrivals). host.startGame(gameId, seats) broadcasts link.started — repeatable, one per call — and host.returnToRoom() broadcasts link.returned when the call ends. SessionLink.activeGame + gameChanges expose the current call on both ends; the welcome handshake seeds a mid-game joiner's activeGame so a late arrival knows a call is running (and waits unseated for the next one — seats are fixed at call time). The lifecycle events are convergent rather than once-only: ping and peers frames also carry the live call, so a started/returned a client missed (a socket that was mid-death, a reconnect landing between broadcasts) self-corrects within a heartbeat instead of stranding its screen on a stale roster. sameActiveCall (gameId + callId) keeps the repeats from re-emitting — clients only fire gameChanges when the call actually differs, and an absent game key (a host that predates the field) leaves activeGame alone.

Liveness rides the same wire. The host broadcasts link.ping every defaultHeartbeatInterval and clients answer link.pong; each side also tracks inbound silence — any frame counts as life. A live client answers every beat automatically, so inbound silence past defaultPeerSilenceLimit only ever means "can't respond": a frozen tab, a suspended app, a phone whose radio stalled without a close frame (the half-open case TCP never admits).

But silence alone is never a verdict — a peer is only timed out while the game is waiting on its input. The link bridge declares the seats a call is currently blocked on through SessionLink.setObligedPeers, republished with every projection (LinkBridge.obligedPeerIds computes it per state; the default counts the whole roster, and each game narrows it to whoever actually owes input — the same seats its awaiting projection names for the room's "Waiting on …" line: the pen-holder while writing and unfiled ballots while voting in Desert Island Poetry, open definitions/fills/drawings/readings then unfiled ballots in Dictionairheads, Infinite Monkeys, Draw What Now, and Dreamweavers, uncurated slogans/sketches/combines then ballots in Buildboard, each hop's forwarder then the reveal ballot in Sketchy Sources, the deal's unconfirmed seats, the mingle's asker, unfiled accusations, and out seats in Off by One, the seat holding the radio in Doctor Maroon, and the rater/unfiled guessers/huddle in Unfocused Group and Acquired Taste). The host's liveness beat drops a silent peer only if it's obliged — the drop rides the ordinary peerLeaves path so the game's quorum hook (ghostwrite the line, shrink the vote, skip the dead describer) fires exactly when the absence matters. A backgrounded phone idling through everyone else's turns keeps its seat; its silence gets checked when its own input comes due. Between calls the bridge clears the set, so nothing is ever timed out in the room.

There is deliberately no protocol-level pingInterval on the server sockets: dart:io reaps a socket that misses about two protocol pongs, and a suspended client can't pong — the close would preempt the obliged-peer gate entirely (a suspended native app or fallback WebView would die at ~8s while idling). The link.ping broadcast already keeps the wire warm; the app heartbeat is the only arbiter of death. A client whose host goes silent takes the same dropped-connection route home — its own silence watchdog is unrelated to obligations.

Teardown ordering follows from the same principle: link.closed is the signal every recovery path waits on, so a client's close() completes the completer and closes the stream controllers without awaiting the socket's close handshake. A half-dead socket can hold that handshake for the whole TCP timeout — an await would leave the link looking alive (and the rejoin path blocked) until the OS gave up. The handshake still happens, just in the background; the host's _dropSocket has always worked this way.

Writes are just as pessimistic: every socket add rides inside sendRaw/_trySend, so a dead peer's write failure drops only that peer — one bad socket can never abort a room-wide broadcast or a vote publish for the rest. A seated client who leaves the game view keeps the seat — the room banner offers "Rejoin the game" while the call is live, and the fresh channel's resync repaints it. The watchdogs run on the root zone's wall clock, not the caller's zone — a fake-async test clock mustn't get to judge socket liveness (the same reason socket.close() hops zones — dart:io's internal close timer mustn't park on a fake clock either).

Reconnection is the wire's other half — and it's credentialed (ADR 0006). Every welcome deals a resumeKey next to the peerId: a host-minted random, kept in _resumeKeys (which outlives the socket it was dealt on) and never mirrored into the roster — the key is a capability, not a roster fact. A returning client's link.hello re-asserts {peerId, resume} together; an id whose seat was already issued answers only to its own key — a wrong or missing key is refused seat_held rather than reminted, so enumerable peer-N ids alone can't steal a seat (and its private channel). A correct claim on a live socket takes over: the incumbent gets link.evicted {reason: 'seat_taken'} ahead of the close so it can stand its reconnect down instead of fighting the replacement — and the teardown still can't evict the replacement, since _dropSocket removes by socket identity. A claim on a never-issued id lands free; anything outside the peer-* namespace gets a fresh assignment. Since seats key on peer id, a reclaimed seat rejoins a live call — the bridge's peerJoins clears the game controller's disconnectedIds mark and republishes the projection. The client surfaces the takeover as seatTaken — a third terminal state beside hostEnded and a silent drop.

Names are one-per-room across player and display alike — two identical roster labels are indistinguishable to everyone playing. A hello whose name (trimmed, case-insensitive) repeats the host's or a live peer's is refused name_taken before the seat registers; nameless hellos skip the check since unnamed displays can coexist. The gate runs after the seat claim's credentials pass but before its stale socket is evicted — a reconnect re-announcing its own name never collides with its ghost (the claimed seat is excluded from the compare), while a claim renaming itself onto an occupied name refuses without evicting the incumbent's live socket.

The app turns that into "Reconnect": ActiveLinkController.adopt persists the join (multiplayer_last_room_* in SettingsRepository, including the resume key), release(dropped:) keeps it for anything that isn't a deliberate leave or a host goodbye, and HomeScreen shows a ReconnectBanner while a room is remembered — one tap re-dials the ticket (re-joining hotspot Wi-Fi first when the ticket carries one), re-asserts the peer id, and the re-adopted link drops the card back to the room banner.

The card isn't the whole answer, though — a host-side flap drops the socket while the radio is still renegotiating, so the first dial after a drop usually misses. release(dropped: true) on a client link also arms an auto-reconnect loop: one dial every four seconds for ninety seconds, guarded against overlapping attempts, cancelled by a successful adopt, a deliberate leave, or the memory clearing. The card stays up the whole time — silent misses reschedule; an adopted seat or a proven-gone room ends the loop. (The host's own link dying ends the room instead — its server is gone, not unreachable.) The diagnostics ring gets auto-reconnect armed/attempt crumbs so a flap's whole recovery story is readable after the fact.

The dial's outcome decides the memory's fate (ReconnectOutcome, dialed through the injectable clientLinkConnectorProvider): an answer — refused socket, lobby refusal — proves the room gone and clears the memory; silence (timeout, unreachable route, a refused Wi-Fi rejoin) proves only that this phone can't reach it right now, so the card stays for another try. Rejoining isn't the card's monopoly either: any join carrying the remembered ticket and role — QR, manual, or a room-list pick — re-asserts the same peer id, so a scan after a drop lands back in the seat instead of minting a second player. The room QR also rides the in-call app bar so a mid-call join or rejoin can scan the host's screen without anyone leaving the game: GameSession.actions / RemoteSession.actions carry the invite through SessionScaffold — sessionActionsProvider seeds it inside a SessionScope (the host's own screen gets it free), and remote views, which run outside that scope, pass it explicitly.

The browser client (ADR 0006) rides the same server: non-/ws requests on the room port serve a Flutter web build of this very app — tool/build.sh's build_web_client step compiles lib/web_client/main.dart and stages it into assets/web_client/ so the served bundle is always exactly the host's own build. The gate is the room token: ?t=<code> authorizes a request and plants the librebash_room cookie, which carries the bundle's own subrequests (nothing else can set headers on a browser fetch); an open room serves freely. Codes compare case-insensitively — the gate page's uppercase styling is cosmetic and desktop browsers ignore the autocapitalize hint, so kxpm and KXPM both pass the gate, the cookie check, and the websocket hello's token compare. A request that carries ?t= and fails gets the gate page back with a wrong-code line instead of an indistinguishable re-prompt. The gate wears the party wallpaper too — _gateMotifTile freezes the MotifBackdrop field into a tiled SVG data-URI (the page renders before the Flutter bundle can), with a prefers-color-scheme dark variant.

The display path is the same shell through a remote-typable URL: /d for an open room, /d/<code> for a locked one — the code rides the path (JoinTicket.displayUri emits it, and emits it bare — the ?v= marker is how player links announce themselves to JoinTicket.fromUri, and a /d path needs no announcement) so a TV remote never types ?t=. The server resolves either form to the bundle's index and gates on the segment exactly as it gates on t — including the cookie plant the shell's subrequests need — and the locked-room gate's form posts back to /d, since landing on / would silently flip the TV into a player join. WebClientConfig reads the first segment: d implies LinkRole.observer and a second segment normalizes into the token, and WebClientShell auto-joins an observer outright — no name, no tap, the page IS the room's big screen. A reload stays an observer: the reclaim only reuses a remembered seat when that seat was an observer's, so a TV origin can't silently steal someone's player credential. JoinTicket.fromUri accepts the segment as t's peer, so scanning a display QR still resolves to its room. ?role=observer remains the equivalent query form for hand-assembled URLs. WebClientBundle maps paths to bundled assets — traversal segments are refused before resolution, and repeated slashes collapse first because plugin-generated URLs aren't always clean (flutter_soloud's init script requests web//libflutter_soloud_plugin.js; a verbatim map 404s and nosniff then blocks the script outright) — and lib/web_client/ is a self-contained shell: WebClientConfig reads the seat (?s=/?k=), token, role, name, and embedded flag off the page URL, and WebClientShell runs a join → room → remote-view state machine that reuses buildRemoteSession, wrapGameSession, and the remembered-room prefs (a reload reclaims the seat; a seat_held refusal on a stale URL credential retries once as a newcomer). An observer's room view wears the same InviteGraphic the native presenter banner does — the join QR with the address and session code spelled out beneath it, above the roster — so the big screen recruits its own latecomers; a player's view skips it. A BrowserChrome row sits top-right on the join and lobby screens — the controls a remote-typed browser can't reach on its own: a night-mode toggle (writes the shared theme_mode key) and a fullscreen toggle (the Fullscreen API through package:web, persisting through the call view since a TV asks once). Its hello announces render: 'web', which is what makes the roster's browser chip show. Boot also brings up the bundled emoji face before runApp — main() awaits emoji_font_loader.dart's fetch of fonts/TwemojiMozilla.ttf (a web-root file staged flat like the rest of the bundle, so native packages never carry it) and registers the family with a FontLoader; CanvasKit has no OS fonts to fall back to, so without it every emoji renders as tofu unless fonts.gstatic.com is reachable — which a LAN room never guarantees. No router, no platform services — the web build can't touch dart:io, and the build failing is the CI guard that keeps remote-path code web-safe.

Browser identity leans on the host rather than the browser, since localStorage is origin-scoped: a host whose IP flaps serves the next join page from a fresh origin that can't see the old one's prefs. The durable store is the host's own shared player pool — playerHistoryRepository, the same "played before" list pass-and-play setup and the native join/host forms draw from. WsHostSessionLink serves it as GET /players on the room port, gated by the same token/cookie as the bundle, Cache-Control: no-cache, and answering display data only ({name, emoji} — peer ids, resume keys, and the rest of the seat credential never leave the link). The endpoint is history-only: it never lists seated players or the host — that half is GET /roster, the pool's live counterpart: same gate, same {name, emoji} rows and no-cache, but answering the room's current occupants (the host plus every greeted peer; a nameless display earns no row since a blank name collides with nothing). Join forms subtract those names from the pool chips — offering a seated person would only feed the joiner into the name_taken gate anyway. The lobby hands the route playersProvider → history as JSON maps — built from values captured at bind time (the repository and the host's typed name), never from the screen's ref: the link is adopted when the room opens and keeps serving after context.go('/') disposes the lobby, so a closure that touched ref would throw mid-request and wedge the response with no status at all. _servePlayers is defended the same way — a throwing provider degrades to an empty pool, never a hung socket. And ActiveLinkController.adopt feeds it: a host link subscribes to peerJoins (plus a sweep of already-present peers) so every remote identity the room shakes hands with lands in the pool. Remote entries are recorded under a name-derived remote:<name> id — never selfId (the device's own entry would rename out from under it) and never the wire peer-N (recycled every session, so a different person landing the slot would rename the previous one through the id-match).

The browser shell pulls the pool whenever the join form is current — mount, a bounced join, a dismissed end card, the page returning to visible — and keeps polling on a three-second cadence for as long as the form stays up (the chips render during connecting too, so the auto-join's dial doesn't pause the refresh). GET /roster rides the same poll through roomRosterUri — the chip row subtracts whoever is already seated — while a failed roster answer simply leaves the chips unfiltered, exactly what a pre-roster page always did. The fetch goes through player_pool.dart's playerPoolUri/decodePlayerPool seam — the wire shape is unit-tested off-browser — with a five-second bound on each leg and every failure logged to the ?debug=1 overlay, so a dead route or a wedged answer surfaces as a retryable error instead of a promise that pends forever. A single dropped LAN packet used to leave the chip row missing for the page's whole life; now the picker survives the flaky hop the room is actually played on.

The join form mirrors the native one: the name field's prefix avatar opens the same pickPlayerAvatar sheet (the hello then carries emoji/colorIndex), the same helper line advertises the picker, and a player join requires a name — the gate fires before the dial on fresh joins only, since seat claims (?s=/?k= and the remembered-room reclaim) resume under the name they already announced and ?role=observer displays stay name-optional and avatar-free. A joiner with no stored pick still rolls a random emoji and palette slot so nobody enters the room faceless. The fetched pool renders through PlayerChipPicker with a null onForgotten — read-only chips, because the list belongs to the host's device and only that device may forget a person. Prefill runs most-committed answer first: ?p= (a player card: base64url {"n":name,"e":emoji,"c":colorIndex} — encodePlayerCard/decodePlayerCard, garbage decodes to null, never a guess), then ?name=, then this origin's prefs, then a card in window.name — the one property that survives a same-tab trip to a different host's page. Joining writes the identity back to all of them: prefs, window.name as a pp1.<b64> card, and a ?p= stamped onto the page URL, so a reload, copy, or bookmark re-offers the same person. The app's embedded fallback stamps ?p= the same way (fallbackClientUri packs FallbackWebArgs's name/emoji/colorIndex), so the WebView plays under the app's own avatar without the join form asking.

Suspend/resume gets its own handling, since a frozen tab's socket is stale the moment it wakes. WebClientShell listens for visibilitychange: a resumed page whose link is isStale (no host frame within the silence limit) closes it outright — the auto-rejoin in the close path reclaims the seat and the fresh welcome repaints the call at its real phase, rather than replaying a frame backlog that may be whole turns behind. A link the host already dropped while the page was away rejoins on the same event, and a plain drop while the page is visible rejoins without waiting for a tap.

The rejoin is a loop, not a shot: a dropped seat keeps re-dialing every four seconds for thirty attempts (about two minutes), showing a "reconnecting" state on the ended card the whole time — the same loop a manual Rejoin tap starts. A host mid-flap routinely takes several dials, and a stopped loop still leaves the ended card up with the manual button, so the seat is never more than one tap from reclaim. Terminal refusals (seat_taken, a closed lobby, a bad token) end the loop immediately, and dismissal or disposal cancels it. The page only stays on the ended screen for the two outcomes auto-rejoin can't fix — seat_taken and host_ended. For field diagnosis the shell accepts ?debug=1, which turns on a bottom log strip: a capped ring of timestamped breadcrumbs (connect attempts, welcomes, refusals, frame types, watchdog closures, gameChanges, resyncs — the link and channel take the same debugLog sink). A screenshot of the strip reproduces the failure story without DevTools; it's inert and hidden on ordinary URLs.

The app's side of the same trail is linkDiagnosticsProvider — a keepAlive ring (LinkDiagnostics, capped at 400 entries) that the host link (recordHost), client links (recordClient), and every game bridge (recordBridge via GameSession.debugLog → sessionDiagnosticsProvider) write breadcrumbs into: joins, reclaims, refusals, drops, call start/return, oblige sets, intents, publishes, resyncs, publish exceptions. Recording is unconditional — the ring exists so a transient failure leaves evidence before anyone thinks to flip a switch; writes land on a microtask so a bridge publishing inside a widget build can't fault on a mid-build provider mutation, and a watchdog outliving the container is guarded by ref.mounted. What the debug mode setting (SettingsRepository.debugMode, debugModeEnabledProvider, off by default, Developer section in settings) turns on is the visibility: the lobby and room-banner QR URLs gain &debug=1 (with the stamped URL shown as selectable text under the code), the embedded fallback URL carries it too, a live breadcrumb strip mounts at the bottom of the host lobby, and a diagnostics viewer dialog (list + copy-all + clear) is reachable from settings and the strip. JoinTicket.fromUri ignores the flag when the app itself scans the code — it's purely a browser signal.

Two sibling switches join debug mode in the Developer section, both inert unless debug mode is also on so a hidden flag can't stay armed. Drive every seat (debug_seat_driver) is the local-test answer for network-only games: the setup screen skips the "Play Together required" panel and starts the typed roster on the LocalSessionLink loopback, minting each seat a Player.peerId equal to its player id — the same identity a real room assigns — so the controllers' networked paths open with no game-side code. The armed SeatDriver rides SessionSetup into GameSession, and NetworkedSessionView attaches it to the game's LinkBridge: the host channel then gets the driven seat's private view, host-seat intents attribute to it (host commands and resync keep the host's own peer id), and each publish's obligedPeerIds auto-advances the driven seat to whoever still owes input. RemoteSession.playerId answers the driven seat, and HostSeatView remounts the remote view on every seat switch — the local ballot, the filed lock, and the game's open editors live on the view's State, which a same-phase republish never resets, so each seat needs the fresh instance a new phone would bring (per-seat drafts survive under the draft store's seat key). remoteScaffold mounts a SeatDriverBar strip naming the seat plus a pin picker (Auto, or a held seat for replaying one player's turn) — every requiresNetwork game inherits the whole rig through the shared bridge and remote view. Ignore player minimums (debug_ignore_min_players) is the smaller half: setup drops minPlayers validation for any game — one seat is still the floor, and maxPlayers keeps applying since it is a real constraint.

Inside the app's embedded fallback the page also gets a window.LibreBash JavaScript channel — WebHostBridge posts {m:'haptic'|speak|stop|turnPing|getVoices} JSON through it and listens for window.librebashDeliver replies. BridgeSpeaker is a Speaker over that channel: the page's TTS lands on the app's real engine, whose utterance end answers back as an utteranceDone delivery so the page's SpeakerQueue paces identically to native. In a plain browser the channel simply isn't injected — and the shell substitutes by role. A browser player seat speaks through WebSpeechSpeaker, a Speaker over the browser's Web Speech API (speechSynthesis + SpeechSynthesisUtterance, with a watchdog so a TV engine that drops its end event can't wedge the queue), and a vibration-API haptic. A browser observer defaults to RemoteHostSpeaker instead — most TV browsers can't synthesize at all, so the display plays WAV clips the host renders and ships in the speak frame (through a WebClipPlayer, a package:web HTMLAudioElement over a data:audio/wav;base64,… URI — kept in its own file so the pure-Dart speaker stays VM-testable), and its own read-aloud presses round-trip a tts_request/tts_audio through the host the same way (a timeout and a stale-token guard mean a wedged or late host answer can't strand the queue; the fallback Web Speech voice only fills in when the host answers null). A lone observer may opt out on its lobby screen — "use this display's own voice", persisted under display_own_voice — but two or more displays force the host's voice and lock the switch: one room, one voice. Because a peer joining or leaving rebuilds the shell mid-call while RemoteSpeechCue keeps its speaker queue, the shell owns a stable SwappableSpeaker that swaps the inner engine only when the resolved kind changes — embedded bridge, host-rendered clips, or local Web Speech — stopping a live utterance but never re-swapping per rebuild. Either way the speaker is wrapped in DuckingSpeaker against the scoped music player, so a display's read-aloud dips its own soundtrack exactly like the host's.

The embedded fallback is a full-screen WebView route (/multiplayer/web, FallbackWebScreen) that plays the host-served bundle inside the app — the path for a call this build can't render natively. The handoff is a seat claim, not a second client: the page URL carries ?s=<peerId>&k=<resumeKey>, the web client hello asserts the pair, and the host's normal claim path evicts the native socket with seat_taken — the same reclaim a reconnect would make, so the seat, bench flag, and reconnect memory all survive. The screen holds what the dead link held: KeepAwake, the room-presence notification, and — via holdWebFallbackNetwork — the app-scoped Wi-Fi join, since the evicted link's teardown would otherwise cut the network the page is speaking over. Routing lands at three points: the join screen reads ticket.compat and skips a doomed native handshake entirely; the home screen's gameChanges listener pushes the route once per call when canRenderCall fails (the room banner offers the same rejoin while the call runs); and RemoteGameScreen bounces to it for a call that slipped through. The WebView is locked to the room's own origin — the bridge channel exists only on that page — clears its autoplay gate, and on platforms or ROMs without an embeddable WebView the same screen instead shows the URL to open in an external browser (same seat, no bridge).

The same static seam carries app sharing: RoomStaticContent lets AppShareService's routes (/install, /app, /app.json) answer ahead of the bundle. An Android host streams its own installed APK — sourceDir, refused outright when the install is split — plus a {versionName, versionCode, sizeBytes, sha256} manifest, and /install is a dependency-free page (same reasoning as the token gate) listing the offline APK download beside the store links, grouped by OS, under a "you don't need this to play" banner. LinkStaticFile bodies are stream factories so the APK pipes straight to the socket without a heap copy; a fileName turns a route into a Content-Disposition download. On non-Android hosts the store links and {"available": false} manifest still answer.

Remote drafts carry a seat's unfiled input across the same drop-and-rejoin. A remote view's input state lives in widget state — a dead link tears the view down mid-obligation, and the rebuilt view would otherwise greet the rejoined seat with a fresh form. RemoteGameViewState owns the lifecycle: each game reports a draftScope (the current obligation's discriminator — round, line, gallery position — derived from the projection, null once the private view confirms filing and until the phase's input can take a restore — the channel merges public and private halves from separate frames, so a scope built on public data alone can open a beat before the private material the input needs), serializes its in-flight input via draftSnapshot, and applies a saved draft in restoreDraft. Edits debounce into RemoteDraftStore (400 ms), with a flush on app backgrounding and on view dispose — the two moments a drop still leaves the process alive to write. Keys are fully namespaced (remote_draft/<room-hash>/<gameId>/<callId>/<seat>/<scope>, where callId is minted per startGame and rides link.started and the welcome's game payload), so a stale draft can never land in a different room, call, seat, or obligation; a per-call sweep retires leftovers. The host stays authoritative — a draft is only local input handed back to the widgets; it still files through the normal intent path and validation. RemoteDraftStore lives in game_core with a MemoryRemoteDraftStore default; the app overrides remoteDraftStoreProvider with RemoteDraftRepository (SharedPreferences) at bootstrap, and the host seat's HostSeatView persists through the same path as any client. Nothing is promised past process death — the debounced write usually lands, but the kill itself leaves no window.

RemoteGameViewState also guards its own subscriptions: the views/replies listeners bind to session.channel, and didUpdateWidget rebinds when the channel instance changes under a mounted view — sendIntent already reads the channel live per call, so without the rebind a link-swap host (the web shell attaches a fresh channel per link) could leave a view dead-inbound and live-outbound. Native screens sidestep the case entirely (the route dies with the link), but the base class owns the contract.

The remote views share a second tier on top (game_mechanics's remote_view.dart, draw_core's remote_drawing_board.dart): RemoteBallotView renders the vote card — pick, reactions, lock button — and RemoteSessionResults the shared results/actions row; RemoteGameViewState owns the matching helpers (lockBallot emits the canonical ballot/favoriteId intent, wireEntries/ownEntryIds/ votableEntryCount/votableCountOf decode entry lists wherever they nest, awaitingLabel names the seats still out, trackDraftField wires a TextEditingController into the draft lifecycle, and RemoteVoiceChorus rolls the read-aloud voices once per scope). The results callouts are one code path too — voteCallouts in vote_round.dart computes winners + most-reacted from result and totals, so host screen and remote view crown the same champions. For the drawing games, RemoteDrawingBoard is a mixin on the view state that owns the DrawingController lifecycle — palette decode, filed-seat teardown, the 'board' draft arms — with boardFiled and boardSpace as the game's seams.

Version compatibility keeps mixed builds honest without refusing anyone. Two independent numbers ride the wire: linkCompatVersion (game_core) is the shared contract — the frame vocabulary plus the state/private/intent/resync conventions every remote view and LinkBridge relies on — and each MiniGame carries its own remoteCompatVersion for its projection keys, intent kinds, and phase semantics. Both bump only on breaking changes: additive wire fields, new content (word pools' contents, scales, cards), and presentation stay compatible — but adding, renaming, or removing a word pool itself is breaking, because setup options are part of the contract. Every hello announces {compat, build, games} — the build label is diagnostic only, games maps each remote-capable game id to its compat — plus two render fields: render: 'web' marks a peer the host-served bundle is rendering (absent means native), and fallback marks a build that can hand a mismatched call to the embedded web client. The host's welcome plus every roster entry mirror the whole set, so each device can answer "can this peer render that call" exactly: LinkPeer.versionMismatch covers the shared contract, canPlay the per-game check, canPlayViaFallback the hosted-client path, seatableFor the union, and canRenderCall the client's own read against the host's announcement. A peer announcing nothing is a pre-versioning build and counts as mismatched. Joining is never refused — the gate lands on the seat instead: networked setup reads seatableFor per roster row, so an incompatible peer with no fallback has its switch forced off and locked (its sittingOut flag itself is untouched, so a compatible pick restores the host's own choice), while a fallback-capable peer stays seatable and plays through the hosted client. The seat list never carries a phone with no render path — the "seated but absent" stall is impossible by construction. Around that gate the UX warns: the client banner flags a mismatched host, the host banner names mismatched peers (their chips wear the icon), browser seats wear the web marker, the picker badges cards someone can't play, setup names them on the row (or marks "plays via web client"), and a client that lands in a call it can't render is handed to the embedded fallback — its seat stays warm either way. Rows synthesized for the local device — the lobby's host row, the banner's "you" chip, the setup seat row — carry the link's own announcement, so a screen can never flag itself.

A game that cannot run pass-and-play declares GameMetadata.requiresNetwork (with isPassAndPlay: false and supportsRemote: true — Draw What Now? is the first: every artist needs their own canvas at the same moment). The flag gates the setup screen itself: with no hosted room the roster section swaps the typed player list, quick-picks, and teams tile for a "Play Together required" panel whose button routes to /multiplayer, and the start button stays locked (canStart is hard-false, with a matching guard in _start so a released-room race still can't reach the pass-and-play path). Once a room is hosted the same screen renders the normal networked seat list. The picker card marks these games with a "Play Together" badge in the metadata row so the requirement is visible before setup opens.

Discovery rides beside the socket, not inside it. lib/core/lan_discovery.dart wraps bonsoir (Zeroconf — Android NSD, Apple Bonjour, Avahi, Windows — no Play Services): a host's LobbyBeacon advertises its lobby port as a _librebash._tcp service whose instance name is the room name and whose TXT locked flag is all joiners learn up front — the session code never appears in the advertisement. A joiner's LobbyBrowser resolves found services into FoundLobby rows (name, endpoint, lock) — but only after a TCP knock answers. mDNS goodbyes are lossy (a hotspot teardown can swallow them entirely), so a resolved record is a rumor until its port proves it still listens; the bookkeeping lives in LobbyBook, split out so the probe/lost races are testable without the plugin. One address:port is one room — a renamed re-advertisement replaces the stale record at that endpoint instead of stacking a ghost beside it, and a late probe for an evicted record can't resurrect it. Queries are lossy enough that an empty search earns a retry: the none-found state carries a refresh affordance that stops the stale browse and re-issues it through lobbyBrowseStartProvider — the injectable seam tests use to fake both a working browse and a refused one. An outright advertising refusal (no daemon, no bus name, publishing disabled) surfaces in the lobby instead of staying invisible: the "Hide this room" switch locks on with the advertiseBlocked explanation, driven by lobbyAdvertiseProvider — the injectable seam tests use to fake both a working broadcast and a refused one. The security rule lives in roomJoinFor: a typed code goes into a ticket aimed only at the room the joiner picked — never broadcast at every discovered host — so a fake advertiser can phish its own lobby but cannot harvest another room's code.

The app wires this under lib/features/multiplayer/: a chooser, the host lobby (QR + roster via qr_flutter, room name and host-name fields with RoomNames/settings fallbacks, require-code and hide-room toggles, the hide choice persisted in settings), the join screen (room list first, camera scan via QrScannerView — the core/ wrapper over flutter_zxing, mounted full-screen on its own /multiplayer/scan route so the preview is as big as the display allows and the pinch never fights a scroll view for the gesture arena. Pinch-to-zoom is driven through the camera controller the plugin hands out (its zoom bounds aren't known at controller-created time — CameraX's ZoomState posts after the stream starts — so the wrapper resolves them on a retry loop and re-asks on each pinch; a corner chip mirrors the live factor) — plus raw address:port under an Advanced expansion for multicast-filtered networks), and RemoteGameScreen, the route a seated client renders while a call runs. Both ends carry a "Present this room" switch: on the join screen it connects as LinkRole.observer instead of player (no seat, no avatar fields — the display icon stands in — and a name that only labels the display in the roster); in the host lobby it sets the link's presents flag — the host keeps its player role and room authority, but its room card renders the presenter surface and "deal me in" starts off. Both display-name fields are backed by the multiplayerPlayerName setting — joiners and hosts type their name once — but only a player join requires it: an empty field stops the join before any socket and stashes the ticket so submitting the name resumes it (observer displays stay optional — "a display" is already a fine roster label — and the host's blank falls back to "Host"). A tappable avatar beside each field opens the shared showAvatarPicker (the same convention as the pass-and-play roster's change-avatar affordance — palette row included), a helper line under the field says the circle is tappable since the bare tooltip left it undiscovered, and a first-timer's emoji and palette slot pre-roll at random so nobody enters the room faceless; the choices persist in multiplayerPlayerEmoji + multiplayerPlayerColor and ride the hello + roster on LinkPeer's emoji + colorIndex, rendered everywhere by PeerAvatar — a palette-colored CircleAvatar styled like PlayerAvatar (emoji → name initial → role icon), its circle the peer's announced color with a deterministic id fold as the unset fallback so every device agrees — and in-game by seat Players carrying both fields, which also reach remote results screens through the game's public players projection.

The two modes share one player pool: player_history (the pass-and-play roster history, most recently used first) is also where room play records the local identity — on a successful join, on a reconnect, and when the host opens a room. PlayerHistoryRepository owns that: recordKnownPlayer upserts the room identity under the reserved self id — or adopts an existing entry's id when the name matches, so "Maya" passing the phone and "Maya" in a room stay one entry — while recordSession keeps upserting whole pass-and-play rosters. Both room name fields offer the same PlayerChipPicker row the setup screen draws from knownPlayersProvider: a tap fills name + avatar (color included), and the forget X drops the person everywhere. The join screen's row additionally subtracts the picked room's occupants — selection starts a GET /roster lookup (room_occupants.dart's injectable fetchRoomOccupants) that re-polls on the same three-second cadence and, on a locked room, retries per code keystroke since the first answer is a 401 until the code lands; a failed fetch leaves the chips unfiltered rather than wedging the form, since the hello's name_taken check stays authoritative (and covers the QR/manual paths a picker filter can't reach). When a stored room name isn't in history yet, poolFor surfaces it as a synthesized self chip — the current identity is always pickable, never invisible.

The room lives on HomeScreen for EVERY attached device — the same card and picker, minus call authority for anyone who isn't the host. "Open the room" hands the link and beacon to app-level providers and lands home; a successful join lands home the same way, presenter or player. RoomBanner renders the room name, join code, live roster, an invite dialog (QR + manual address — a client re-shares the JoinTicket it joined with; the SessionLink.joinTicket extension in session_link answers that ticket off either end of a WebSocket link so the UI never type-checks transports), and the role's exit action: "End the room" for the host, "Leave the room" for a client. The dialog is two-faced: a Players/TV display segment swaps the join QR for the display path (the /d URL as typeable text plus a scan-me QR for a spare phone — the host toolbox's "Put it on a TV" row opens straight to it), and, on Android, a wireless-display switch. That switch drives WirelessDisplayService — a dev.johnkneev.librebash/wireless_display method channel (show(url)/hide/openDisplaySettings) plus a paired event channel streaming {supported, attached, shown} — into MainActivity's DisplayManager watch: while a Dart subscriber exists it reports DISPLAY_CATEGORY_PRESENTATION displays (a Miracast/Wi-Fi Display sink the user paired in system settings, or a wired secondary), and show opens a RoomDisplayPresentation — an Android Presentation holding a plain WebView pointed at the room's own /d URL with ?projected=1 stamped on it. The projected surface is the host-served web client in its observer role, so the TV side needs no second Flutter engine and no duplicated rendering; sink pairing stays with the OS. hide is a full teardown, not just a dismiss: when our presentation was actually projecting it drops the OS-level Miracast/Wi-Fi Display session too — DisplayManager's hidden disconnectWifiDisplay() (a reflection call the AIDL marks permission-free, unlike connect/scan) with WifiP2pManager.removeGroup as the fallback where the hidden call can't run. Both are best-effort; a session we never projected onto stays the user's own. The switch's off, the display's removal, the activity's death, and every room teardown path in ActiveLinkController.release all land on it, so a casual user never has to hunt a cast down in system settings. The projected=1 flag marks the page as a view-only mirror: the web client hides its own read-aloud affordances (RemoteHostSpeaker answers isAvailable false — nothing can tap a cast surface anyway, and its audio is already the host's channel), and on the host side GameSession.selfProjecting (fed from wirelessDisplayState.shown) lets a remote view dedupe — with exactly one observer attached, "read on the display" hides because the local "say it" already speaks through the same speakers; a second browser observer makes the displays distinct again and the button returns. Every snapshot also reports supported — the device's own Miracast capability, read reflectively from DisplayManager.getWifiDisplayStatus() (it's @hide): a successful call is authoritative — FEATURE_STATE_ON/OFF means the platform can source WFD, UNSET/UNSUPPORTED means it can't (a Pixel has no WFD adapter; Samsung's Smart View reports through the same one), and config_enableWifiDisplay is only the fallback when the reflection can't run — Samsung leaves that flag off despite shipping WFD. A Miracast-less build (every Pixel — stock Android dropped WFD, its cast page is Google Cast only) keeps the row but greys it with the reason, and hides the pairing doorway entirely: openDisplaySettings tries WIRELESS_DISPLAY_SETTINGS before CAST_SETTINGS because where it resolves it's the real pairing surface (Samsung lands on Smart View), while a Miracast-less phone's settings can only offer Cast targets — its fallback is the /d browser path (or a wired display, which still arrives as a presentation display and keeps the switch live), and the greyed subtitle says so — plus the third way in: a friend's device can do the casting, since the client app's own wireless-display tile projects the host's /d URL exactly like the host's would. The presenter needs no authority — the casted page joins as an ordinary observer, so the room and its controls stay with the host while a Miracast-capable phone anywhere in the room can put it on the TV. And because a cast surface takes no input and a TV browser's chrome wants a remote and a steady hand, the host toolbox carries the display's own controls while observer peers exist: {kind: 'display', action} frames addressed per observer land on the web shell's link.messages — night flips the page's persisted theme pref (the same key BrowserChrome's toggle writes), fullscreen asks the document for fullscreen — best-effort, since browsers gesture-gate the API and a refusal just stays windowed (the Miracast WebView is already fullscreen). Off-Android the service no-ops and the switch hides. A presenter's banner — link.presents, true for an observer client and for a host that opened the room presenting — goes further: the invite graphic (InviteGraphic, shared with the browser display's lobby — see "The web client") stays on-screen since the shared display IS how latecomers join, and its status line tells the room where picking happens instead of offering the picker. The picker below filters to supportsRemote games while a link is adopted, and the multiplayer-chooser button hides entirely — both lobby and join routes bounce home rather than bind or orphan a second link. The room name travels with the link, not just mDNS: WsHostSessionLink binds with roomName (mutable — renames rebroadcast), sends it in the welcome and every link.peers frame, and the client's roomName getter feeds its own banner title. The lobby field's reroll button deals a fresh generated suggestion, and a hand-typed name persists as multiplayerRoomName — room stats group on it, so a named room keeps its leaderboard across sessions. HomeScreen owns the room listeners: a call pushes /multiplayer/game for seated clients AND observers — the display follows every call it can render, mid-game join included — and a link the wire closed explains itself (ended vs dropped) then releases — a close the controller ordered (a deliberate leave, or an adopt replacing the link) has already dropped the adopted-link state by the time closed completes, so it stays silent.

Picking a game on the host runs the ordinary GameSetupScreen, which swaps its pass-and-play roster for the room's live phones — one deal-in switch per seat: the host's own row (hostPlays), every player peer (sittingOut), and the game's bot row. A peer whose announced versions can't render the game carries the warning on its own row. Bench flags are room-scoped on the link — they persist across calls until flipped back, ride the roster broadcast as out (the host's own entry carries !hostPlays), and the benched client's banner says why a call skipped it. Setup gates Start on playerCountIsValid and on no call already running, then fires link.startGame and launches /play. The link crosses routes in ActiveLinkController — the same keepAlive handoff pattern as SessionSetupController — and the app shell owns closing it; adoption also holds the wakelock and the room presence for the link's lifetime so no single screen has to.

Room presence (lib/data/services/room_presence_service.dart, dev.johnkneev.librebash/room_presence) is what keeps a room alive when the app leaves the screen. The wakelock only stops auto-sleep while foregrounded; a backgrounded app's sockets are the OS's to suspend. While a link is adopted the service holds the process at foreground priority — Android's dataSync foreground service (RoomPresenceService.kt, ongoing notification: host/guest title, live player count off peerChanges, tap resumes the task — the jump-back-in affordance), iOS's beginBackgroundTask (~30 s grace — a quick app-switch survives, real backgrounding still ends the room and the lobby says so on iOS). A recents swipe is a deliberate kill: stopWithTask buries the service with the Dart isolate rather than leave a zombie notification over a dead room. SessionLink.closed completes on every link type — host close, dead server socket, dead client socket — and presence (like the wakelock) drops with it; permission denial degrades to keep-alive-without-the-visible-toast. The mDNS advertisement outlives the lobby screen in RoomBeaconController (RoomPresence pairs the beacon with the room name so even a hidden room still labels itself on the banner), and a coarse peerChanges stream — joins, leaves, in-place renames, emoji edits, room renames, and pick changes — drives every roster repaint while peerLeaves stays attach/detach-pure for quorum logic.

Between calls the room doubles as a suggestion box: setGamePick sends link.pick (client → host) or applies directly (host), the pick lands on the peer's roster entry, and the home cards mark the picked game with the picker's avatar — on host and client alike, one pick per peer so tapping another card swaps. Tapping the marked game again withdraws the pick; startGame clears them all since a pick is a vote for this call, not a standing order.

Host tools (lib/features/multiplayer/host_toolbox.dart) hang the room's controls off a chrome that outlives every route: a draggable, edge-snapping bubble mounts in MaterialApp.router's builder while the adopted link isHost, so it floats above lobby, setup, in-call, and home alike. Because that overlay lives above the Navigator, two plumbing pieces make it behave: rootNavigatorKey (in core/routing/root_navigator.dart, the router's own key) gives the bubble a context it can push the bottom sheet from, and appModalRouteDepthProvider — a NavigatorObserver counting popup and sheet routes — folds the bubble and pause card away while a dialog is up so neither sits on a scrim. A second observer on the same navigator, KeyboardDismissObserver, releases the primary focus and hides the platform IME when the route that owns it leaves, so a session closed mid-typing can't strand the keyboard inset on the page underneath. The sheet itself reads the link directly: a pause switch, the wrap-it-up countdown row (_WrapUpTile opens _WrapUpDialog's preset chips + custom-minutes CountStepper, and shows the armed remainder with a one-tap stop), a _PeerRow per roster entry (PeerAvatar, the render hello field drawn as a native/web glyph, and a health dot off peerLastHeard — live within two heartbeat intervals, quiet past that, stale beyond the silence limit, gone when the link no longer answers), bench and kick per row, the invite dialog's QR, a share row through TextShareService (share_plus with a clipboard fallback — the service seam exists because features never import plugins), the known-players editor (PlayerChipPicker with its delete affordance wired to removeKnownPlayer — host-only, mirroring the browser picker's read-only stance), and the diagnostics dialog.

Pause is a room state the wire carries: link.paused is a dedicated broadcast frame rather than a roster field — the whole point is freezing the table now — and the welcome payload echoes it so a join mid-pause lands held. Two freeze seams make it honest without teaching every game about links: SessionPausedScope (game_mechanics/room_pause.dart) wraps the app's remote/host views from the overlay, and TurnTimer consults it when its own paused argument isn't set — every countdown ring and every client-side auto-submit on expiry stops with zero per-game work; while PausableDeadline (same file, package:clock-driven for fakeAsync tests) is the controller-side counterpart: a wall-clock deadline that banks its remaining time on pause and re-arms it on resume. The phase-cutoff games — Buildboard, Draw What Now, Dreamweavers, and Sketchy Sources — arm it through PhaseCutoff (same file) via SessionScope's sessionLinkProvider; any future game that grows a cutoff uses the same seam. The release heals the wire side too: each client's LinkGameChannel resyncs on the pausedChanges release, and the bridge's PauseLedger prices the held span out of its turnRemaining answer, so every ring resumes from the same remainder. Both ends paint the shared PausedOverlay (features/multiplayer/paused_overlay.dart) while held — the native chrome mounts it in HostToolbox, the browser shell in its own stack; the host's copy carries a Resume button, the client's explains the host will resume, and the card's barrier eats taps underneath — a player could still drive their screen only by going around the overlay, not through it.

Wrap it up is pause's sibling: another room state the wire carries, this one a host-armed countdown rather than a hold. SessionLink grows wrapUpEndsAt/wrapUpDuration/wrapUpChanges/wrapUpPhase/ setWrapUp, all backed by a shared WrapUpClock (game_core/wrap_up_clock.dart) that owns the deadline, the armed length, pause banking, and the expiry sweep — identical bookkeeping on the host link, the client link, LocalSessionLink, and FakeSessionLink. The wire frame link.wrap_up ships {ms, total} — a relative remainder plus the armed length, never a deadline, same clock-skew argument as turnRemainingMs — and the welcome payload echoes the armed state so a mid-countdown join lands counting. It's additive on purpose: older peers ignore the frame and just never see the nudge. Pausing the room banks the countdown's remainder through the same setPaused seam, and resume re-broadcasts the fresh deadline.

Its takeover of timer-aware phases is the deeper trick. On the wire side, LinkBridge.turnRemaining answers the wrap-up's remainder while one is armed, and turnTimerFields reports the countdown's armed length as turnTimer — so the countdown simply is the phase's projected clock: a longer pick extends a timed turn, a shorter one hastens it, and an untimed phase gains a ring mid-phase. The bridge's PauseLedger is fed paused || wrapArmed, so the wrap-up's span counts as held time for the phase's own clock — cancelling hands back the banked remainder verbatim instead of burning the game's budget. On the host side PhaseCutoff swaps the armed PausableDeadline the same way: the phase's deadline banks on takeover, the wrap-up's deadline plus phaseCutoffGrace arms in its place, an early cancel reverts to the banked clock, and an expired countdown lets the armed deadline finish its grace tail — onFire resolves the phase exactly as its own timer would (the clock keeps endsAt armed for an expiryTail past zero so projections don't flash the phase's own clock back during that grace beat).

The countdown is phase-bound, not room-scoped — a "wrap it up" is a "wrap up this phase" nudge, aimed at whatever phase type was live when the host armed it. SessionLink.wrapUpPhase carries the binding: the live phase's PhaseCutoff stamps its own phase key on adoption, and each new cutoff compares the tag to its own key at construction — a match carries the countdown into the next window of the same kind (the next piece's guessing, the next story's reveal), a mismatch clears it, and an unclaimed countdown — armed in the lobby, between phases, or in a moment with no cutoff — dies at the next phase that opens rather than taking over a clock it was never aimed at. Cancelling a cutoff deliberately leaves the tag alone: the next phase decides the countdown's fate, not the dead window. The tag is host-side bookkeeping only — it never rides the wire, and client links report null.

Phases with no clock of their own arm dormant cutoffs so the nudge still bites: Draw What Now's and Dreamweavers' ballots resolve at expiry, their per-piece reveals advance the gallery the way the host's "next" button does, Sketchy Sources' reveal scores an open ballot then moves the walk (re-arming after a resolve so the scored display stays wrappable), and Buildboard's reveal advances to the next client's ballot. The standings arm a claim-only cutoff — onFire null — so a countdown can't outlive the game that aimed it, and controller teardown clears whatever is still armed. Phases and games with no cutoff at all are untouched: there the countdown is only the floating WrapUpChip (features/multiplayer/wrap_up_chip.dart), the non-blocking pill mounted beside PausedOverlay in the app chrome and the web shell; it turns loud inside the last ten seconds and flashes "Time's up!" for a beat when the countdown runs out. While armed, the chrome also pins a MusicRegion on wrapUpMusicSlot — the bundled "play them off" loop — so every native device's soundtrack swaps underneath the countdown and hands back on release, all locally, no wire traffic.

Bench rides the existing sittingOut flag — promoted onto the SessionLink contract as setPeerSittingOut — so a benched peer stays connected, keeps its warm seat and roster row, and is simply skipped when calls seat players; the flag survives a seat reclaim the same way it always has. Kick is the harder eject: kickPeer sends a new EvictReason.kicked (deliberately distinct from seatTaken so the client's auto-reconnect stands down the same way, and its remembered-room credential is cleared rather than replayed), drops the socket, clears resumeKeys, and retires the peer id for the room's lifetime — necessary because the claim handshake lets any hello ask for a free peer-N, and reusing the kicked id would hand the rejoiner seat-keyed game state mid-call (Draw What Now's drawings are peer-id-keyed). The honest client forgets its credential on the evict frame and mints a fresh seat on manual rejoin; anything quoting the retired id is refused seat_held. The host's own row and observers carry no kick or bench — you can't eject yourself, and a display has no seat to warm.

A networked game plugs in through MiniGame.supportsRemote + buildRemoteSession. The host runs the ordinary session — GameSessionHost builds it with the room's link in GameSession.link, and SessionScope seeds sessionLinkProvider — while every seated phone renders a remote view fed by a RemoteGameChannel (game_core): LinkGameChannel on clients merges broadcast {kind:'state'} public projections with {kind:'private'} per-seat ones and sends {kind:'intent', body} envelopes back to link.hostPeerId. The channel is born a beat after link.started routes the client in — the route pushes, then the view builds it — so the host's first publish can fall in the gap; on creation it sends a {kind:'resync'} intent (retrying on a slow beat while the public half stays empty), and the bridge answers peer-scoped with that seat's state + private. The retry cap only applies while nobody is watching — a mounted views listener over a still-empty public projection re-arms the resync and keeps asking, so a view that opened after the burst ran out isn't left blank until the next publish. The channel's views stream is broadcast, so a remount or a later subscriber replays cleanly — and each client shell keeps the channel married to the call: RemoteGameScreen builds one per mounted remote view, while the web shell disposes and rebuilds its LinkGameChannel whenever ActiveGame.callId changes (a channel reused across calls replays the previous game's last public view into the new call's first frame — which is exactly the stuck-second-game bug this guards). The channel also re-asks on its own whenever link.pausedChanges releases, and both shells resync on wake — RemoteGameScreen on AppLifecycleState.resumed, the browser shell on visibilitychange. The host side is a per-game link bridge (AcquiredTasteLinkBridge, BuildboardLinkBridge, DictionairheadsLinkBridge, DilemmasLinkBridge, DrawWhatNowLinkBridge, DreamweaversLinkBridge, MaroonLinkBridge, MonkeysLinkBridge, OffByOneLinkBridge, PoetryLinkBridge, SketchySourcesLinkBridge, and UnfocusedLinkBridge): it owns the wire schema, projects public + private views on every state change, and validates each intent against the live controller state — phase, seat, and structure — before invoking it. All twelve extend LinkBridge<S> (game_core), the shared skeleton that owns the wire lifecycle: the messages/peerLeaves/peerJoins subscriptions, the host seat's in-process channel, publish/resync addressing, the intent-envelope decode, and the peer→seat resolution every intent gate shares (playerForPeer/playerIndexForPeer — wire id with the player id as pass-and-play fallback). Two tiers sit between the base and the games, also in game_core/game_mechanics: NetworkedLinkBridge adds the peer-tracking layer every networked game shares — the players roster projection, seat drop/rejoin routing to the controller's NetworkedHooks, and host intent dispatch (start, end, playAgain); BallotLinkBridge adds the vote game's ballot/react intent routing and pendingBallots projection — so a game's own code is only the projections (publicView/privateView), the game-specific intent validator (handleGameSpecificIntent), the disconnect/reconnect hooks, and an optional onPublish hook for phase-scoped bookkeeping like dealt hints. Ballot math itself lives in game_mechanics's ballot_tracking.dart (fileBallot/fileVotePick/ recordStandaloneReaction/awaitingPicks/talliedBallots), with a BallotEligibility seam for games whose entries have more than one author. The roster/team/reaction wire shapes are likewise one copy — playerToWire/teamToWire/wireStringMap in game_core's wire_codec.dart, mirrored by the remote view's decoders. The bridge itself is the host seat's RemoteGameChannel, so the host's phone plays through the same remote view and the same validation path; authorship and other secrets stay on the host until the projection would reveal them anyway. The in-process half of that channel is HostSeatChannel (game_core) — it holds the latest merged view, replays it to the late-mounting remote view, and routes host-seat intents back through the bridge's validator.

Timers follow the same host-authoritative shape: a countdown is projected as a relative remainder computed on the host's clock at publish time (turnRemainingMs next to the configured turnTimer in the dictionairheads writing view), never as a wall-clock deadline — peers' clocks can't be trusted to match. LinkBridge owns that computation: turnTimerFields emits the pair together (wrap-aware on both halves — a live wrap-up reports its armed length as turnTimer and its remainder as turnRemainingMs, which is how the host's nudge replaces a phase's clock without a single remote view noticing), while turnRemaining prices room-pause and wrap-up time out of the phase's own clock through a PauseLedger (game_core/pause_ledger.dart) that books link.paused || wrapArmed intervals — a resync answered after a hold reads the same frozen remainder every seat's ring already shows. The remote TurnTimer seeds its countdown from that remainder (initialRemaining) and re-seeds when a changed host value drifts past resyncTolerance (a second), so ordinary publish latency never rewinds a running ring but a pause or a slept tab lands the host's number. The countdown itself runs on clock.now() rather than counted ticks, so a browser that throttles timers in a locked tab only delays the repaint, never the count.

Every publish feeds that local view whether the host holds a seat or is presenting — an unseated host gets the public projection plus an empty private half (the spectator rendering, with the phase controls _isHost adds). The bridge also republishes on peerJoins — an observer (or a player between calls) attaching mid-game gets the current projection immediately instead of waiting for the next state change — and publishes once on construction so the host seat's view never waits for the first controller change.

Publishing is fault-contained. publish runs through _tryPublish, which catches anything the projection/broadcast chain throws — a publish wired to ref.listen fires inside the controller's notifyListeners, so an exception used to propagate back through the intent handler with the state already committed: zero bytes on the wire, healthy sockets, every seat frozen on the pre-change view, and every later intent rejected by a validator checking the advanced state. A thrown publish now records a publish threw: breadcrumb and retries on a bounded delayed budget (_republishStep × _maxRepublishes), re-reading state at each attempt so the retry projects the committed state rather than a stale argument; a fresh successful publish or a new controller change resets the budget, and dispose cancels the pending retry. The wire evidence of the original failure is its signature: heartbeats flowing, no relay — the publish simply never emitted.

A remote view decides what it renders by role, not by which device it runs on: RemoteSession.playerId == null means the device holds no seat — observer or unseated watcher — and Dictionairheads answers with spectator variants (the word card + who's still writing; the full entry list + who's still voting) where a player would get input fields. Reveal and results need no variant — they were always read-only. On the wire side the checks stack the same way: an observer's link.pick is dropped at the host (no seat, no say), game intents from unseated ids no-op in the controller, and private projections are only ever addressed to seated peers.

The presenter is also the room's voice. Spectator entry cards carry a per-entry read-aloud on the device's own speaker (the display reads the room's submissions while phones vote), and the host gets a second cue — "read on the display" — whenever an observer peer is attached. That button sends a {kind: 'speak', utterances: [...]} relay frame straight to each observer; LinkGameChannel routes unknown kinds into replies, where the observer's remote view picks it up and drives its local Speaker — no bridge involvement, since a read-aloud cue is room-level presentation, not game state. Each utterance carries a voice group (round for the headword pair, an entry's def-N id for each definition) rather than a real voice — TtsVoice maps are engine-specific, so a host's piper speaker key would mean nothing on an Android seat — and, when the host's engine can render one, a base64 WAV audio clip of the line already spoken in the host's own configured or rolled voice. Speaker.synthesize is the optional capability that produces the clip: FlutterTtsSpeaker renders through synthesizeToFile, ProcessSpeaker renders piper via --output_file and the flag-table engines via their -w-style wavFlag (spd-say can't render off-speaker and answers null). One clip per utterance per voice group, synthesized once and fanned out to every observer — a roomful of displays hears one voice, the host's. A speaker that acceptsClips (the web display's RemoteHostSpeaker) queues the clip verbatim under the clip:wav; marker prefix; any other speaker ignores audio and maps the groups onto voices it rolls from its own engine, honoring its own configured voice/rate when set — the remote view keeps the same announcer-plus-chorus cadence as the pass-and-play controller, just rolled per device.

A read-aloud pressed on the display itself works the same way in reverse: the display's RemoteHostSpeaker posts a {kind: 'tts_request', text, req} frame to the host, the host renders it under the same voice/rate policy, and answers that one peer {kind: 'tts_audio', req, audio} — a null audio still answers, so the display knows to fall back to its own browser voice instead of waiting. Non-host peers ignore the request; a host that can't synthesize still has to reply.

Both directions of that cue are RemoteSpeechCue (game_core): speakOnDisplays renders and ships utterance by utterance — each line leaves as its own speak frame the moment its clip lands, rather than waiting out the whole batch's serial renders — while handleReply consumes speak/tts_request/tts_audio into a SpeakerQueue (the same sequential utterance walk SpeakQueueButton delegates to) through a serialized enqueue tail, so a frame parked on its voice fetch can't be jumped by a later clip-bearing one. VoiceRoller turns each group into a pick from the local engine, lazily: an all-clip batch never loads the voice list at all. Groups keep their rolled voice for the cue's life — the headword's round voice survives the per-utterance frames a reveal now arrives as. stop bumps a generation counter that retires in-flight renders, parked enqueues, and pending tts_request answers alike, so a cancelled read can't resurrect behind the speak_stop. The display-side scan for observer peers (displayPeerIds) rides along too, so a remote view keeps only the UI decisions: which utterances, and when.

Host renders are bounded end to end: FlutterTtsSpeaker.synthesize times a stalled synthesizeToFile out at twenty seconds and ships the line as text instead, a throwing speaker finishes its SpeakerQueue rather than latching it mid-flight, and RemoteHostSpeaker treats a clip player that throws on play the same as one that reports an error — every failure path lands on "complete the utterance" so no single dead engine can silence a room's queue.

Ambient speech gets the same display-is-the-speaker treatment through DisplayRelaySpeaker (game_core) — the Speaker GameSessionHost hands GameSession when a networked call starts. While observer peers are attached, its speak renders the line through the wrapped engine once and ships each display the same {kind: 'speak'} frame the push button sends — reveals, callouts, and say-it presses all voice from the TV rather than the host's pocket, still in the host's voice since the clip ships rendered. A speaker that can't synthesize ships text alone and the display rolls locally, same as the push path. The wrapped engine only answers when no display listens, or when the lone display is this phone's own ?projected=1 mirror — a projection shares the host's audio channel, so a shipped clip and a local speak reach the same speakers and the local one wins on latency (and on engines that can speak but not render). A second observer ends the mirror and shipping resumes, the projection playing its frame like every other display. stop silences the engine and ships {kind: 'speak_stop'} so a cancelled read-aloud doesn't keep talking on the far side — the cue's handleReply drains its queue and abandons a batch still awaiting voices, matching a local dispose, and a render in flight when the stop lands drops rather than shipping behind it.

Because the relay already voices every host speak through the displays, a say-it button sitting beside a "read on the display" twin produces identical output — remote views collapse to the display button alone while a real display is attached (RemoteGameViewState. sayItIsDisplaySpeech), the same lone-mirror exception the push button carries. Standalone read-alouds with no display twin (the read-all on a reveal, a menagerie consult) stay: they're still the only way to voice that batch, and the relay lands them on the TV regardless.

The host fills the presenter role itself when the lobby's "Present this room" switch is on — WsHostSessionLink.presents keeps the room banner's invite on-screen and starts hostPlays off, so calls begin unseated. "Deal me in" is then the per-call seat choice: it lives on the link, so a host who deals in stays dealt in for the room's whole life — off again only by the toggle or by the room ending — and a new room opened presenting starts unseated again. An unseated host renders the spectator variants plus the phase-advance controls, and its read buttons speak locally when no separate display is attached.

Turn pings are how a pocketed phone learns its turn opened. A remote view derives an actionable flag from the projection — seated, and the phase owes it something (an unfiled definition, an uncast ballot) — and feeds it to TurnAlert (game_core), a rising-edge detector: one ping per obligation opening, silent on rebuilds and resyncs, re-armed once the seat acts. The ping itself is RemoteSession.turnPing, seeded from GameSession.turnPing for the host seat through sessionTurnPingProvider — same callback on both ends of the wire. The app-side turnPingProvider owns the policy: the buzz always fires (it IS the in-game nudge), while the OS notification only fires when the app isn't resumed — nobody needs a heads-up for a screen they're watching. Both halves ride the room_presence channel. buzz is the in-app half: Android plays a rising buzz-buzz-BUZZ through VibrationEffect.createWaveform — the real vibrator, not View.performHapticFeedback, so the system's touch-feedback toggle can't swallow it (needs VIBRATE, granted at install) — and iOS plays a light→medium→heavy UIImpactFeedbackGenerator sequence. ping is the background half: Android posts a transient heads-up on its own high-importance room_turns channel (the room notification's channel stays deliberately silent), iOS posts a local notification inside the grace window with a lazy permission ask; both tap back into the running task. Spectators never ping — no seat, no obligation.

When no shared LAN exists — or the venue's AP isolates clients — the host becomes the network provider itself. The platform seam is a pair of services in lib/data/services/, both shaped like ScreenPinningService (interface + method channel + no-op):

  • HotspotService drives Android's WifiManager.startLocalOnlyHotspot through the dev.johnkneev.librebash/hotspot channel on MainActivity.kt. Each start mints a fresh random SSID/passphrase — which is why QR join carries credentials — and the channel answers with the AP interface's actual IPv4, since the subnet is not fixed (the spike's AP landed on 10.204.x.x; the Kotlin side polls the interface list until a new private IPv4 appears). The lobby then rebinds the server to that address — never a wildcard — and the JoinTicket gains ssid/psk so one scan carries network + session. Every start also requests friendly credentials — mintHotspotCredentials (lib/features/multiplayer/ hotspot_credentials.dart) mints a LibreBash-XXXX SSID and a pronounceable nonsense-CODE passphrase — which Android 16.1+ applies via startLocalOnlyHotspotWithConfiguration (the credential-setting builder methods only exist there; MainActivity probes them reflectively and falls back to a random AP); older releases ignore the request, and the reservation's softApConfiguration stays the source of truth either way.
  • WifiJoinService drives ConnectivityManager.requestNetwork with a WifiNetworkSpecifier: a scanned ticket carrying credentials first shows the system join prompt, binds only this process to the host's hotspot (the phone's own Wi-Fi stays up), and only then dials the socket. Denial or timeout surfaces as a localized error, not a generic unreachable host. Platforms without specifier support just dial — the same ticket works for anyone who joined the hotspot by hand, and the error names the SSID to join.

That hand-join is a real path, not a fallback afterthought: the room QR is a URL that only resolves once the phone is already attached, so a device without the app (an iPhone, a laptop) needs the hotspot's credentials some other way. While a hotspot runs, the lobby's QR gains a Room/Wi-Fi toggle — the Wi-Fi side encodes a WIFI: payload (wifiJoinQrPayload) that iOS and Android cameras answer with a native join prompt, and the SSID/passphrase print as selectable text underneath for cameras or laptops that can't scan.

The runtime permission splits by API level — NEARBY_WIFI_DEVICES on API 33+, ACCESS_FINE_LOCATION below — gated inside the channel so a denied prompt answers as a structured error, and MainActivity resumes the pending call after the system prompt. The hotspot's lifetime is the link's: ActiveLinkController.release() stops a hosted hotspot and leaves a specifier-bound join alongside the socket close, so an abandoned lobby, a finished session, and an unmounting screen all land on the same teardown. Every game in the catalog runs over the link — Acquired Taste, Buildboard, Desert Island Poetry, Dictionairheads, Draw What Now?, Dreamweavers, Infinite Monkeys, The Island of Doctor Maroon, Off by One, Sketchy Sources, This or That?, and Unfocused Group. Networked calls skip the pass-and-play handoff and a seat whose peer drops stops blocking the quorum — including an all-departed table, which lands on a possibly-empty reveal rather than waiting on phones that already left (teams stay disabled for the first cut). How the writing phase shares the room is the game's call: Dictionairheads and Infinite Monkeys file from every seat at once, while Poetry keeps the pen sequential — the current poet's phone alone shows the field (with a turn ping when it lands), every other seat watches the leaked ends and the clock roll by, and a departed poet's line is Muse-ghostwritten under its own name so the poem never stalls. Unfocused Group's secrets stay seat-private instead: the consumer's rating lives only on their phone (canRate flags the owing seat — the value never crosses early), then every researcher guesses simultaneously from their own phone with awaiting counting down the unfiled seats; a dropped consumer's turn is skipped outright (nobody can guess an unmade rating), and a dropped guesser just stops holding the quorum. Infinite Monkeys adds its own wrinkle too: the scene stays on the host until the reveal — a filling projection carries only the seat's private hand of blank types, never the excerpt or its fills — and a departed seat's open blanks take Bard-O-Bot understudy words under its own name, so the reveal still captions a monkey and the ballot scores it at half points. Acquired Taste shares Unfocused's shape with one extra secret: the dealt rating and the calibration scales ride the influencer's private view during review (only the venue and dish are public), the calibration picks go public as evidence once the review locks, then followers guess simultaneously under the same quorum rules — a dropped influencer skips their post rather than stranding the room on a review nobody can file. Island of Doctor Maroon is the describer-driven counterpart: the card and its resolved bans ride only the describer's private view (every other phone watches the describer's chip, the synced clock, and the tally climb while guesses are shouted aloud — nothing quorum-gated), the debrief credits the calls on the describer's phone alone, and a dropped describer's turn is skipped at the gate, ended mid-call, or advanced past mid-debrief. Its clock is the purest form of the host-relative projection: describingStartedAt stamps the call's open, and turnRemainingMs ships as a remainder so a seat joining mid-call inherits the room's time — a watcher's timer is a readout, not a trigger; only the describer's expiry files the timed-out card. Off by One is the all-secrets game: every seat's dealt program is its own private half all round (the dealtWords map never crosses the wire), the accusation is a simultaneous per-phone ballot whose picks stay unshipped until the reveal, and even the unmasking tag is phase-gated — wasOut lands on an out seat's badge only when the reveal opens, so a wrong-floor program looks identical to a right one the whole mingle. The steal guess obliges only out seats while the option list stays room-public, and the host alone paces the reveal and tally. What does not exist yet: an iOS counterpart to either service (Personal Hotspot can't be driven programmatically — manual join + QR remains the path there).

iOS is designed-for but deferred — no test hardware. What already carries over: the LAN tier is platform-neutral Dart plus bonsoir's darwin implementation, and ios/Runner/Info.plist already declares NSLocalNetworkUsageDescription and NSBonjourServices (_librebash._tcp), so browse/host/join over shared Wi-Fi should work unchanged. Joining an Android host's hotspot by hand already works too — the lobby's Wi-Fi code/credentials attach the phone, and the ticket's ip:port survives the trip. The checklist when hardware exists:

  • WifiJoinService counterpart: NEHotspotConfigurationManager.apply with the com.apple.developer.networking.HotspotConfiguration entitlement — iOS can join a known SSID programmatically, unlike hosting one. Joins a ssid/psk ticket without leaving the app.
  • HotspotService stays a no-op forever: Personal Hotspot is user-driven, always shares cellular upstream, and has no API. An iPhone host simply never shows the "Provide the Wi-Fi" row.
  • Xcode capabilities toggles the entitlement requires live in the project, not in Dart — expect signing-team friction, not code.

BLE remains a possible later transport behind the same seam.

Diagnostics and logging

LoggingService (lib/data/services/logging_service.dart) is the app's persistent diagnostic log — the local-only answer to a crash reporter. One file per day is appended under <app support>/logs/yyyy-MM-dd.log; files seven days or older are pruned at startup, and a day crossing ~2 MB is trimmed to its newest half. A 500-line in-memory buffer mirrors the current day's tail. Every entry is severity-tagged (LogLevel in data/services/log_level.dart — debug, info, warning, error); the persisted log_level setting filters at write time, so a debug day's file keeps everything while the default info skips chatter.

bootstrapApp initializes the service before anything that might need it, applies the persisted level, and installs FlutterError.onError plus PlatformDispatcher.instance.onError — uncaught errors are the whole reason a persisted log exists. The service never throws: a full or unmounted volume (or the web build, where no support directory exists) degrades to the in-memory buffer rather than replacing the original error from inside a catch block. LoggingService.instance is mutable so tests can substitute LoggingService.testing rooted at a temp directory.

Callers write through the convenience methods — debug(), info(), warning(), error() — each accepting an optional tag, error, and stackTrace. Tags name the subsystem (audio, tts, net, presence, nav, scan, wake, share, web, app) so a shared file can be searched per area. A LoggingNavigatorObserver on the GoRouter records every route transition, so a shared log can be replayed to see which path the user took. Anything that used to be a bare debugPrint belongs here instead — console output is invisible on release builds and gone with the process; the file survives both.

The settings screen's Troubleshooting section exposes the level pick (LogLevelSetting persists and pushes into minLevel live), a link to the user guide's known issues, and View logs at /settings/logs. LogViewerScreen lists the dates with files newest-first (today always selectable), reads a day's file from disk, and offers copy, share-file through share_plus (clipboard fallback where no share UI exists, the same pattern as TextShareService), refresh, and confirmed clear. A privacy note reminds users logs can contain app internals and paths — they leave the device only through explicit copy/share.

Persisted names are contracts

GameMetadata.id values are stable identifiers — future save/resume state and stats will key off them, so they must never change once a game ships. Likewise the screen-pinning channel name and the BUILD_TIMESTAMP dart-define key are platform contracts. terminology.md keeps the full list.

Build metadata and packaging

tool/build.sh is the single entrypoint for release-shaped builds (linux, android, all, per-format linux-*). It writes tool/build-timestamp.json before every build; the value reaches the app through --dart-define-from-file and lib/core/build_info.dart, and shows up on the about tile. Android versionCode is derived from the git commit count (resolveVersionCode in android/app/build.gradle.kts), never the pubspec build number. docs/dev-guide/ci-and-packaging.md has the full packaging and release story.

Adding a new mini game

  1. packages/<game_name>/ — new package in the workspace (add to the root workspace: list, resolution: workspace in its pubspec, path deps on game_core/game_mechanics, plus game_content when the game draws from the shared vocabulary).
  2. Implement MiniGame: const class, metadata (stable id), and buildSession returning GameSessionView — the session-screen shell (game_mechanics) that owns SessionScope, the post-frame beginFromOptions() kick, the themed exit gate, and the networked/local mode switch — around the game's local phase switch. The screen is driven by a @riverpod controller whose dependencies: list the four shared session providers. The metadata is also the game's setup declaration — playersTitle, previousPlayersTitle, beginLabel, rules/rulesTitle, roundOptions, and bot shape the shared GameSetupScreen; the session's first frame calls the controller's beginFromOptions() to turn the resolved SessionOptions into play. A game that only works over a room additionally sets requiresNetwork: true (+ isPassAndPlay: false, supportsRemote: true): the setup screen then refuses every local roster path until a room is hosted (see the requiresNetwork paragraph under multiplayer).
  3. Add a <Game>Strings class + scope if the game has its own text.
  4. Register it in gameRegistryProvider and add its localizedGameInfo/wrapGameSession entries and ARB strings.
  5. Tests for the state machine live in the package's own test/.
  6. Remote play is opt-in: supportsRemote + buildRemoteSession returning a remote view. The remote view subclasses RemoteGameView/RemoteGameViewState (game_mechanics) — the base owns the wire lifecycle (subscriptions, phase-change ballot reset, the RemoteSpeechCue and TurnAlert plumbing, the session scaffold and exit gate — remoteScaffold keys the phase subtree by phase, so a phase change is a real remount: two same-type screens in a row, like a per-piece reveal straight into the standings, can't silently reuse the first screen's State); the game fills in hasOpenAction, the phase hooks (onPhaseChanged/onViewChanged/afterView), onReply, and the per-phase widgets. Games whose input can outlive a blink — text fields, drawing boards, rating picks — additionally fill draftScope/draftSnapshot/restoreDraft and call draftEdited() on every edit so a dropped link's rejoin resumes mid-obligation (see Remote drafts above); the shared ballot slice rides ballotDraft/restoreBallotDraft. The base also holds the tap-side filed/fileIntent lock and the common projection decoders — wireEntries, wireStringList, wireIntMap, wirePlayer, wireTeams, wireResults, ownEntryIds, wireTurnTimer, wireTurnRemaining. game_mechanics also carries the shared chrome — the scrollable-and-capped sessionContentColumn body every phase view wraps in, the ballot (RemoteBallotView + RemoteBallotTile, RemoteReactionPicker, voteResultFromWire/voteResultToWire — reactOnly drops a forced seat to an emoji-only face, the base's sendReaction files each pick over the react intent, entryMedia/ownTag let a game hang artwork above each entry and reword the own-entry tag without the widget importing the game's media types, and paged — the drawing games' opt-in — swaps the interactive and react-only tile lists for a one-entry-at-a-time pager (swipe or the chevron steppers, a dot row marking the page and the starred pick) because a big subtitle like the framed drawing would squeeze a scrolling list to a single visible tile; the game then caps that artwork against the ballot's fixed chrome rather than the raw screen height, and the spectator face stays a list either way), RemoteWaitingCard, the shared leadersOf "ties share the crown" rule (vote_round.dart), the RatingSubmit/NumberLine/NumberLineMarker rating widgets, and HostSeatView — the host's in-call mount that assembles the RemoteSession and republishes projections on each state change — plus NetworkedSessionView above it, which owns the bridge's create/dispose lifecycle (and the post-frame onReady kickoff a no-options game deals from) so a session screen writes only the bridge construction, the provider, and the remote view. Above all of that sits GameSessionView, the shell every game screen returns from buildSession: it wraps SessionScope, fires the options kick, runs the local mode inside SessionScaffold with the themed exit gate (exitCopy/isExitSafe/onJumpToStandings are the seams), and hands the whole screen to NetworkedSessionView when networkedWhen reports a networked call — a local-only game leaves the three bridge params null and gets everything but the hand-off. The game_core pieces are HostSeatChannel for the bridge's in-process host pipe, RemoteSpeechCue/SpeakerQueue/VoiceRoller for read-aloud in both directions, TurnAlert for the per-seat "your turn" edge (RemoteSession.turnPing is the callback it drives), and package:game_core/testing.dart (FakeSessionLink, FakeRemoteGameChannel, RecordingSpeaker) for remote-view tests. Countdowns project a host-computed relative remainder (turnRemainingMs, with LinkBridge's PauseLedger pricing room-pause and wrap-up-takeover time out of it), never a wall-clock deadline — and the room's own wrap-up countdown rides link.wrap_up the same way ({ms, total}, welcome-echoed).