Skip to content

feat(airconsole): global highscore leaderboard#167

Open
tim4724 wants to merge 4 commits into
mainfrom
worktree-clever-toasting-micali
Open

feat(airconsole): global highscore leaderboard#167
tim4724 wants to merge 4 commits into
mainfrom
worktree-clever-toasting-micali

Conversation

@tim4724

@tim4724 tim4724 commented Jun 4, 2026

Copy link
Copy Markdown
Owner

What

Adds a global highscore leaderboard to AirConsole mode (phase 1). On game end the display stores each played player's lines to AirConsole's native High Score API and renders a global top-10 panel on the results screen.

This is phase 1 of the highscore feature. Normal mode (a server-side board + nickname-confirmation dialog) is deferred to a later phase. Score metric is lines cleared.

How it works

  • Store: for each played player, write their lines to two boards via level_version bucketing: all-time (v1-all) and current month (v1-<YYYY-MM>, UTC). playerId is the AC device_id, so getUID(playerId) attributes the score to the right account.
  • Display: the results screen becomes two-column. Left = the existing session leaderboard, now annotated with each player's world rank ("World #N"). Right = a global top-10 panel that appears only when a session player ranks in the world top 100, and auto-toggles (~6s) between the All-time / Monthly boards. The All-time/Monthly pills double as a manual override (tap to pin, stops rotation).
  • Fetch: requestHighScores(..., total=20, top=10) so the global top 10 and connected players' own context entries (their ranks.world) come back. onHighScoreStored triggers a debounced re-fetch so freshly-set bests reflect.
  • Safety: all AC calls are feature-detected, so the AC_MOCK simulator and any SDK without the High Score API no-op cleanly. Normal mode never loads this code.

Why no native weekly/monthly: AirConsole's High Score API has no time-range parameter (ranks are spatial/social only), so monthly is emulated by bucketing into level_version.

Files

  • public/display/display-airconsole.js — store / fetch / queue / auto-toggle (AC-only bootstrap)
  • public/display/DisplayUI.jsrenderGlobalLeaderboard, annotateResultWorldRanks, row data-player-id
  • public/display/index.html.results-layout + #global-leaderboard panel markup
  • public/display/display.css — two-column layout, panel/pill/row styles, responsive stacking
  • public/display/DisplayState.js — panel DOM refs
  • public/shared/i18n.js — 4 keys across 11 locales

(screen.html/controller.html are gitignored, regenerated at build via scripts/generate-airconsole-html.js.)

Testing

  • ✅ 416 unit tests pass
  • ✅ 13 AirConsole E2E tests pass (npm run test:e2e:airconsole), confirming the onGameEnd wrap doesn't break the mock path
  • ✅ Browser-verified the renderer, two-column layout, pills, and per-player rank badges
  • ✅ Subagent code review completed; blocker (request-queue state not reset between rounds) and should-fixes (debounce store storm, queue dedupe, ARIA tab roles) addressed

🤖 Generated with Claude Code

On game end, store each played player's lines to AirConsole's native High
Score API on two boards (all-time and current month) via level_version
bucketing, then fetch and render a global top-10 panel on the results
screen. The panel sits beside the session leaderboard, annotates each
player with their world rank, and auto-toggles (~6s) between the All-time
and Monthly boards a session player ranks in (world top 100). The pills
double as a manual override.

Feature-detected so the AC_MOCK path no-ops, and normal mode is untouched
(the server-side board for normal mode is deferred to a later phase).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown

Claude finished @tim4724's task in 4m 53s —— View job


Code Review

  • Gather context
  • Read changed files
  • Analyze code
  • Post review

Overall this is a well-structured feature. The request queue, debounce, and watchdog are solid, ARIA tabs are complete, and the gallery scenario gives the renderer good test coverage. Three findings worth addressing, one of which is a real bug.


Bug: watchdog not cleared in _hsPumpRequests catch block

public/display/display-airconsole.js, lines 255–266

try {
  airconsole.requestHighScores(...);
} catch (e) {
  _hsRequestInFlight = false;
  _hsPendingBoard = null;
  _hsPumpRequests();   // ← re-pumps the queue
  // ↑ but _hsPumpWatchdog was set just above the try — it's still armed
}

The watchdog is set unconditionally before requestHighScores. If the SDK throws synchronously, the catch block resets in-flight state and pumps the next request, but the original watchdog is still ticking. Ten seconds later it fires again, sets _hsRequestInFlight = false / _hsPendingBoard = null a second time, and calls _hsPumpRequests() a second time — which, if a new request happened to be in-flight by then, would corrupt the in-flight flag.

Fix: clear _hsPumpWatchdog at the top of the catch block, matching what onHighScores does.

} catch (e) {
  if (_hsPumpWatchdog) { clearTimeout(_hsPumpWatchdog); _hsPumpWatchdog = null; }
  _hsRequestInFlight = false;
  _hsPendingBoard = null;
  _hsPumpRequests();
}

