Imported from freemansoft/fs-game-score (
.claude/skills/fs-game-score-live-sync/SKILL.md). Install upstream withnpx skills add freemansoft/fs-game-score --skill fs-game-score-live-sync. Copyright stays with the author.
FS Score Card — Live game sync
View-only LAN score sharing. Host owns real state in gameNotifierProvider / playersNotifierProvider. Spectator mirrors wire snapshots in gameSyncSpectatorProvider — never writes prefs or host notifiers.
Full reference: docs/Game-Sync.md. State overview: State-Management.md — Live score sync.
Always use fvm flutter per CLAUDE.md.
Platform scope (v1)
canHostLiveSync / canJoinLiveSync in lib/sync/game_sync_platform.dart — Android/iOS native only. Web/desktop hide live host/join; CSV share unchanged. Real mDNS E2E needs two devices on the same Wi-Fi; emulators use manual ws:// in kDebugMode on the join screen.
Providers
gameSyncHostProvider (lib/provider/game_sync_host_provider.dart)
- Reads game + players notifiers; does not own game state.
startHosting()→resolveLiveSyncAppVersion(game)→startGameSyncHost(initialSnapshot, pin, requiredAppVersion).- Unknown version →
errorMessage: 'live_sync_app_version_unknown'. - Listens to game/players →
broadcastCurrentState()(revision++,broadcastGameSyncSnapshot). - Snapshot
hostDeviceName=liveSyncConnectionLabel(gameId:, hostIp:)— short game ID or LAN IP, neverPlatform.localHostname. stopHosting()on dispose, new game, or leaving score table (score_table_screen,new_game_control).
gameSyncSpectatorProvider (lib/provider/game_sync_spectator_provider.dart)
- Owns mirrored
Game/Playersfor read-only UI only. gameSyncTransportFactoryProvider→GameSyncTransport Function().connect()creates a fresh transport each time (never reuse disposed instances).connect(wsUrl, pin)→Future<GameSyncConnectResult>— completes on first snapshot, terminal error, or 15s timeout. SetsconnectedHostIpfrom parsed URL.isConnectedrequiresconnected+ non-null game and players (first snapshot received).- Clears mirrored state on
idle,wrongPin,versionMismatch,hostClosed.
Wire protocol
lib/sync/game_sync_protocol.dart — JSON over WebSocket.
Types: hello, welcome, reject, snapshot, ping, pong, hostClosed.
URL / QR: ws://<host-ip>:8765?game=<gameId>&pin=<pin> (game_sync_qr.dart). mDNS: _fsscore._tcp.
Mapper: game_sync_mapper.dart ↔ Game + Players.
Wire compatibility and versioning (check on every change)
The shared snapshot shape is GameConfiguration.toJson() keys/types + Player.toJson() keys/types + the protocol message structure in game_sync_protocol.dart. The compatibility boundary is the major semver: gameSyncAppVersionsMatch admits a spectator only when its major matches the host's (1.12.0+236 ↔ 1.13.0+200 OK; 2.x ↔ 1.x rejected).
Before finishing any change, ask: did the shared snapshot shape change?
- Breaking → bump the MAJOR version (
pubspec.yaml, CHANGELOG; seefs-game-score-release-engineer): removing, renaming, or retyping any key inGameConfiguration.toJson/Player.toJson, or changing a protocol message's structure. A same-major spectator would mis-parse, so the major bump is what makes mismatched shapes refuse to connect. - Non-breaking → no major bump: adding a new value to an existing enum field (e.g. a new
GameMode—fromJsonfalls back to the default for unknown values and same-major peers share the code), or adding an optional key that older readers can ignore.
Adding GameMode values (Golf, Hearts) was non-breaking — new values in the existing gameMode string, shape unchanged. Descriptor-only fields (GameRules: winDirection, endCondition, roundOptions, suggestedMaxRounds) are derived from gameMode and never serialized, so they never affect the wire.
Handshake and validation
- Spectator sends
hello:{ pin, appVersion, spectatorName }. - Host validates 6-digit PIN → else
reject/wrongPin(gameSyncRejectWrongPin). - Host validates
appVersionviagameSyncAppVersionsMatch(non-empty, same major semver, e.g.1.12.0+236matches1.13.0+200) → elseversionMismatch(gameSyncRejectVersionMismatch). - Host sends
welcome+ initialsnapshot; later snapshots on each broadcast. - Spectator re-checks host version on
welcome(defense in depth). JoinLiveGameScreenawaitsGameSyncConnectResult.connectedbeforegoNamed('live-spectator').
App version: global appVersion from PackageInfo in bootstrapApp(), else GameConfiguration.version via resolveLiveSyncAppVersion() (game_sync_app_version.dart).
Connection banner (spectator)
LiveConnectionBanner + resolveLiveConnectionBannerTarget() (game_sync_connection_label.dart):
- Short game ID (first 8 chars of
spectator.game?.gameId) - Else LAN IPv4 from
connectedHostIpor snapshothostDeviceName - Else
liveConnectionConnectedOnly— neverlocalhost
UI touchpoints
| Component | Route / role |
|---|---|
LiveShareControl |
Host dialog; CloseButton dismisses dialog only — sharing continues until Stop live sharing |
JoinLiveGameScreen |
/join-live — discovery, connect overlay, error snackbars |
_JoinLiveScanDialog |
One-shot QR scan (DetectionSpeed.noDuplicates) — multiple Navigator.pop breaks GoRouter |
SpectatorScoreTableScreen |
/live-spectator — read-only ScoreTable |
LiveConnectionBanner |
Spectator connection label |
L10n: liveConnectionConnected, liveConnectionConnectedOnly, liveConnectionWrongPin, liveConnectionVersionMismatch, liveSyncAppVersionUnknown.
Debug logging
lib/sync/game_sync_log.dart — gameSyncLog(), gameSyncLogConnectionState() inside assert() (debug builds only).
Testing
| Area | Files / approach |
|---|---|
| Protocol, version, QR, mapper, labels, platform | test/game_sync_*.dart |
| Provider/widget | Override gameSyncTransportFactoryProvider → () => FakeGameSyncTransport |
| Persist | Spectator snapshots must not touch SharedPreferences |
See fs-game-score-testing-workflow for integration and fake transport patterns.
Key files
lib/provider/game_sync_host_provider.dart,lib/provider/game_sync_spectator_provider.dartlib/sync/game_sync_lan_io.dart,game_sync_transport.dart,fake_game_sync_transport.dartlib/presentation/live_share_control.dart,join_live_game_screen.dart,live_connection_banner.dart