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_coreis the "plugin" seam. A mini game is a package that implements theMiniGamecontract (metadata+buildSession). The app'sGameRegistry(built inlib/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_mechanicsholds 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 optionalsubjectshows what's being voted on), and the round resolves to aVoteResultwhose helpers (pointsFor,winnerIds,mostReactedIds…) implement the app's scoring scheme: favorite = 100 points, each emoji reaction = 10, all emoji scored positively.VoteScoreboardrenders the result — ranked players, each followed by their scored entries with the reactions flying through them. Entries authored by the app itself use thebotAuthorIdconvention: votable like any other, sorted into the scoreboard, but never eligible for a player callout. Every session screen wraps its phase content inSessionScaffold— 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'sendSessionEarly()to jump to the results phase. Games pass null on phases where exit is already safe — the results screen stays ungated.drawDeckProvideris the odd provider out: unlike the four session providers it is not overridden bySessionScope— it resolves in the root container, so every game and every session shares the one deck, andbootstrapApphydrates it from disk. Authored content draws go through it (deck.deal,dealPool,composePhrase/composeGuessCardtake the deck); anything that is game logic rather than authored content — bot guesses, hand shuffles, voice rolls — stays onsessionRandomProvider. 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), andVoteResultsView(callouts over the scoreboard plus closing actions).VoteResultsViewis itself a thin vote shell overSessionResultsView— the scoring-agnostic results layout (headline, callouts, scrollable score area, closing actions) that points-based games fill withPlayerScoreboardinstead of aVoteScoreboard— orTeamScoreboardwhen a session runs in teams mode (team identity mirrors player identity:TeamAvataris name + emoji + palette color);PlayerChipRowcovers the "pick a player" moment (Doctor Maroon uses it to credit whoever guessed a creature).ShareActionsis 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_contentholds the shared vocabulary. Curated all-ages-safe word pools (adjectives, nouns, names, creatures, quirks, clauses, partitives…) pluspick/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 sharedDrawDeck, so no recipe or word repeats until its pool wraps), pluspoolDeckKey+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 — pluscurated_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 — andnonsense_words.dart: a syllable composer (randomNonsenseWord, configurableminSyllables/maxSyllables) that mints pronounceable fake words with a fake part-of-speech tag, plusbotDefinition— the app-written "bot" entry a game can slip into a vote — andguess_cards.dart, the forbidden-word deck machinery:NounEntry.forbiddengives every guess-target bank entry a ranked ban list (most-obvious clue first, so a game bans the first few or the whole list),GuessCardcarries the card's words and theirbannedWords, andcomposeGuessCardiscomposePhraseplus the rule that every component word must carry authored bans — for Doctor Maroon and any future guess-the-thing variant — andconfusables.dart, the confusable-family lens:NounEntry.familiestags a word into one or more sets of near-identical things ("rodents", "dumplings"),NounEntry.definitioncarries the one-line differentiating gloss, andNounEntry.generatorsallowlists 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.kindis the semantic lens slots pick by:thingis reserved for concrete objects so an "a {thing}" slot can't mint "a bowling", whileactivity,phenomenon,landform,style, andconceptkeep sports, weather, terrain, hairdos, and abstracts available to templates that want them;singularThingsnarrows 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 inword_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/servicesinterface (ScreenPinningService,FlutterTtsSpeaker) so tests and desktop builds can substitute no-op fakes. - Session state is game-owned.
GameSessionhands the game aList<Player>(seating order), resolvedSessionOptions, aSpeaker, aSessionLinktransport seam (see Local multiplayer below), anonExitcallback, and the session-scoped extras (actions,onOutcome,turnPing,debugLog,seatDriver,selfProjecting). Each game wraps its session inSessionScope, 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. (sessionRandomProvideris the deliberate exception — it is not seeded, since a freshRandomis already the right default; tests override it for determinism.) The controller's@Riverpodannotation must declaredependencies: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@riverpodannotations. - flutter gen-l10n:
AppLocalizationsfromlib/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
pumpAndSettleand render their final state instantly whenMediaQuery.disableAnimationsis on. - Ambient — loops (
EmojiBackdropdrift,Wiggle, tagline cycling). They may only run under anAmbientMotionInheritedWidget permit;AmbientMotion.of(context)is false with no ancestor and under reduced motion. The app root (MaterialApp.router'sbuilderinapp.dart) and the web client root installAmbientMotion(enabled:driven byambientMotionEnabledProvider— a runtime-onlykeepAlivesetting, 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>Stringsclass with English defaults plus a<Name>StringsScope— a three-line subclass ofGameStringsScope<T>(game_mechanics) — and a<Name>Strings.of(context)accessor. wrapGameSessioninlib/features/session/game_catalog.dartwraps every session inMechanicsStringsScope(built from ARB) plus the game's own scope, keyed byGameMetadata.id.localizedGameInfoin 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, andlocalizedRulesfeed 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 drivesMaterialApp.themeMode). - Common settings (per-game overrides):
game_scoring_<gameId>,game_inspiration_<gameId>,game_typos_<gameId>, andgame_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 eachRoundOptionthe game declares onGameMetadata.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):SoundCueenumerates the fourteen cues;SoundPlayeris the seam —playis 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).NoOpSoundPlayeris the const default;FilteredSoundPlayerwraps 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):SoundScopeis the InheritedWidget andcontext.playSound(cue)the fire line — besidePartyHapticsin spirit.SoundScope.offalls back to a constNoOpSoundPlayer, so widgets pumped bare in tests stay silent rather than needing a fake engine. Cues ignoreMediaQuery.disableAnimations— sound is not motion; the reveal choreography'sonArrivedcallbacks are tracked timers (not animation listeners) precisely so a reduced-motion scoreboard still lands its ticks. - The engine (
lib/data/services/sound_player.dart):SoLoudSoundPlayerwrapsflutter_soloud(pinned 5.1.2, F-Droid-safeno_xiph_libs: truehook — seepubspec.yaml). It boots lazily on the first cue: the engine comes up throughensureSoLoudEngine(see the Music engine bullet — an unguardedSoLoud.initreinits 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. Thevolumesetter lands per-cue atplaytime — neversetGlobalVolume, which would make the effects slider fight the music channel's gain. The web client never builds this —soundPlayerProviderpicks per platform (native_audio_players.dartvsweb_client/web_audio_players.dart, bound bydart.library.js_interop), and the browser build getsWebSoundPlayerinstead: one preloaded<audio>template per cue (fetched during the page's idle load — autoplay policy gatesplay(), neverload()), 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.assetForowns the cue→file mapping. Declared as a pubspec asset directory;assets/audio/ATTRIBUTIONS.mdcredits every source (CC0 unless noted).tool/import_audio.shre-runs the normalize+transcode for future picks. - Scope policy: the app root (
app.dart'sMaterialAppbuilder) installs the raw player — the host device plays the full vocabulary.RemoteGameScreenand player seats in the web client each wrap their shell in aFilteredSoundPlayerallowingtap,turnPing,timerTick, andtimeUp— "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/ddisplay IS the room's speaker, so it getssoundPlayerProviderunfiltered. While an observer is attached the app shell itself drops into that filtered menu — the sameroomDisplayPresentProviderroster stream that hands the music over swaps the rootSoundScope— 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_volumekeys inSettingsRepository(default on, default 1.0, volume clamped 0–1). TheSfxEnabled/SfxVolumenotifiers persist, then write through tosoundPlayerProvider— a mid-party mute silences the very next cue. - Browser autoplay policy: with cues on plain
<audio>elements there is noAudioContextto suspend — a refusedplay()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 castPresentationWebView opts out entirely (mediaPlaybackRequiresUserGesture = false). Music adds one belt-and-suspenders layer for the gap in between:WebMusicPlayerparks 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:
tapon picker cards, the setup start button, and remote forward actions;passWhooshwhen the pass gate mounts andconfirmon its identity button;countdownBeep/countdownGoon the splash;timerTickinside the turn timer's final five seconds andtimeUpat expiry;stampwhen a ballot star slams;turnPingon a remote seat's actionable edge;leaderboardRowas each scoreboard row lands (rimshoton the lowest zero-point row);revealStingwhen a results screen mounts;drumrollstarted so its ~3 s tail lands on the winner's row;winnerFanfareat 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):MusicPlayeris 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 andreleasedrops back to whatever asked before it.duck()/unduck()form a refcounted dip for speech and celebrations,pause()/resume()cover app backgrounding, andenabled/volumemirror the settings. Every implementation degrades silently — a missing track or dead engine means silence, never a crash.NoOpMusicPlayeris the const default;RecordingMusicPlayer(game_core/lib/src/testing/) is the test double.MusicPlayer.menuSlotis the shared base layer. - The scope (
game_mechanics/lib/src/music_scope.dart):MusicScopeis the InheritedWidget;MusicScope.offalls back to a constNoOpMusicPlayerso bare test widgets stay silent. Three widgets ride it:MusicRegionholds a request for its slot while mounted (re-requesting if the scope or slot changes),MusicDuckholds a time-boxed dip (self-releasing so a lingering screen can't strand it), andMusicLifecycleobserves the app lifecycle: real backgrounding (paused/hidden/detached) pauses immediately, butinactive— a transient dip like a system sheet, pinning prompt, or soft keyboard — only starts a short grace timer thatresumedcancels, so a focus flicker can't kill the soundtrack. Like cues, music ignoresMediaQuery.disableAnimations— sound is not motion. - The engine (
lib/data/services/music_player.dart):SoLoudMusicPlayershares theflutter_soloudsingleton withSoLoudSoundPlayer, and both boot it throughensureSoLoudEngine(lib/data/services/soloud_engine.dart) —SoLoud.initon a live engine deinits and reinits it, which kills every voice and unloads every cached source, so the helper checksisInitializedand joins one in-flight boot rather than racing a secondinit. A refusedplay(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 buildsWebMusicPlayerinstead (lib/web_client/ web_music_player.dart, via the samedart.library.js_interopconditional 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.dartinstalls the raw player plusMusicLifecycleand a permanentMusicRegionformenuSlotaround the navigator — home, setup, lobbies, and settings all sit on the menu loop. InsidewrapGameSessioneach session adds aMusicRegionfor itsGameMetadata.musicSlug, so the game's theme starts the moment the session subtree mounts and hands the menu back on exit.SessionResultsViewwraps itself inMusicDuckfor the reveal — the track dips but keeps playing under the sting.speakerProviderwraps the real engine inDuckingSpeaker, which dips the music for every utterance's duration. - Scope policy: remote app seats (
RemoteGameScreen) and player seats in the web client each install aNoOpMusicPlayer— "phones small" means no music off the host, and the browser bundle still never stagesassets/audio/music/(~14 MB stays out of the staged assets). The lobby counts too: the app shell swaps its rootMusicScopeto the no-op player while the adopted link is a non-presenting client (deviceOwnsRoomAudio— host orlink.presentskeeps it), so a joined player's phone doesn't carry the menu loop between calls. A web-client observer — the/ddisplay — flips the policy: it owns the room's soundtrack, so it scopes the realmusicPlayerProviderand the unfilteredsoundPlayerProvider— the providers' platform pick lands the<audio>-element builds here — andRoomStaticContentfalls back to the host's native asset tree for/assets/paths the staged bundle lacks — the display'sassets/assets/audio/music/…requests resolve to the MP3s the app already ships, so nothing gets bundled twice. While anyLinkRole.observerpeer is attached — whoever's device is actually presenting, host or client — the room's soundtrack belongs to the display:roomDisplayPresentProviderstreams the roster check so a display attaching or leaving mid-room (lobby included) flips the app-shell scopes live,deviceOwnsRoomAudio'sdisplayAttachedhands the music over, and the host'sSoundScopedrops 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/dobserver 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 bytool/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) plusmenuandwrap_up(the wrap-up countdown'swrapUpMusicSlot).assets/audio/ATTRIBUTIONS.mdcredits every track. - Settings:
music_enabled/music_volumekeys inSettingsRepository(default on, default 0.5, clamped 0–1). TheMusicEnabled/MusicVolumenotifiers persist, then write through tomusicPlayerProvider— 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 throughpreviewVolume, and only hit SharedPreferences ononChangeEnd— 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):
HotspotServicedrives Android'sWifiManager.startLocalOnlyHotspotthrough thedev.johnkneev.librebash/hotspotchannel onMainActivity.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 on10.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 theJoinTicketgainsssid/pskso one scan carries network + session. Every start also requests friendly credentials —mintHotspotCredentials(lib/features/multiplayer/ hotspot_credentials.dart) mints aLibreBash-XXXXSSID and a pronounceablenonsense-CODEpassphrase — which Android 16.1+ applies viastartLocalOnlyHotspotWithConfiguration(the credential-setting builder methods only exist there;MainActivityprobes them reflectively and falls back to a random AP); older releases ignore the request, and the reservation'ssoftApConfigurationstays the source of truth either way.WifiJoinServicedrivesConnectivityManager.requestNetworkwith aWifiNetworkSpecifier: 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:
WifiJoinServicecounterpart:NEHotspotConfigurationManager.applywith thecom.apple.developer.networking.HotspotConfigurationentitlement — iOS can join a known SSID programmatically, unlike hosting one. Joins assid/pskticket without leaving the app.HotspotServicestays 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
packages/<game_name>/— new package in the workspace (add to the rootworkspace:list,resolution: workspacein its pubspec, path deps ongame_core/game_mechanics, plusgame_contentwhen the game draws from the shared vocabulary).- Implement
MiniGame: const class,metadata(stableid), andbuildSessionreturningGameSessionView— the session-screen shell (game_mechanics) that ownsSessionScope, the post-framebeginFromOptions()kick, the themed exit gate, and the networked/local mode switch — around the game's local phase switch. The screen is driven by a@riverpodcontroller whosedependencies:list the four shared session providers. The metadata is also the game's setup declaration —playersTitle,previousPlayersTitle,beginLabel,rules/rulesTitle,roundOptions, andbotshape the sharedGameSetupScreen; the session's first frame calls the controller'sbeginFromOptions()to turn the resolvedSessionOptionsinto play. A game that only works over a room additionally setsrequiresNetwork: 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). - Add a
<Game>Stringsclass + scope if the game has its own text. - Register it in
gameRegistryProviderand add itslocalizedGameInfo/wrapGameSessionentries and ARB strings. - Tests for the state machine live in the package's own
test/. - Remote play is opt-in:
supportsRemote+buildRemoteSessionreturning a remote view. The remote view subclassesRemoteGameView/RemoteGameViewState(game_mechanics) — the base owns the wire lifecycle (subscriptions, phase-change ballot reset, theRemoteSpeechCueandTurnAlertplumbing, the session scaffold and exit gate —remoteScaffoldkeys 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'sState); the game fills inhasOpenAction, 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 filldraftScope/draftSnapshot/restoreDraftand calldraftEdited()on every edit so a dropped link's rejoin resumes mid-obligation (see Remote drafts above); the shared ballot slice ridesballotDraft/restoreBallotDraft. The base also holds the tap-sidefiled/fileIntentlock and the common projection decoders —wireEntries,wireStringList,wireIntMap,wirePlayer,wireTeams,wireResults,ownEntryIds,wireTurnTimer,wireTurnRemaining.game_mechanicsalso carries the shared chrome — the scrollable-and-cappedsessionContentColumnbody every phase view wraps in, the ballot (RemoteBallotView+RemoteBallotTile,RemoteReactionPicker,voteResultFromWire/voteResultToWire—reactOnlydrops a forced seat to an emoji-only face, the base'ssendReactionfiles each pick over thereactintent,entryMedia/ownTaglet a game hang artwork above each entry and reword the own-entry tag without the widget importing the game's media types, andpaged— 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 bigsubtitlelike 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 sharedleadersOf"ties share the crown" rule (vote_round.dart), theRatingSubmit/NumberLine/NumberLineMarkerrating widgets, andHostSeatView— the host's in-call mount that assembles theRemoteSessionand republishes projections on each state change — plusNetworkedSessionViewabove it, which owns the bridge's create/dispose lifecycle (and the post-frameonReadykickoff 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 sitsGameSessionView, the shell every game screen returns frombuildSession: it wrapsSessionScope, fires the options kick, runs the local mode insideSessionScaffoldwith the themed exit gate (exitCopy/isExitSafe/onJumpToStandingsare the seams), and hands the whole screen toNetworkedSessionViewwhennetworkedWhenreports a networked call — a local-only game leaves the three bridge params null and gets everything but the hand-off. Thegame_corepieces areHostSeatChannelfor the bridge's in-process host pipe,RemoteSpeechCue/SpeakerQueue/VoiceRollerfor read-aloud in both directions,TurnAlertfor the per-seat "your turn" edge (RemoteSession.turnPingis the callback it drives), andpackage:game_core/testing.dart(FakeSessionLink,FakeRemoteGameChannel,RecordingSpeaker) for remote-view tests. Countdowns project a host-computed relative remainder (turnRemainingMs, withLinkBridge'sPauseLedgerpricing room-pause and wrap-up-takeover time out of it), never a wall-clock deadline — and the room's own wrap-up countdown rideslink.wrap_upthe same way ({ms, total}, welcome-echoed).