Fix this →


Low: empty-UID entries share dedup key ''

public/display/display-airconsole.js, line 334

var firstUid = euids[0] || '';
if (!seenUid[firstUid]) {
  seenUid[firstUid] = true;
  // ...
  top.push({ ... });
}

If two leaderboard entries have no UIDs at all, both have firstUid = '', so only the first is added to top and the rest are silently dropped. In practice the AirConsole API always returns UIDs for stored scores, but a defensive skip (e.g. if (!firstUid) continue; before the seenUid check, or guard on euids.length) would make the intent explicit and prevent a surprise if the API ever returns a UID-less entry.

Fix this →


Low: stale onHighScores can briefly corrupt cache after rapid Play Again

public/display/display-airconsole.js, lines 282–294

acStoreHighScores resets _hsCache = {} and _hsPlayerUids = {} immediately, then queues fresh requests. But an in-flight request from the previous round may still deliver its onHighScores callback after this reset. When it does:

  • uidToPlayer is empty (because _hsPlayerUids was cleared), so qualifies is false
  • _hsCache[key] is overwritten with "no qualifying players" data
  • _hsRefreshDisplay() calls hideGlobalLeaderboard()

The leaderboard disappears until the new round's responses arrive. In real gameplay games take several minutes and API responses take < 1s, so this window is essentially zero — it's a concern only for the test harness / debug scenarios where a round can end immediately. Still worth tracking since roomState !== RESULTS in _hsRefreshDisplay protects the inter-round gap but not the case where a new round also reaches RESULTS quickly.

One option: increment a small _hsRound counter in acStoreHighScores and close over it in the requestHighScores call + onHighScores dispatch; discard callbacks whose generation doesn't match the current counter.


Nit: heading order inside the panel

public/display/index.html, lines 198–203

<div class="global-leaderboard__tabs" role="tablist">…tabs…</div>
<h3 class="global-leaderboard__heading">Global Top 10</h3>
<ol id="global-leaderboard-list" role="tabpanel" ></ol>

The <h3> follows the tab row visually but precedes the <ol> in DOM order — screen readers would announce "Global Top 10" after the tabs but before the list content, which is fine. Purely a cosmetic note: if the heading is meant to label the panel as a whole (not the tabpanel), placing it before the tablist would read more naturally to AT users. No action needed if the current layout is intentional.


Positive observations

  • The request queue + watchdog design handles both SDK-throws and silent network failures cleanly.
  • acStoreHighScores resetting the entire pipeline state (queue, in-flight, timers, cache) on each new round is the right call — prevents stale-flag stranding across Play Again.
  • _hsToList normalizing both array and pipe-string forms is a good forward-compatibility hedge.
  • ARIA tab pattern (role=tablist, role=tab, aria-selected, aria-controls, role=tabpanel, aria-labelledby) is complete and updated correctly in renderGlobalLeaderboard.
  • !(r.lines > 0) for the zero-lines skip is intentionally NaN-safe (vs. r.lines <= 0).
  • The gallery airconsole-results scenario with interleaved session players at odd ranks gives the colorIndex tinting real coverage.

…w fixes

The leaderboard never appeared because the AirConsole SDK returns
`uids`/`nicknames` as arrays, not the pipe-joined strings the typedef
documents. The code split on "|", matched no players (so qualifies was
always false and the panel stayed hidden) and fell every name back to the
"Player" default. Normalize both array and string forms.

Also addresses code-review findings:
- skip storing 0-line results (instant topouts) so they don't create
  "0 lines" board entries
- watchdog clears the request in-flight flag if onHighScores never fires,
  so a network blip can't permanently stall the panel
- complete the ARIA tab pattern (aria-controls, role=tabpanel,
  aria-labelledby) and add a .gl-tab focus-visible ring
- drop the redundant window.airconsole guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the boxed look: the global panel loses its container box, the rows
lose their per-row cards (now hairline-separated), and the All-time/Monthly
tabs become flat text with an accent underline instead of filled pills. The
panel is set off from the session list by a single divider rule (left in
two-column, top when stacked), keeping it flat and in the existing style.

Add a "Results (AirConsole)" gallery scene that injects results and
populates the global top-10 panel (session players interleaved at world
ranks so the per-row badges render) so the leaderboard is covered by the
gallery UI surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the global Top 10 rows use the same card treatment as the session
leaderboard (filled bg-card with border/radius, compact) so the two columns
read as one component instead of a flat list beside cards.

Replace the per-session-row "World #N" badge with color: a session player's
entry in the global board is tinted in their player color (rank + name),
mirroring the session cards, so they spot themselves at a glance. Removes
annotateResultWorldRanks, the result-row data-player-id, the
.result-world-rank style, and the now-unused leaderboard_world_rank string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant