diff --git a/frontend/src/styles/rbh/spectator-screen.scss b/frontend/src/styles/rbh/spectator-screen.scss
index 99bf1fbaf224..780f564c75ec 100644
--- a/frontend/src/styles/rbh/spectator-screen.scss
+++ b/frontend/src/styles/rbh/spectator-screen.scss
@@ -40,19 +40,92 @@
}
}
+ // ============================================================
+ // COUNTDOWN OVERLAY
+ // ============================================================
+ .countdownOverlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(6px);
+ opacity: 1;
+ transition: opacity 0.4s ease-out;
+
+ &.hidden {
+ display: none !important;
+ }
+
+ &.fade-out {
+ opacity: 0;
+ }
+
+ &.fade-in {
+ opacity: 0;
+ }
+
+ .countdownContent {
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ }
+
+ .countdownLabel {
+ font-size: 2rem;
+ font-weight: 500;
+ color: var(--sub-color);
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ }
+
+ .countdownNumber {
+ font-size: 15rem;
+ font-weight: 900;
+ color: var(--main-color);
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ text-shadow: 0 0 60px
+ color-mix(in srgb, var(--main-color) 40%, transparent);
+ animation: countdownPulse 1s ease-in-out infinite;
+ }
+
+ @keyframes countdownPulse {
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+ 50% {
+ transform: scale(1.05);
+ opacity: 0.9;
+ }
+ }
+ }
+
// ============================================================
// LEADERBOARD VIEW
// ============================================================
.leaderboardView {
+ height: 100%;
+
.content {
- display: grid;
- grid-template-columns: 1fr;
+ display: flex;
+ flex-direction: column;
gap: 2rem;
height: 100%;
.bigtitle {
font-size: 2em;
margin-bottom: 1em;
+ flex-shrink: 0;
.text {
color: var(--main-color);
}
@@ -61,6 +134,11 @@
.tableAndUser {
font-size: 1rem;
width: 100%;
+ // Allow this container to shrink so overflow works inside
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
& > .divider {
height: 0.25rem;
@@ -68,6 +146,7 @@
background: var(--sub-alt-color);
border-radius: calc(var(--roundness) / 2);
margin-bottom: 2em;
+ flex-shrink: 0;
}
.leaderboardTable {
@@ -75,6 +154,9 @@
display: flex;
flex-direction: column;
gap: 0.5rem;
+ // Fill remaining space and allow children to overflow
+ flex: 1;
+ min-height: 0;
.leaderboardHeader,
.leaderboardRow {
@@ -122,6 +204,7 @@
font-size: 0.75em;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--sub-alt-color);
+ flex-shrink: 0;
}
.leaderboardBody {
@@ -129,6 +212,40 @@
flex-direction: column;
gap: 0.5rem;
width: 100%;
+ // Enable vertical scrolling for large leaderboards
+ overflow-y: auto;
+ // Fill remaining space but don't exceed it
+ flex: 1;
+ min-height: 0;
+ // Small bottom padding so last row isn't flush against edge
+ padding-bottom: 0.5rem;
+
+ // Subtle custom scrollbar for dark theme
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ margin: 0.25rem 0;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 3px;
+ transition: background 0.2s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.25);
+ }
+
+ &:active {
+ background: var(--main-color);
+ }
+ }
.leaderboardRow {
background: linear-gradient(
@@ -146,6 +263,8 @@
transition:
background 0.3s ease,
border-color 0.3s ease;
+ // Prevent rows from shrinking when container is constrained
+ flex-shrink: 0;
&:hover {
background: linear-gradient(
@@ -156,6 +275,41 @@
border-color: rgba(255, 255, 255, 0.1);
}
+ &.tier-podium {
+ border-color: color-mix(
+ in srgb,
+ var(--main-color),
+ transparent 55%
+ );
+ background: linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--main-color), transparent 88%) 0%,
+ rgba(255, 255, 255, 0.04) 100%
+ );
+ }
+
+ &.tier-contender {
+ border-left: 3px solid
+ color-mix(in srgb, var(--main-color), transparent 35%);
+ }
+
+ &.placeholder-summary {
+ opacity: 0.9;
+ border-style: dashed;
+ border-color: rgba(255, 255, 255, 0.2);
+ background: linear-gradient(
+ 135deg,
+ rgba(255, 255, 255, 0.02) 0%,
+ rgba(255, 255, 255, 0.01) 100%
+ );
+
+ .placeholder-count {
+ font-weight: 700;
+ color: var(--main-color);
+ margin-right: 0.4rem;
+ }
+ }
+
.avatarNameBadge {
display: flex;
gap: 0.5em;
diff --git a/frontend/src/styles/tribe.scss b/frontend/src/styles/tribe.scss
index 13e9490f5e1c..ad5efc2f01cb 100644
--- a/frontend/src/styles/tribe.scss
+++ b/frontend/src/styles/tribe.scss
@@ -1,8 +1,3 @@
-#tribeStateDisplay {
- position: fixed;
- z-index: 999999999999999;
-}
-
.pageTribe {
height: 100%;
display: grid;
@@ -723,6 +718,13 @@
&.faded {
opacity: 0.25;
}
+ &.dnf {
+ opacity: 0.35;
+ color: var(--sub-color);
+ .pos {
+ color: var(--sub-color);
+ }
+ }
.progressAndGraph {
width: 25%;
}
@@ -1026,17 +1028,6 @@
margin-bottom: 0.5rem;
}
}
-
- a {
- color: var(--main-color);
- text-decoration: underline;
- }
- }
-
- .eulaFooter {
- color: var(--sub-color);
- font-size: 0.85rem;
- text-align: center;
}
.eulaCountdown {
@@ -1204,6 +1195,18 @@ body.duelFlowActive {
#bannerCenter {
display: none !important;
}
+
+ // Hide all ads
+ .ad,
+ .advertisement,
+ #ad-vertical-left-wrapper,
+ #ad-vertical-right-wrapper,
+ #ad-footer-wrapper,
+ #ad-footer-small-wrapper,
+ #ad-result-wrapper,
+ #ad-result-small-wrapper {
+ display: none !important;
+ }
}
// Suppress qsr dev warning globally (fires before duelFlowActive is set)
diff --git a/frontend/src/ts/event-handlers/global.ts b/frontend/src/ts/event-handlers/global.ts
index e093b3936a25..f61f8adae27e 100644
--- a/frontend/src/ts/event-handlers/global.ts
+++ b/frontend/src/ts/event-handlers/global.ts
@@ -15,12 +15,41 @@ import * as TestState from "../test/test-state";
import * as TribeState from "../tribe/tribe-state";
import { isAnyChatSuggestionVisible } from "../tribe/tribe-chat";
import * as Tribe from "../tribe/tribe";
+import * as DuelState from "../tribe/duel/duel-state";
document.addEventListener("keydown", async (e) => {
if (PageTransition.get()) return;
if (e.key === undefined) return;
- const pageTestActive: boolean = ActivePage.get() === "test";
+ const activePage = ActivePage.get();
+ const pageTestActive: boolean = activePage === "test";
+ const duelState = DuelState.getFlowState();
+ const duelHotkeysLocked =
+ (activePage === "test" ||
+ activePage === "tribe" ||
+ activePage === "waiting") &&
+ duelState !== "SYSTEM_SELECT" &&
+ duelState !== "OTP";
+
+ if (
+ duelHotkeysLocked &&
+ !isInputElementFocused() &&
+ (e.key === "Tab" || e.key === "Enter" || e.key === "Escape")
+ ) {
+ e.preventDefault();
+ return;
+ }
+
+ if (
+ duelHotkeysLocked &&
+ e.key.toLowerCase() === "p" &&
+ (e.metaKey || e.ctrlKey) &&
+ e.shiftKey
+ ) {
+ e.preventDefault();
+ return;
+ }
+
if (pageTestActive && !TestState.resultVisible && !isInputElementFocused()) {
const popupVisible: boolean = Misc.isAnyPopupVisible();
// this is nested because isAnyPopupVisible is a bit expensive
diff --git a/frontend/src/ts/pages/rbh/spectator-screen.ts b/frontend/src/ts/pages/rbh/spectator-screen.ts
index 7b5886aaa61c..5983f73caa6e 100644
--- a/frontend/src/ts/pages/rbh/spectator-screen.ts
+++ b/frontend/src/ts/pages/rbh/spectator-screen.ts
@@ -1,10 +1,13 @@
import Page from "../../pages/page";
import { qs, ElementWithUtils, createElementWithUtils } from "../../utils/dom";
import { addToGlobal } from "../../utils/misc";
+import { getTribesServerUrl } from "../../utils/tribe";
+import { io, type Socket } from "socket.io-client";
// ============ TYPES ============
type LeaderboardEntry = {
+ id: string;
name: string;
wpm: number;
acc: number;
@@ -26,21 +29,66 @@ type DuelState = {
maxTime: number;
};
+type DuelSpectatorSide = {
+ id: string;
+ name: string;
+ wpm: number;
+ connected: boolean;
+};
+
+type DuelSpectatorPayload = {
+ serverTime: number;
+ roomId: string | null;
+ roomState: string | null;
+ active: boolean;
+ race: {
+ startAt: number | null;
+ duration: number;
+ };
+ sides: {
+ L: DuelSpectatorSide | null;
+ R: DuelSpectatorSide | null;
+ };
+};
+
+type DuelSpectatorSubscribeResponse = {
+ ok: boolean;
+ state: DuelSpectatorPayload;
+ leaderboard: unknown;
+};
+
type ViewType = "leaderboard" | "duel";
// ============ CONFIGURATION ============
-const USE_DUMMY_DATA = true;
-const DUMMY_DATA_PATH = "/data/dummy-participants.json";
-const PROD_DATA_PATH = "/data/participants.json";
+const DUEL_LEADERBOARD_ENDPOINT = "/duel/leaderboard";
+const DUEL_SPECTATOR_ENDPOINT = "/duel/spectator";
+const LEADERBOARD_POLL_INTERVAL_MS = 1000;
+const DUEL_STATE_POLL_INTERVAL_MS = 250;
+const SPECTATOR_SUBSCRIBE_TIMEOUT_MS = 4000;
+const DUEL_CLOCK_TICK_MS = 100;
+const FALLBACK_DATA_PATH = "/data/dummy-participants.json";
+const DEFAULT_LEFT_NAME = "System Left";
+const DEFAULT_RIGHT_NAME = "System Right";
// ============ STATE ============
// Current view
-// let currentView: ViewType = "leaderboard";
-let currentView: ViewType = "duel";
+let currentView: ViewType = "leaderboard";
// Leaderboard state
let entries: LeaderboardEntry[] = [];
+let leaderboardPollInterval: ReturnType
| undefined;
+let leaderboardFetchInFlight = false;
+let hasWarnedLeaderboardFetch = false;
+let duelStatePollInterval: ReturnType | undefined;
+let duelStateFetchInFlight = false;
+let hasWarnedDuelStateFetch = false;
+let duelClockInterval: ReturnType | undefined;
+let duelClockStartAt: number | null = null;
+let duelClockDuration = 30;
+let duelServerOffset = 0;
+let spectatorSocket: Socket | null = null;
+let spectatorSubscribeTimeout: ReturnType | undefined;
// Duel state
const duelState: DuelState = {
@@ -52,6 +100,7 @@ const duelState: DuelState = {
// DOM cache
let pageElement: ElementWithUtils | null = null;
+let placeholderSummaryEl: ElementWithUtils | null = null;
// ============ HELPERS ============
@@ -60,6 +109,561 @@ function getPageElement(): ElementWithUtils | null {
return pageElement;
}
+function isFiniteNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function getDuelLeaderboardUrl(): string {
+ return `${getTribesServerUrl()}${DUEL_LEADERBOARD_ENDPOINT}`;
+}
+
+function getDuelSpectatorUrl(): string {
+ return `${getTribesServerUrl()}${DUEL_SPECTATOR_ENDPOINT}`;
+}
+
+function normalizeLeaderboardEntry(
+ value: unknown,
+ fallbackId?: string,
+): LeaderboardEntry | null {
+ if (value === null || value === undefined || typeof value !== "object") {
+ return null;
+ }
+
+ const entry = value as Record;
+ const id = entry["id"];
+ const name = entry["name"];
+ const wpm = entry["wpm"];
+ const acc = entry["acc"];
+ const raw = entry["raw"];
+ const consistency = entry["consistency"];
+ const date = entry["date"];
+
+ if (
+ typeof name !== "string" ||
+ name.trim().length === 0 ||
+ !isFiniteNumber(wpm) ||
+ !isFiniteNumber(acc) ||
+ !isFiniteNumber(raw) ||
+ !isFiniteNumber(consistency)
+ ) {
+ return null;
+ }
+
+ const resolvedId =
+ typeof id === "string" && id.trim().length > 0
+ ? id.trim()
+ : (fallbackId ?? name.trim());
+
+ return {
+ id: resolvedId,
+ name: name.trim(),
+ wpm,
+ acc,
+ raw,
+ consistency,
+ date: isFiniteNumber(date) ? date : Date.now(),
+ };
+}
+
+function parseLeaderboardPayload(payload: unknown): LeaderboardEntry[] {
+ const parsed: LeaderboardEntry[] = [];
+
+ if (Array.isArray(payload)) {
+ for (const item of payload) {
+ const normalized = normalizeLeaderboardEntry(item);
+ if (normalized) {
+ parsed.push(normalized);
+ }
+ }
+ return parsed;
+ }
+
+ if (
+ payload !== null &&
+ payload !== undefined &&
+ typeof payload === "object"
+ ) {
+ for (const [id, value] of Object.entries(payload)) {
+ const normalized = normalizeLeaderboardEntry(value, id);
+ if (normalized) {
+ parsed.push(normalized);
+ }
+ }
+ return parsed;
+ }
+
+ return parsed;
+}
+
+function isSameEntry(a: LeaderboardEntry, b: LeaderboardEntry): boolean {
+ return (
+ a.id === b.id &&
+ a.name === b.name &&
+ a.wpm === b.wpm &&
+ a.acc === b.acc &&
+ a.raw === b.raw &&
+ a.consistency === b.consistency &&
+ a.date === b.date
+ );
+}
+
+function splitLeaderboardEntries(sortedEntries: LeaderboardEntry[]): {
+ activeEntries: LeaderboardEntry[];
+ placeholderCount: number;
+} {
+ const activeEntries: LeaderboardEntry[] = [];
+ let placeholderCount = 0;
+
+ for (const entry of sortedEntries) {
+ if (entry.wpm === -1) {
+ placeholderCount++;
+ continue;
+ }
+ activeEntries.push(entry);
+ }
+
+ return { activeEntries, placeholderCount };
+}
+
+function reconcileLeaderboard(nextEntries: LeaderboardEntry[]): void {
+ const container = getContainer();
+ if (container === null) return;
+
+ const sortedNext = [...nextEntries].sort(leaderboardSort);
+ const { activeEntries, placeholderCount } =
+ splitLeaderboardEntries(sortedNext);
+
+ // Fast path: if we have no cached state at all, do a full init
+ if (rowNodeMap.size === 0 && entryCache.size === 0) {
+ init(sortedNext);
+ return;
+ }
+
+ const nextIdSet = new Set();
+ for (const entry of activeEntries) {
+ nextIdSet.add(entry.id);
+ }
+
+ // --- Remove stale entries (present in old set but not in new set) ---
+ const staleIds: string[] = [];
+ for (const [id, row] of rowNodeMap) {
+ if (!nextIdSet.has(id)) {
+ staleIds.push(id);
+ row.remove();
+ }
+ }
+ for (const id of staleIds) {
+ rowNodeMap.delete(id);
+ entryCache.delete(id);
+ }
+
+ // --- Add new entries & patch changed entries ---
+ for (let i = 0; i < activeEntries.length; i++) {
+ const entry = activeEntries[i];
+ if (!entry) continue;
+ let row = rowNodeMap.get(entry.id);
+
+ if (row === undefined) {
+ // Brand new entry: create DOM node
+ row = createRow(entry, i + 1);
+ container.append(row);
+ rowNodeMap.set(entry.id, row);
+ entryCache.set(entry.id, { ...entry });
+ } else {
+ // Existing entry: patch only changed cells
+ const cached = entryCache.get(entry.id);
+ if (!cached || !isSameEntry(cached, entry)) {
+ updateRowContent(row, entry, i + 1);
+ entryCache.set(entry.id, { ...entry });
+ } else {
+ // Data identical -- but rank might have shifted
+ const rankEl = row.native.children[0];
+ const rankStr = String(i + 1);
+ if (rankEl && rankEl.textContent !== rankStr) {
+ rankEl.textContent = rankStr;
+ }
+ applyTierClass(row, i + 1, false);
+ }
+ }
+ }
+
+ // Update the authoritative entries array
+ entries = sortedNext;
+ updatePlaceholderSummary(container, placeholderCount);
+
+ // Reorder DOM to match new sort order (with bounded FLIP animations)
+ reorderAndAnimate(container, activeEntries);
+}
+
+async function fetchLeaderboardFromServer(): Promise<
+ LeaderboardEntry[] | null
+> {
+ const response = await fetch(getDuelLeaderboardUrl(), {
+ cache: "no-store",
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const payload: unknown = await response.json();
+ return parseLeaderboardPayload(payload);
+}
+
+async function fetchFallbackLeaderboard(): Promise {
+ const response = await fetch(FALLBACK_DATA_PATH, {
+ cache: "no-store",
+ });
+ if (!response.ok) return [];
+ const payload: unknown = await response.json();
+ return parseLeaderboardPayload(payload);
+}
+
+async function syncLeaderboardFromServer(): Promise {
+ if (leaderboardFetchInFlight) return false;
+ leaderboardFetchInFlight = true;
+
+ try {
+ const nextEntries = await fetchLeaderboardFromServer();
+ if (nextEntries !== null) {
+ reconcileLeaderboard(nextEntries);
+ }
+ hasWarnedLeaderboardFetch = false;
+ return true;
+ } catch (error) {
+ if (!hasWarnedLeaderboardFetch) {
+ console.warn(
+ "[SpectatorScreen] Failed to fetch duel leaderboard:",
+ error,
+ );
+ hasWarnedLeaderboardFetch = true;
+ }
+ return false;
+ } finally {
+ leaderboardFetchInFlight = false;
+ }
+}
+
+function startLeaderboardPolling(): void {
+ stopLeaderboardPolling();
+ leaderboardPollInterval = setInterval(() => {
+ void syncLeaderboardFromServer();
+ }, LEADERBOARD_POLL_INTERVAL_MS);
+}
+
+function stopLeaderboardPolling(): void {
+ if (leaderboardPollInterval) {
+ clearInterval(leaderboardPollInterval);
+ leaderboardPollInterval = undefined;
+ }
+}
+
+function normalizeSpectatorSide(
+ value: unknown,
+ fallbackName: string,
+): DuelSpectatorSide | null {
+ if (value === null || value === undefined || typeof value !== "object") {
+ return null;
+ }
+
+ const side = value as Record;
+ const id = side["id"];
+ const name = side["name"];
+ const wpm = side["wpm"];
+ const connected = side["connected"];
+
+ if (typeof id !== "string" || id.trim().length === 0) return null;
+
+ return {
+ id: id.trim(),
+ name:
+ typeof name === "string" && name.trim().length > 0
+ ? name.trim()
+ : fallbackName,
+ wpm: isFiniteNumber(wpm) ? wpm : 0,
+ connected: typeof connected === "boolean" ? connected : true,
+ };
+}
+
+function parseDuelSpectatorPayload(
+ payload: unknown,
+): DuelSpectatorPayload | null {
+ if (
+ payload === null ||
+ payload === undefined ||
+ typeof payload !== "object"
+ ) {
+ return null;
+ }
+
+ const record = payload as Record;
+ const serverTime = record["serverTime"];
+ const active = record["active"];
+ const race = record["race"];
+ const sides = record["sides"];
+ const roomId = record["roomId"];
+ const roomState = record["roomState"];
+
+ if (!isFiniteNumber(serverTime) || typeof active !== "boolean") {
+ return null;
+ }
+
+ if (race === null || race === undefined || typeof race !== "object") {
+ return null;
+ }
+ if (sides === null || sides === undefined || typeof sides !== "object") {
+ return null;
+ }
+
+ const raceRecord = race as Record;
+ const startAtRaw = raceRecord["startAt"];
+ const durationRaw = raceRecord["duration"];
+
+ const sidesRecord = sides as Record;
+ const left = normalizeSpectatorSide(sidesRecord["L"], DEFAULT_LEFT_NAME);
+ const right = normalizeSpectatorSide(sidesRecord["R"], DEFAULT_RIGHT_NAME);
+
+ return {
+ serverTime,
+ roomId: typeof roomId === "string" ? roomId : null,
+ roomState: typeof roomState === "string" ? roomState : null,
+ active,
+ race: {
+ startAt: isFiniteNumber(startAtRaw) ? startAtRaw : null,
+ duration:
+ isFiniteNumber(durationRaw) && durationRaw > 0 ? durationRaw : 30,
+ },
+ sides: {
+ L: left,
+ R: right,
+ },
+ };
+}
+
+function stopDuelClock(): void {
+ if (duelClockInterval) {
+ clearInterval(duelClockInterval);
+ duelClockInterval = undefined;
+ }
+}
+
+function resetDuelClock(): void {
+ stopDuelClock();
+ duelClockStartAt = null;
+ duelClockDuration = 30;
+ duelServerOffset = 0;
+}
+
+function getSyncedServerNow(): number {
+ return Date.now() + duelServerOffset;
+}
+
+function tickDuelClock(): void {
+ if (duelClockStartAt === null) {
+ updateTimer(duelClockDuration, duelClockDuration);
+ return;
+ }
+
+ const elapsed = Math.max(0, (getSyncedServerNow() - duelClockStartAt) / 1000);
+ const timeLeft = Math.max(0, duelClockDuration - elapsed);
+ updateTimer(timeLeft, duelClockDuration);
+}
+
+function syncDuelClock(
+ startAt: number | null,
+ duration: number,
+ serverTime: number,
+): void {
+ duelClockDuration = duration > 0 ? duration : 30;
+ duelServerOffset = serverTime - Date.now();
+ duelClockStartAt = startAt;
+
+ tickDuelClock();
+
+ if (duelClockStartAt === null) {
+ stopDuelClock();
+ return;
+ }
+
+ duelClockInterval ??= setInterval(() => {
+ tickDuelClock();
+ }, DUEL_CLOCK_TICK_MS);
+}
+
+function applyDuelSpectatorState(state: DuelSpectatorPayload): void {
+ const left = state.sides.L;
+ const right = state.sides.R;
+
+ updatePlayer1({
+ name: left?.name ?? DEFAULT_LEFT_NAME,
+ wpm: left?.wpm ?? 0,
+ isConnected: left?.connected ?? false,
+ });
+ updatePlayer2({
+ name: right?.name ?? DEFAULT_RIGHT_NAME,
+ wpm: right?.wpm ?? 0,
+ isConnected: right?.connected ?? false,
+ });
+
+ syncDuelClock(state.race.startAt, state.race.duration, state.serverTime);
+
+ if (state.active) {
+ void showDuel();
+ } else {
+ void showLeaderboard();
+ }
+}
+
+async function fetchDuelStateFromServer(): Promise {
+ const response = await fetch(getDuelSpectatorUrl(), {
+ cache: "no-store",
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const payload: unknown = await response.json();
+ return parseDuelSpectatorPayload(payload);
+}
+
+async function syncDuelStateFromServer(): Promise {
+ if (duelStateFetchInFlight) return false;
+ duelStateFetchInFlight = true;
+
+ try {
+ const payload = await fetchDuelStateFromServer();
+ hasWarnedDuelStateFetch = false;
+ if (!payload) return false;
+ applyDuelSpectatorState(payload);
+ return true;
+ } catch (error) {
+ if (!hasWarnedDuelStateFetch) {
+ console.warn("[SpectatorScreen] Failed to fetch duel live state:", error);
+ hasWarnedDuelStateFetch = true;
+ }
+ return false;
+ } finally {
+ duelStateFetchInFlight = false;
+ }
+}
+
+function startDuelStatePolling(): void {
+ stopDuelStatePolling();
+ duelStatePollInterval = setInterval(() => {
+ void syncDuelStateFromServer();
+ }, DUEL_STATE_POLL_INTERVAL_MS);
+}
+
+function stopDuelStatePolling(): void {
+ if (duelStatePollInterval) {
+ clearInterval(duelStatePollInterval);
+ duelStatePollInterval = undefined;
+ }
+}
+
+function startFallbackPolling(): void {
+ startDuelStatePolling();
+ startLeaderboardPolling();
+}
+
+function stopFallbackPolling(): void {
+ stopDuelStatePolling();
+ stopLeaderboardPolling();
+}
+
+function clearSpectatorSubscribeTimeout(): void {
+ if (!spectatorSubscribeTimeout) return;
+ clearTimeout(spectatorSubscribeTimeout);
+ spectatorSubscribeTimeout = undefined;
+}
+
+function onSpectatorStatePushed(payload: unknown): void {
+ const parsed = parseDuelSpectatorPayload(payload);
+ if (!parsed) return;
+ applyDuelSpectatorState(parsed);
+}
+
+function onLeaderboardPushed(payload: unknown): void {
+ const parsed = parseLeaderboardPayload(payload);
+ reconcileLeaderboard(parsed);
+}
+
+function subscribeSpectatorFeed(): void {
+ if (!spectatorSocket || !spectatorSocket.connected) return;
+
+ clearSpectatorSubscribeTimeout();
+ spectatorSubscribeTimeout = setTimeout(() => {
+ startFallbackPolling();
+ console.warn(
+ `[SpectatorScreen] duel_spectator_subscribe timeout (${SPECTATOR_SUBSCRIBE_TIMEOUT_MS}ms), using HTTP fallback`,
+ );
+ }, SPECTATOR_SUBSCRIBE_TIMEOUT_MS);
+
+ spectatorSocket.emit(
+ "duel_spectator_subscribe",
+ (response: DuelSpectatorSubscribeResponse) => {
+ clearSpectatorSubscribeTimeout();
+
+ if (!response.ok) {
+ startFallbackPolling();
+ return;
+ }
+
+ stopFallbackPolling();
+ const parsedState = parseDuelSpectatorPayload(response.state);
+ if (parsedState) {
+ applyDuelSpectatorState(parsedState);
+ }
+ onLeaderboardPushed(response.leaderboard);
+ },
+ );
+}
+
+function setupSpectatorPush(): void {
+ teardownSpectatorPush();
+
+ spectatorSocket = io(getTribesServerUrl(), {
+ autoConnect: true,
+ reconnection: true,
+ reconnectionAttempts: Infinity,
+ query: {
+ name: "Spectator",
+ },
+ });
+
+ spectatorSocket.on("connect", () => {
+ subscribeSpectatorFeed();
+ });
+
+ spectatorSocket.on("duel_spectator_state", (payload: unknown) => {
+ onSpectatorStatePushed(payload);
+ });
+
+ spectatorSocket.on("duel_leaderboard_snapshot", (payload: unknown) => {
+ onLeaderboardPushed(payload);
+ });
+
+ spectatorSocket.on("disconnect", () => {
+ startFallbackPolling();
+ });
+
+ spectatorSocket.on("connect_error", (error: Error) => {
+ startFallbackPolling();
+ console.warn("[SpectatorScreen] Spectator socket connect error:", error);
+ });
+}
+
+function teardownSpectatorPush(): void {
+ clearSpectatorSubscribeTimeout();
+
+ if (!spectatorSocket) return;
+
+ if (spectatorSocket.connected) {
+ spectatorSocket.emit("duel_spectator_unsubscribe");
+ }
+ spectatorSocket.removeAllListeners();
+ spectatorSocket.disconnect();
+ spectatorSocket = null;
+}
+
// ============ VIEW SWITCHING ============
const TRANSITION_DURATION = 600; // ms
@@ -108,26 +712,49 @@ export async function showDuel(): Promise {
const leaderboardView = page.qs("#leaderboardView");
const duelView = page.qs("#duelView");
+ const countdownOverlay = page.qs("#countdownOverlay");
+ const countdownNumber = page.qs("#countdownNumber");
- if (!leaderboardView || !duelView) return;
+ if (!leaderboardView || !duelView || !countdownOverlay || !countdownNumber) {
+ return;
+ }
isTransitioning = true;
- // Fade out + blur current view
+ // Fade out leaderboard
leaderboardView.addClass("transitioning-out");
-
await new Promise((resolve) => setTimeout(resolve, TRANSITION_DURATION));
leaderboardView.addClass("hidden");
leaderboardView.removeClass("transitioning-out");
- // Show and fade in new view
+ // Show duel view blurred behind countdown
duelView.removeClass("hidden");
+ duelView.setStyle({ filter: "blur(24px)", opacity: "0.5" });
+
+ // Show countdown overlay with fade-in
+ countdownOverlay.addClass("fade-in");
+ countdownOverlay.removeClass("hidden");
+ void countdownOverlay.native.offsetHeight; // Force reflow
+ countdownOverlay.removeClass("fade-in");
+
+ // Run 10-second countdown
+ for (let i = 10; i >= 1; i--) {
+ countdownNumber.setText(i.toString());
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+
+ // Fade out countdown overlay
+ countdownOverlay.addClass("fade-out");
+ await new Promise((resolve) => setTimeout(resolve, 400));
+ countdownOverlay.addClass("hidden");
+ countdownOverlay.removeClass("fade-out");
+
+ // Reveal duel view (remove blur)
+ duelView.setStyle({ filter: "blur(0)", opacity: "1" });
duelView.addClass("transitioning-in");
- // Force reflow before removing transition class
void duelView.native.offsetHeight;
-
await new Promise((resolve) => setTimeout(resolve, 50));
duelView.removeClass("transitioning-in");
@@ -141,32 +768,88 @@ export function getCurrentView(): ViewType {
// ============ LEADERBOARD FUNCTIONS ============
-function createRow(entry: LeaderboardEntry, rank: number): ElementWithUtils {
- const row = createElementWithUtils("div", {
- classList: ["leaderboardRow"],
- dataset: { name: entry.name },
+// --- Optimization caches ---
+// Maps entry.id -> the live DOM node for that row (avoids re-querying)
+const rowNodeMap = new Map();
+// Maps entry.id -> the last-rendered data snapshot (avoids redundant DOM writes)
+const entryCache = new Map();
+// Cached container reference (avoids re-querying #leaderboardBody every tick)
+let cachedContainer: ElementWithUtils | null = null;
+
+// FLIP animation threshold: only animate position changes for the top N rows.
+// Rows beyond this index skip getBoundingClientRect entirely.
+const FLIP_ANIMATION_LIMIT = 20;
+
+function getContainer(): ElementWithUtils | null {
+ cachedContainer ??= qs("#leaderboardBody");
+ return cachedContainer;
+}
+
+function clearCaches(): void {
+ rowNodeMap.clear();
+ entryCache.clear();
+ placeholderSummaryEl = null;
+}
+
+// --- Formatting helpers (pure, no DOM) ---
+
+function formatStat(
+ val: number,
+ isPlaceholder: boolean,
+ isPct: boolean = false,
+): string {
+ if (isPlaceholder) return "-";
+ return isPct ? Math.floor(val) + "%" : String(Math.round(val));
+}
+
+function formatFloat(
+ val: number,
+ isPlaceholder: boolean,
+ isPct: boolean = false,
+): string {
+ if (isPlaceholder) return "-";
+ return isPct ? val.toFixed(2) + "%" : val.toFixed(2);
+}
+
+function formatDate(date: number, isPlaceholder: boolean): string {
+ if (isPlaceholder || date <= 0) return "-";
+ return new Date(date).toLocaleDateString("en-GB", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
});
+}
- const isPlaceholder = entry.wpm === -1;
+const TIER_CLASSES = [
+ "tier-podium",
+ "tier-contender",
+ "tier-field",
+ "tier-placeholder",
+];
- const formatStat = (val: number, isPct: boolean = false): string | number => {
- if (isPlaceholder) return "-";
- return isPct ? Math.floor(val) + "%" : Math.round(val);
- };
+function setTextIfChanged(target: Element | null, value: string): void {
+ if (target && target.textContent !== value) {
+ target.textContent = value;
+ }
+}
- const formatFloat = (val: number, isPct: boolean = false): string => {
- if (isPlaceholder) return "-";
- return isPct ? val.toFixed(2) + "%" : val.toFixed(2);
- };
+function applyTierClass(
+ row: ElementWithUtils,
+ rank: number,
+ isPlaceholder: boolean,
+): void {
+ row.removeClass(TIER_CLASSES);
+ row.addClass(getTierClass(rank, isPlaceholder));
+}
- const dateStr =
- !isPlaceholder && entry.date > 0
- ? new Date(entry.date).toLocaleDateString("en-GB", {
- day: "2-digit",
- month: "short",
- year: "numeric",
- })
- : "-";
+// --- Row creation (only used for genuinely new entries) ---
+
+function createRow(entry: LeaderboardEntry, rank: number): ElementWithUtils {
+ const isPlaceholder = entry.wpm === -1;
+ const row = createElementWithUtils("div", {
+ classList: ["leaderboardRow", getTierClass(rank, isPlaceholder)],
+ dataset: { key: entry.id, name: entry.name },
+ });
row.setHtml(`
${rank}
@@ -176,90 +859,336 @@ function createRow(entry: LeaderboardEntry, rank: number): ElementWithUtils {
${entry.name}