Data Flow Architecture
This document traces how data moves between the extension’s components, what gets serialized at each boundary, and the message protocols that connect them.
Component Overview
The extension has three components, each running in a separate Chrome extension context (isolated JS environment with its own globals and lifecycle). They communicate via Chrome’s message passing APIs. Chrome JSON-serializes all data crossing boundaries between contexts — no class instances, Maps, Sets, or functions survive the trip. Game state objects must be explicitly serialized before sending and reconstructed on the receiving side.
Content Script
Runs in the MAIN world of the BGA game board. Returns raw extraction data to the Background Service Worker.
BGA’s modern layout serves a table at a /tableview?table=<id> shell page and embeds the actual board in a same-origin iframe (the classic /<gameId>/<slug>?table=<id> page); the gameui global lives only in that frame. So extraction (and the icon probe and live watcher) inject into all frames of the tab — the board is whichever frame finds gameui loaded. Legacy direct game URLs and replays are the special case where the board is the top frame.
The board iframe loads after the shell, and sub-frame loads don’t fire chrome.tabs.onUpdated, so a chrome.webNavigation.onCompleted listener (requires the webNavigation permission) re-runs the icon probe for the active tab once a game-board frame finishes loading — otherwise the toolbar icon would miss the late iframe and stay dark, most visibly while the side panel is closed. The same listener also re-resolves the panel content when the board frame arrives, unless it’s already resolved for that table: the onUpdated-driven extraction fires when the shell completes and can give up (after its retry window) before gameui exists in the iframe, which would otherwise strand an open panel on the help/not-a-game view until the user clicked the icon.
Must be fully self-contained — injected via chrome.scripting.executeScript(), so any references to module-level code are undefined after Chrome serializes the function.
Responsibilities:
- Read player info (id, name, BGA-assigned color, observer flag) and initial hand from
gameui.*globals - Fetch full notification history via BGA’s API
- Extract game name from page URL pathname
- Package results as
RawExtractionData
Key files:
src/extract.ts— data extraction from BGA page globals and API
Background Service Worker
Persistent orchestrator. Processes raw extraction data into game state and pushes results to the Side Panel.
Responsibilities:
- Inject the Content Script into BGA game pages
- Run game-specific processing pipelines (raw packets -> game log -> game state)
- Push results to the Side Panel (no request/response — push-only model)
- Manage toolbar icon/badge animations
- Coordinate live tracking (watcher injection, rate-limited re-extraction with deferred catch-up)
- Handle navigation events and auto-hide logic
Key files:
src/background.ts— orchestration, message handling, icon/badge, live trackingsrc/pipeline.ts— pure pipeline logic (processGameLog,processGameState,runPipeline); shared by background.ts and CLI scripts (scripts/game-log.ts,scripts/game-state.ts)src/games/*/process_log.ts— raw BGA packets to structured game logsrc/games/*/game_state.ts— game log to game state, serialization
Side Panel
Extension page. Receives PipelineResults (raw data, game log, and serialized game state) pushed from the Background Service Worker, renders interactive HTML in the browser side panel.
Responsibilities:
- Receive pushed results from the Background Service Worker and render game-specific HTML summaries
- Manage UI state (toggles, zoom, section visibility) with localStorage persistence
- Generate self-contained ZIP downloads with inlined assets
- Maintain connection lifecycle (reconnect on service worker restart)
Key files:
src/sidepanel/sidepanel.ts— UI logic, message handling, downloads, zoom, togglessrc/games/*/render.ts— game-specific HTML renderingsrc/games/*/display.ts— per-game display menu construction and display-option application (section visibility, shimmer)src/render/help.ts— help page contentsrc/sidepanel/settings.ts— shared localStorage persistence (loadSetting/saveSetting with typed defaults)src/render/toggle.ts— shared toggle logic (used by both side panel and ZIP export); tooltips are CSS-only via anchor positioning
Data Flow: Full Extraction
Extracts game data from a BGA page, processes it through a game-specific pipeline, and delivers the result to the Side Panel for rendering. Both supported and unsupported games follow the same flow — the difference is whether the pipeline processes the data or passes it through as raw-only.
Triggers:
- User clicks the extension icon
- User presses the keyboard shortcut (
toggle-sidepanel) - User switches to a tab with a BGA game table
- Page finishes loading on a BGA game URL
- Window focus changes to a window with a BGA game tab
Background Service Worker
- Gate on
background.isPotentialTablePage()— a BGA URL carrying atable=id. This covers the classic/<gameId>/<slug>?table=board URL and the modern/tableview?table=shell that embeds the board in an iframe. It excludes the classic/table?table=page — the board-less pre-game lobby (which redirects to/tableview) — so that resolves to help immediately instead of flashing a spinner and burning extraction retries. If it isn’t a potential table page, send"notAGame"to the Side Panel (help page) and stop. - Read the table number from the URL’s
table=param — the slug-less/tableviewshell URL still carries it. Lock against concurrent extractions. - Send
"loading"message to Side Panel - Inject
dist/extract.jsinto all frames of the tab (MAIN world). Retry a few times to let the board iframe finish loading. The game slug isn’t knowable from the shell URL, so it comes from the resolved board frame’s data (next), not the tab URL.
⇩ (no data passed to Content Script)
Content Script
- Read player info (id, name, BGA color hex, observer flag) and current hand contents from
gameui.gamedatas(frames that aren’t the board — the shell and loader frames — return{ notGame: true }instead, which the background skips silently; a frame that hasgameuibut fails returns{ error, msg }, which surfaces as an error rather than the help page) - Fetch full notification history via
gameui.ajaxcall() - Extract game name from this frame’s URL pathname
- Package results as
RawExtractionData
⇩ RawExtractionData (auto-serialized by Chrome):
⇩ { gameName, players: Record<id, PlayerInfo>, gamedatas: {my_hand, cards}, packets: RawPacket[], currentPlayerId }
⇩ PlayerInfo: { id, name, colorHex (BGA hex, no `#`), isCurrent }
Background Service Worker — picks the successful (non-error) frame result as the board’s RawExtractionData; the game slug it reports decides the branch. (No board frame after the retries — the iframe never loaded, or it isn’t a game — falls back to "notAGame" / the help page.) Branches on whether that game is supported:
Supported game ("extract") | Unsupported game ("unsupportedGame") |
|---|---|
| ***Background Service Worker*** 1. Validate player count via `pipeline.isValidPlayerCount()` — reject unsupported configurations (e.g. 2-player Crew) 2. Transform raw data via `pipeline.runPipeline()`: - Innovation: `process_log.processRawLog()` → `GameState.processLog()` → `GameState.toJSON()` - Azul: `process_log.processAzulLog()` → `game_state.processLog()` → `game_state.toJSON()` - Crew: `process_log.processCrewLog()` → `game_engine.processCrewState()` → `serialization.crewToJSON()` 3. If the pipeline throws, cache a fallback `PipelineResults` with `rawData` only (`gameLog` and `gameState` are `null`) so the *Side Panel* can still offer a raw data download 4. Cache `PipelineResults` (with `gameLog` and `gameState`) 5. Push results to *Side Panel* 6. Inject live watcher (sets up Live Tracking) | ***Background Service Worker*** 1. Cache `PipelineResults` with `rawData` only (`gameLog` and `gameState` are `null`) 2. Push results to *Side Panel* |
| ``` ⇩ "resultsReady" message with PipelineResults payload: ⇩ { gameName, tableNumber, rawData, gameLog, gameState } ``` ***Side Panel*** 1. Reconstruct live objects from serialized state: - Innovation: fetch `card_info.json`, call `GameState.fromJSON()` - Azul: call `game_state.fromJSON()` - Crew: call `serialization.crewFromJSON()` 2. Generate HTML, set up toggles/zoom, apply per-game display options (tooltips are CSS-driven via anchor positioning) | ``` ⇩ "resultsReady" message with PipelineResults payload: ⇩ { gameName, tableNumber, rawData, gameLog: null, gameState: null } ``` ***Side Panel*** 1. Detect `gameState` is `null` — show help page 2. Enable download button (ZIP contains only `raw_data.json`) |
Data Flow: Live Tracking
Keeps the Side Panel in sync as the game progresses by detecting DOM changes and re-running the extraction pipeline. Initiated by the watcher injection in Full Extraction step 4.
Content Script (watcher)
- Observe DOM mutations on
#logs/#game_play_areaviaMutationObserver(injected into all frames; self-bails where no log container exists, so only the board frame observes) - Wait for changes to settle (2000ms quiet period) before notifying
⇩ "gameLogChanged" message
Background Service Worker
- Validate re-extraction guards:
- Sender tab matches tracked live tab
- Side Panel is open
- No extraction currently in progress
- At least 5 seconds since last extraction
- If rate-limited (less than 5s since last extraction): schedule a deferred re-extraction after the remaining time. Only one deferred timer is active at a time; subsequent mutations within the same window are coalesced.
- If all guards pass, re-run Full Extraction flow silently (clear any deferred timer)
- Only push results to Side Panel if packet count increased
Data Flow: Side Panel Connect
When the Side Panel opens (or reconnects after a service worker restart), the Background Service Worker pushes any cached results immediately. This eliminates request/response round trips — the side panel never polls for data.
Triggers:
- User opens the side panel (via extension icon or keyboard shortcut)
- Service worker restarts while the side panel is open
Side Panel
- Start in help page state by default
- Load persisted pin mode from localStorage and push to background via
setPinMode - Establish port via
chrome.runtime.connect({name: "sidepanel"})
⇩ Port connection event
Background Service Worker
- Query the active tab and read its table number via
background.tableNumberFromUrl() - Compare the active tab’s table number against
lastResults?.tableNumber:- Same table: push cached
"resultsReady"immediately (no loading flash) - Different table (user navigated while panel was closed): run
background.resolveContent()withsource: "reopen"— shows"loading", then extracts fresh results - No cached results (service worker restart): run
background.resolveContent()withsource: "reconnect"— no"loading"to avoid flashing during the idle shutdown cycle
- Same table: push cached
⇩ "resultsReady" message with PipelineResults payload (cached or freshly extracted)
Side Panel
- Compare incoming results against
currentResults(bytableNumberand packet count) — skip render if identical (see Service worker shutdown cycle) - If results received with
gameState: render game page - If results received without
gameState: show help page with download enabled - If no results: remain on help page until a Full Extraction completes
Data Flow: ZIP Download
Packages current game data and a self-contained HTML summary into a downloadable ZIP file.
Triggers:
- User clicks the download button in the Side Panel
Side Panel
- Use cached
PipelineResultsfrom the last render - For supported games: generate self-contained HTML page via
render.renderFullPage()with all assets inlined as base64 data URIs - Package into ZIP via JSZip:
raw_data.json— original BGA packetsgame_log.json— structured log entries (supported games only)game_state.json— serialized game state (supported games only)summary.html— self-contained HTML (supported games only)
- For unsupported games: ZIP contains only
raw_data.json - Download as
bgaa_<tableNumber>_<moveId>.zip
Data Flow: Time Tracking
Tracks how long game table pages are open and in focus. Works across all BGA games, not only supported ones.
Session lifecycle
Background Service Worker
- On every focus-changing event (
tabs.onActivated,tabs.onUpdatedURL change,tabs.onRemoved,windows.onFocusChanged), calltimeTracker.handleFocusChange(url) SessionTrackerparses the URL viaparseGameTableUrl():- If it’s a game table URL different from the active session: end the current session, start a new one
- If it’s the same table: no-op
- If null or non-game URL: end the current session
- Completed sessions are written to
chrome.storage.localas compact tuples[gameId, tableId, from, to] - A game-name map (
gameId → gameName) is maintained alongside sessions
The open session additionally carries lastSeen (last confirmed-active timestamp) and idleSince (start of the current idle stretch, or null when active); both are dropped when the session is finalized to the [slug, tableId, from, to] tuple.
Idle, heartbeat, and recovery
Focus events alone can’t catch the cases where no event fires: the user walks away with the game tab still focused, the screen locks, the machine sleeps, or the browser is killed before it can write the session end. chrome.idle plus a chrome.alarms heartbeat close those gaps. Tuning constants live in time-tracking.ts (IDLE_DETECTION_SECONDS, IDLE_GRACE_MS, HEARTBEAT_MS, STALE_SESSION_MS).
- Idle onset (
chrome.idle.onStateChanged→"idle"/"locked", afterIDLE_DETECTION_SECONDSof no input):timeTracker.markAway()recordsidleSincebut does not end the session, and a one-shotIDLE_FINALIZE_ALARMis armed forIDLE_GRACE_MSlater. First idle wins, so the recorded onset isn’t pushed forward. - Return to activity (
"active"): the finalize alarm is cancelled, then the focused tab is re-applied viahandleFocusChange(reading the focused window’s active tab; if no Chrome window holds focus, nothing is resumed). This one path covers both cases — if the session survived (returned within grace) the same table is a no-op continuation with no break; if the grace already finalized it during the away period, a fresh session starts. So a long absence yields two separate sessions, not one stretched across the gap. - Grace elapsed while still idle (
IDLE_FINALIZE_ALARMfires):finalizeIdle()ends the session atidleSince, so the detection interval counts as play but the grace wait does not. (Leaving via a focus change while idle ends it atidleSincetoo.) - Heartbeat (
HEARTBEAT_ALARM, everyHEARTBEAT_MSwhile a session is open and the user is active):touch()advanceslastSeenso an abrupt shutdown can be bounded.syncHeartbeatAlarm()creates the alarm only while a session is active and clears it otherwise, so the service worker isn’t woken when nothing is being tracked. - Crash/quit recovery (service-worker startup):
recoverStaleSession()runs first. A session that was idle past the grace is closed atidleSince; one whoselastSeenis older thanSTALE_SESSION_MSis closed atlastSeen; a session still fresh (normal short SW restart) or idle-within-grace is left running. The startup re-apply of the focused tab is then skipped when a session survived recovery — otherwise the ~60s heartbeat-driven SW wake/restart cycle would re-runhandleFocusChangeand keep clearingidleSince, so a walk-away would never finalize. The re-apply is also gated onchrome.idle.queryState()being"active": the SW cold-restarts repeatedly while the user is away, andonStateChangedwon’t replay the already-past idle transition to a fresh instance — so a session started blindly at startup would never learn it’s idle and would be finalized as a 0-length stale session on the next restart, looping once per restart. Querying the live idle state starts a session only when the user is genuinely present; the"active"transition on their return starts it otherwise. This keeps a single session from ballooning across the entire time the browser was shut. - Zero-length guard:
appendSessiondrops any session whose end is not strictly after its start. The recovery rules above make this rare, but it cleanly absorbs residual races (open-then-immediately-leave, or a startup/crash that finalizes at the same instant it began) so history never accrues meaningless 0-duration rows.
Storage architecture (two tiers)
| Tier | Key(s) | Written when | Purpose |
|---|---|---|---|
chrome.storage.local | bgaa_time_sessions, bgaa_time_games, bgaa_time_modes (real-time), bgaa_time_types (tournament/arena/regular) | Every session end / on classification / on stats-page deletion | Primary durable store |
BGA page localStorage | bgaa_time_sessions (the CSV export string) | On backup (throttled) | Cross-reinstall backup |
BGA localStorage backup/restore
Triggered when navigating to any BGA page, throttled to once per 5 minutes. Backup/restore reuse the CSV export/import, so the backup carries everything export does — sessions, game names, real-time modes, and table types — through one serialization path.
- Restore (once per SW lifetime): if
chrome.storage.localis empty, read the backup string from BGA page localStorage andimportSessionsCsv()it. Handles the fresh-install-after-reinstall case. - Backup: write
exportSessionsCsv()to BGA page localStorage viachrome.scripting.executeScript(MAIN world).
CSV export / import
Side Panel
- User clicks Export
exportSessionsCsv()reads sessions, game map, real-time modes, and table types fromchrome.storage.local- Produces CSV with columns:
game, game_id, table_id, from, to, minutes, realtime, type.gameis the display name andgame_idthe URL slug (both stored so the round-trip is lossless);realtime(1/0/empty) andtype(tournament/arena/regular/empty) are per-table classifications. All are inlined on every session row. - Downloads as
bgaa_playtime_YYYY-MM-DD.csv
importSessionsCsv() reads columns by header name (game, game_id, table_id, from, to required; realtime/type optional), merges sessions (dedup by start timestamp), and restores the session slug, the slug→name map, and each table’s realtime mode and type (first value wins, so live data isn’t clobbered). Export→import reproduces the stored data exactly.
Session deletion
Side Panel — each finished row in the Sessions / Tables stats view exposes an X on hover (the in-progress row has none, since the live tracker would re-append it). After a window.confirm, the side panel calls:
deleteSession(from)— drops the single session whose start timestamp matches (the same key used for import dedup).deleteTableSessions(tableId)— drops every session for the table and prunes that table’s now-orphanedbgaa_time_modes/bgaa_time_typesentries.
Both rewrite bgaa_time_sessions in chrome.storage.local; the storage.onChanged listener re-renders the stats page.
Key files:
src/time-tracking.ts— types, URL parser, SessionTracker class (focus + idle/liveness lifecycle), idle/heartbeat tuning constants, sync logic, export
Message Protocol
Side Panel → Background Service Worker
| Message | Response | Purpose |
|---|---|---|
"setPinMode" | true | Set auto-hide mode (background keeps in-memory copy; sidepanel persists via localStorage) |
"pauseLive" | — | Stop live tracking |
"resumeLive" | — | Re-inject watcher on active tab |
Background Service Worker → Side Panel
| Message | Payload | Purpose |
|---|---|---|
"loading" | — | Show loading spinner |
"resultsReady" | { results: PipelineResults } | Push extraction results for rendering |
"notAGame" | — | Current tab is not a BGA game page — show help |
"gameError" | { error: string, results?: PipelineResults } | Pipeline failed — show help with error message; if results is present (raw data preserved from failed pipeline), enable download button |
"liveStatus" | { active: boolean } | Update live tracking indicator |
Content Script → Background Service Worker
| Message | Purpose |
|---|---|
"gameLogChanged" | DOM mutation detected — trigger live re-extraction |
Connection Management
The Side Panel maintains a persistent port via chrome.runtime.connect({name: "sidepanel"}). The Background Service Worker uses port connection/disconnection to track whether the Side Panel is open.
On port connect, the Background Service Worker queries the active tab and compares its table number against cached results. If they match, cached results are pushed immediately (see Side Panel Connect). If they differ (user navigated to a different table while the panel was closed) or no results are cached (e.g. after a service worker restart), a fresh extraction runs with a "loading" indicator.
Service worker shutdown cycle
Chrome terminates idle service workers after ~30 seconds of inactivity. When this happens while the Side Panel is open, a reconnect cycle occurs:
- Service worker shuts down — the port disconnects
- Side Panel schedules a “disconnected” indicator after 3 seconds
- Side Panel retries
chrome.runtime.connect()after 1 second - Reconnection wakes the service worker —
onConnectfires - Background Service Worker pushes cached
lastResultsvia"resultsReady"
This cycle repeats every ~30 seconds during idle periods. Two mechanisms prevent unnecessary re-renders and loading flicker:
Cached results on same-table reconnect: On port connect, the Background Service Worker checks whether the active tab matches cached lastResults by table number. During the idle shutdown cycle the tab hasn’t changed, so cached results are pushed directly without re-extraction or loading indicator. Only when the tab has changed (e.g. user navigated while the panel was closed) does a full re-extraction run with "loading".
Deduplication guard: the Side Panel compares incoming "resultsReady" against currentResults by tableNumber and rawData.packets.length. If both match, the render is skipped. This is the same comparison the Background Service Worker uses in Live Tracking to decide whether to push updates (only when packet count increases).
The "loading" message clears currentResults, ensuring that intentional re-extractions (e.g. page reload) always render even if the data hasn’t changed — the dedup guard only suppresses redundant renders from the idle shutdown cycle.
Event Catalog
This section describes every external event that can affect the side panel, how the background service worker detects it, and what it does in response.
There are two main handlers in the background service worker:
togglePanel— handles icon clicks and keyboard shortcuts. Opens/closes the panel and runs the initial extraction with badge animation.handleNavigation— handles all subsequent navigation events (tab switch, page load, SPA navigation, window focus). Classifies the active tab’s URL viaresolveContentand pushes the appropriate message to the side panel. When an extraction is already in progress, the tab ID is saved aspendingNavTabIdand processed when the current extraction finishes. Also checks auto-hide pin mode and closes the panel when applicable.
User actions
| Event | Chrome API | Handler | Side panel effect |
|---|---|---|---|
| Click extension icon / keyboard shortcut | chrome.action.onClicked, chrome.commands.onCommand | togglePanel — if panel is open, close it; otherwise open panel, extract, push results. Sets extracting before opening so the onConnect handler (which fires when the panel’s JS loads) skips its own extraction, avoiding a race. | Full extraction with badge animation; shows loading then results or help |
| User reloads the game page | chrome.tabs.onUpdated with status: "complete" | handleNavigation with source "navigation" — re-extracts from the reloaded page | Fresh extraction; loading shown if table changed, otherwise silent update |
| User navigates to a different page in the same tab | chrome.tabs.onUpdated — two detection modes: (1) full page load fires status: "complete"; (2) SPA navigation (BGA uses pushState) fires with url change but no status field. Both reach the same handleNavigation call. | handleNavigation — classifies the new URL and resolves content | Shows new game, help page, or auto-closes depending on URL and pin mode |
| User switches to a different tab | chrome.tabs.onActivated | handleNavigation with source "navigation" — extracts from the newly active tab | Shows the new tab’s game, help page, or auto-closes |
| User switches to a different Chrome window | chrome.windows.onFocusChanged | handleNavigation with source "focus" — queries the active tab in the focused window. Fires for the window gaining focus, regardless of whether the side panel is open there. Also ends any active time tracking session on WINDOW_ID_NONE (all windows lost focus). | Silent update (no loading indicator); shows current game or help |
| User closes a game tab | chrome.tabs.onRemoved | Ends the active time tracking session if the closed tab was the tracked tab | No side panel effect |
| User clicks help button in side panel | Side panel DOM event | Toggles between help page and game summary; sends "pauseLive" / "resumeLive" to background | Swaps view; live tracking paused while on help |
Game state changes
| Event | Chrome API | Handler | Side panel effect |
|---|---|---|---|
| Game move happens (opponent or self) | "gameLogChanged" message from watcher’s MutationObserver on #logs / #game_play_area (2s debounce) | triggerLiveExtraction — rate-limited (5s minimum interval), deferred if too soon, skipped if panel closed or extraction in progress | Re-renders only if packet count increased; silent (no loading indicator) |
Extension lifecycle
These events use the onConnect handler, which is the same code path that fires when togglePanel opens the panel. The race is avoided by the extracting flag: togglePanel sets it before opening, so when onConnect fires it sees the flag and skips its own extraction.
| Event | Chrome API | Handler | Side panel effect |
|---|---|---|---|
| Service worker restarts | Port disconnect detected by side panel; reconnects after 1s via chrome.runtime.connect | onConnect handler — pushes cached results if same table, otherwise re-extracts with source "reconnect" | No loading indicator; dedup guard skips render if data unchanged. Disconnected indicator shown after 3s if reconnect hasn’t completed |
| Side panel closes | Port onDisconnect | Sets sidePanelOpen = false, stops live tracking | N/A (panel gone) |
Filtering and deduplication
Not all events lead to a visible update. Several guards prevent unnecessary work:
extractingflag: only one extraction runs at a time; concurrent navigation events are queued viapendingNavTabId(last writer wins)tab.status !== "complete"check:handleNavigationbreaks early if the tab is still loading (waits for the subsequentstatus: "complete"event)shouldShowLoadingfilter: only"click","navigation", and"reopen"sources show the loading indicator;"focus","reconnect", and"live"sources update silently- Same-table loading suppression: even for sources that show loading, the
"loading"message is only sent when the table number differs from cached results - Packet count dedup: live tracking only pushes results when
packets.lengthincreases; the side panel independently skips re-renders when bothtableNumberandpackets.lengthmatchcurrentResults - Auto-hide:
handleNavigationchecksshouldAutoClose(url, pinMode)before extracting — if the pin mode requires it, the panel is closed and no extraction runs
Asset Resolution
Game renderers accept an asset resolver function rather than hardcoding paths:
- In extension:
chrome.runtime.getURL("assets/bga/innovation/icons/hex_5.png")produceschrome-extension://<id>/assets/bga/innovation/icons/hex_5.png - For ZIP export: resolver returns relative path
"assets/bga/...", theninlineAssets()replaces all such references with base64 data URIs
This dual-mode resolution lets the same render code serve both live display and self-contained HTML exports.