diff --git a/src/components/frontend/radio/RadioArena.tsx b/src/components/frontend/radio/RadioArena.tsx
new file mode 100644
index 0000000..a08b99f
--- /dev/null
+++ b/src/components/frontend/radio/RadioArena.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import type { RadioStats } from "@/app/(frontend)/radio/actions";
+import { useRealtimePresence } from "@/hooks/useRealtimePresence";
+import { RadioLeaderboard } from "./RadioLeaderboard";
+import { RadioTraffic } from "./RadioTraffic";
+
+export function RadioArena({
+ initialStats,
+ currentUserId,
+}: {
+ initialStats: RadioStats;
+ currentUserId: number | string;
+}) {
+ const [refreshKey, setRefreshKey] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const activeOperators = useRealtimePresence("radio", playing, true);
+ const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
+ setPlaying(phase === "playing");
+ }, []);
+
+ return (
+
+
setRefreshKey((key) => key + 1)}
+ onPhaseChangeAction={onPhaseChange}
+ />
+
+
How scoring works
+
+
+ Accuracy tracks every keystroke; each wrong character or backspace counts against it. Each message scores accuracy × speed multiplier, up to ~120 points for a fast perfect message.
+
+
+ Sprint: clear exactly 10 messages as fast as possible; maximum score is 1200.
+
+
+ Watch: relay as many messages as possible in 60 seconds; score is unbounded in practice.
+
+
+ XP: earn 0.1 per perfectly-typed message.
+
+ The lifetime leaderboard ranks Top Score, Top Accuracy, Fastest Sprint, and Most Messages.
+
+
+
+
+ );
+}
diff --git a/src/components/frontend/radio/RadioLeaderboard.tsx b/src/components/frontend/radio/RadioLeaderboard.tsx
new file mode 100644
index 0000000..5426a75
--- /dev/null
+++ b/src/components/frontend/radio/RadioLeaderboard.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Trophy } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
+
+type LeaderboardTab = "score" | "accuracy" | "messages" | "time";
+
+interface ApiUser {
+ id: number;
+ username?: string | null;
+ displayName?: string | null;
+ payloadDisplayName?: string | null;
+}
+
+interface ApiProfile {
+ id: number;
+ profileTitle?: string | null;
+ user?: ApiUser | number | null;
+ progression?: {
+ minigames?: {
+ radioTraffic?: {
+ totalMessages?: number | null;
+ bestScore?: number | null;
+ bestAccuracy?: number | null;
+ bestSprintTimeMs?: number | null;
+ } | null;
+ } | null;
+ } | null;
+}
+
+interface LeaderboardRow {
+ id: number;
+ name: string;
+ totalMessages: number;
+ bestScore: number;
+ bestAccuracy: number;
+ bestSprintTimeMs: number;
+ isCurrentUser: boolean;
+ isOutsideTopTen?: boolean;
+}
+
+interface TabConfig {
+ readonly label: string;
+ readonly sort: string;
+ readonly matches: (row: LeaderboardRow) => boolean;
+ readonly empty: string;
+}
+
+interface RadioLeaderboardProps {
+ refreshKey?: number;
+ currentUserId?: number | string;
+ activeOperators?: PresenceParticipant[];
+}
+
+const TAB_ORDER = ["score", "accuracy", "messages", "time"] as const satisfies readonly LeaderboardTab[];
+const TAB_CONFIG = {
+ score: {
+ label: "Top Score",
+ sort: "-progression.minigames.radioTraffic.bestScore",
+ matches: (row) => row.bestScore > 0,
+ empty: "No scored transmissions yet. Take the first slot!",
+ },
+ accuracy: {
+ label: "Top Accuracy",
+ sort: "-progression.minigames.radioTraffic.bestAccuracy",
+ matches: (row) => row.bestAccuracy > 0,
+ empty: "No accuracy records yet. Relay a perfect transmission!",
+ },
+ messages: {
+ label: "Most Messages",
+ sort: "-progression.minigames.radioTraffic.totalMessages",
+ matches: (row) => row.totalMessages > 0,
+ empty: "No accurate messages yet. Get on frequency!",
+ },
+ time: {
+ label: "Fastest Sprint",
+ sort: "progression.minigames.radioTraffic.bestSprintTimeMs",
+ matches: (row) => row.bestSprintTimeMs > 0,
+ empty: "No sprint times yet. Complete a 10-message sprint!",
+ },
+} satisfies Record;
+
+function rankClass(index: number) {
+ if (index === 0) return "font-bold text-amber-400";
+ if (index === 1) return "font-semibold text-slate-300";
+ if (index === 2) return "font-semibold text-amber-700";
+ return "text-muted-foreground";
+}
+
+export function RadioLeaderboard({
+ refreshKey = 0,
+ currentUserId,
+ activeOperators = [],
+}: RadioLeaderboardProps) {
+ const [tab, setTab] = useState("score");
+ const [rows, setRows] = useState(null);
+ const [failed, setFailed] = useState(false);
+ const tabConfig = TAB_CONFIG[tab];
+
+ useEffect(() => {
+ let cancelled = false;
+ setFailed(false);
+ setRows(null);
+
+ const params = new URLSearchParams({ sort: tabConfig.sort, limit: "100", depth: "1" });
+ fetch(`/api/profiles?${params.toString()}`)
+ .then((res) => {
+ if (!res.ok) throw new Error("leaderboard fetch failed");
+ return res.json() as Promise<{ docs?: ApiProfile[] }>;
+ })
+ .then((data) => {
+ if (cancelled) return;
+ const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => {
+ const radioTraffic = doc.progression?.minigames?.radioTraffic ?? {};
+ const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
+ return {
+ id: doc.id,
+ name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
+ totalMessages: radioTraffic.totalMessages ?? 0,
+ bestScore: radioTraffic.bestScore ?? 0,
+ bestAccuracy: radioTraffic.bestAccuracy ?? 0,
+ bestSprintTimeMs: radioTraffic.bestSprintTimeMs ?? 0,
+ isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
+ };
+ });
+ const visible = mapped.filter(tabConfig.matches).slice(0, 10);
+ const current = mapped.find((row) => row.isCurrentUser);
+ if (current && !visible.some((row) => row.id === current.id)) {
+ visible.push({ ...current, isOutsideTopTen: true });
+ }
+ setRows(visible);
+ })
+ .catch(() => {
+ if (!cancelled) setFailed(true);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [currentUserId, refreshKey, tabConfig.matches, tabConfig.sort]);
+
+ return (
+
+
+
+
+
Radio Traffic Leaderboard
+ Top 10
+
+
+ {TAB_ORDER.map((value) => (
+ setTab(value)}
+ >
+ {TAB_CONFIG[value].label}
+
+ ))}
+
+
+
+
+ {activeOperators.length} {activeOperators.length === 1 ? "operator" : "operators"} on frequency
+
+ {activeOperators.length > 0 && (
+
+ ({activeOperators.map((operator) => operator.name).join(", ")})
+
+ )}
+
+
+ {failed ? (
+
Could not load the leaderboard.
+ ) : rows === null ? (
+
+ ) : rows.length === 0 ? (
+
{tabConfig.empty}
+ ) : (
+
+
+
+
+ #
+ Operator
+ Score
+ Accuracy
+ Time
+ Messages
+
+
+
+ {rows.map((row, index) => (
+
+
+ {row.isOutsideTopTen ? "—" : index + 1}
+
+
+
+ {row.name}
+ {row.isCurrentUser && (
+ (you)
+ )}
+ {row.isOutsideTopTen && (
+
+ unranked
+
+ )}
+ {activeOperators.some((operator) => operator.id === row.id) && (
+
+ )}
+
+
+
+ {row.bestScore.toFixed(2)}
+
+
+ {(row.bestAccuracy * 100).toFixed(2)}%
+
+
+ {row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
+
+
+ {row.totalMessages}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}