diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx
index 1f08d4d..ef2dd1c 100644
--- a/app/game/[id]/game-page-client.tsx
+++ b/app/game/[id]/game-page-client.tsx
@@ -33,6 +33,8 @@ import { UpscalerBarChart } from "@/components/charts/UpscalerBarChart"
import { FpsBoxplot } from "@/components/charts/FpsBoxplot"
import { FpsRangeChart } from "@/components/charts/FpsRangeChart"
import { DeviceDonut } from "@/components/charts/DeviceDonut"
+import { PerformanceTierChart } from "@/components/charts/PerformanceTierChart"
+import { StabilityScatterChart } from "@/components/charts/StabilityScatterChart"
// Comments
import { CommentSection } from "@/components/comments/comment-section"
@@ -149,6 +151,20 @@ interface StatsResponse {
hardwareName: string
count: number
}>
+ performanceTiers: Array<{
+ hardwareSlug: string
+ unplayable: number
+ playable: number
+ smooth: number
+ excellent: number
+ }>
+ stabilityScatter: Array<{
+ id: string
+ hardwareSlug: string
+ fpsAvg: number
+ fpsOnePercentLow: number
+ stabilityRatio: number
+ }>
filterOptions: {
protonVersions: string[]
osVersions: string[]
@@ -352,6 +368,8 @@ export function GamePageClient({
upscalerStats: filterUpscaler(stats.upscalerStats),
fpsRange: filterByDevice(stats.fpsRange),
deviceBreakdown: filterByDevice(stats.deviceBreakdown),
+ performanceTiers: filterByDevice(stats.performanceTiers),
+ stabilityScatter: filterByDevice(stats.stabilityScatter),
}
}, [stats, selectedDevices, filters])
@@ -1030,6 +1048,31 @@ export function GamePageClient({
)}
+ {/* Row 4 — Performance Tiers + Stability Scatter */}
+ {filteredStats && (
+
+ {filteredStats.performanceTiers.length > 0 && (
+
+
+ Performance Tiers
+
+
+
+ )}
+ {filteredStats.stabilityScatter.length > 0 && (
+
+
+ Avg FPS vs 1% Low (Stability)
+
+ [d.hardwareSlug, d.hardwareName]))}
+ />
+
+ )}
+
+ )}
+
diff --git a/components/charts/PerformanceTierChart.tsx b/components/charts/PerformanceTierChart.tsx
new file mode 100644
index 0000000..91600d6
--- /dev/null
+++ b/components/charts/PerformanceTierChart.tsx
@@ -0,0 +1,72 @@
+"use client"
+
+import { EChartWrapper } from "./EChartWrapper"
+
+interface TierData {
+ hardwareSlug: string
+ hardwareName?: string
+ unplayable: number
+ playable: number
+ smooth: number
+ excellent: number
+}
+
+export function PerformanceTierChart({ data }: { data: TierData[] }) {
+ if (!data || data.length === 0) {
+ return No tier data
+ }
+
+ const labels = data.map((d) => d.hardwareName || d.hardwareSlug)
+
+ const option = {
+ tooltip: {
+ trigger: "axis" as const,
+ axisPointer: { type: "shadow" as const },
+ },
+ legend: {
+ data: ["<30 fps", "30-59", "60-119", "≥120"],
+ textStyle: { color: "#999" },
+ top: 0,
+ },
+ grid: { left: 100, right: 20, top: 40, bottom: 30 },
+ xAxis: { type: "value" as const, splitLine: { lineStyle: { color: "#333" } } },
+ yAxis: {
+ type: "category" as const,
+ data: labels,
+ axisLine: { lineStyle: { color: "#555" } },
+ axisLabel: { color: "#999" },
+ },
+ series: [
+ {
+ name: "<30 fps",
+ type: "bar" as const,
+ stack: "total",
+ data: data.map((d) => d.unplayable),
+ itemStyle: { color: "#ef4444" },
+ },
+ {
+ name: "30-59",
+ type: "bar" as const,
+ stack: "total",
+ data: data.map((d) => d.playable),
+ itemStyle: { color: "#eab308" },
+ },
+ {
+ name: "60-119",
+ type: "bar" as const,
+ stack: "total",
+ data: data.map((d) => d.smooth),
+ itemStyle: { color: "#22c55e" },
+ },
+ {
+ name: "≥120",
+ type: "bar" as const,
+ stack: "total",
+ data: data.map((d) => d.excellent),
+ itemStyle: { color: "#3b82f6" },
+ },
+ ],
+ }
+
+ return
+}
diff --git a/components/charts/StabilityScatterChart.tsx b/components/charts/StabilityScatterChart.tsx
new file mode 100644
index 0000000..6cbed23
--- /dev/null
+++ b/components/charts/StabilityScatterChart.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import { EChartWrapper } from "./EChartWrapper"
+
+interface ScatterPoint {
+ id: string
+ hardwareSlug: string
+ fpsAvg: number
+ fpsOnePercentLow: number
+ stabilityRatio: number
+}
+
+export function StabilityScatterChart({ data, deviceNames }: { data: ScatterPoint[]; deviceNames?: Record }) {
+ if (!data || data.length === 0) {
+ return No stability data yet
+ }
+
+ // Group by device
+ const deviceGroups = new Map()
+ for (const point of data) {
+ const existing = deviceGroups.get(point.hardwareSlug) || []
+ existing.push(point)
+ deviceGroups.set(point.hardwareSlug, existing)
+ }
+
+ const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"]
+ const series = Array.from(deviceGroups.entries()).map(([slug, points], idx) => ({
+ name: deviceNames?.[slug] || slug,
+ type: "scatter" as const,
+ data: points.map((p) => [p.fpsAvg, p.fpsOnePercentLow]),
+ itemStyle: { color: colors[idx % colors.length] },
+ symbolSize: 8,
+ }))
+
+ // Perfect stability line (y = x)
+ const maxFps = Math.max(...data.map((d) => d.fpsAvg))
+ const perfectLine = {
+ name: "Perfect Stability",
+ type: "line" as const,
+ data: [
+ [0, 0],
+ [maxFps, maxFps],
+ ],
+ lineStyle: { color: "#555", type: "dashed" as const },
+ symbol: "none",
+ silent: true,
+ }
+
+ const option = {
+ tooltip: {
+ trigger: "item" as const,
+ formatter: (params: any) => {
+ if (params.seriesName === "Perfect Stability") return ""
+ return `${params.seriesName}
Avg: ${params.value[0]} fps
1% Low: ${params.value[1]} fps`
+ },
+ },
+ legend: {
+ textStyle: { color: "#999" },
+ top: 0,
+ },
+ grid: { left: 60, right: 20, top: 40, bottom: 40 },
+ xAxis: {
+ type: "value" as const,
+ name: "Avg FPS",
+ nameTextStyle: { color: "#999" },
+ splitLine: { lineStyle: { color: "#333" } },
+ axisLabel: { color: "#999" },
+ },
+ yAxis: {
+ type: "value" as const,
+ name: "1% Low FPS",
+ nameTextStyle: { color: "#999" },
+ splitLine: { lineStyle: { color: "#333" } },
+ axisLabel: { color: "#999" },
+ },
+ series: [...series, perfectLine],
+ }
+
+ return
+}
diff --git a/lib/api/game-stats.ts b/lib/api/game-stats.ts
index 3c6a0c6..8531530 100644
--- a/lib/api/game-stats.ts
+++ b/lib/api/game-stats.ts
@@ -252,7 +252,34 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
}),
)
- // ── 9. Filter options ────────────────────────────────────────
+ // ── 9. Performance tier breakdown per device ────────────────
+ const tierMap = new Map()
+ for (const e of entries) {
+ const existing = tierMap.get(e.hardwareSlug) || { unplayable: 0, playable: 0, smooth: 0, excellent: 0 }
+ if (e.fpsAvg < 30) existing.unplayable++
+ else if (e.fpsAvg < 60) existing.playable++
+ else if (e.fpsAvg < 120) existing.smooth++
+ else existing.excellent++
+ tierMap.set(e.hardwareSlug, existing)
+ }
+
+ const performanceTiers = Array.from(tierMap.entries()).map(([slug, tiers]) => ({
+ hardwareSlug: slug,
+ ...tiers,
+ }))
+
+ // ── 10. Stability scatter data ──────────────────────────────
+ const stabilityScatter = entries
+ .filter((e) => e.fpsOnePercentLow != null)
+ .map((e) => ({
+ id: e.id,
+ hardwareSlug: e.hardwareSlug,
+ fpsAvg: e.fpsAvg ?? 0,
+ fpsOnePercentLow: e.fpsOnePercentLow!,
+ stabilityRatio: e.fpsAvg > 0 ? Math.min(1, e.fpsOnePercentLow! / e.fpsAvg) : 0,
+ }))
+
+ // ── 11. Filter options ────────────────────────────────────────
const protonVersions = [
...new Set(entries.map((e) => e.protonVersion).filter(Boolean)),
] as string[]
@@ -277,6 +304,8 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
upscalerStats,
fpsRange,
deviceBreakdown,
+ performanceTiers,
+ stabilityScatter,
filterOptions: { protonVersions, osVersions },
}
},