diff --git a/apps/web/.claude/GAME_STATS_COMPARISON.md b/apps/web/.claude/GAME_STATS_COMPARISON.md
new file mode 100644
index 00000000..714d5e96
--- /dev/null
+++ b/apps/web/.claude/GAME_STATS_COMPARISON.md
@@ -0,0 +1,584 @@
+# Cross-Game Stats Analysis & Universal Data Model
+
+## Overview
+
+This document analyzes ALL arcade games to ensure our `GameResult` type works universally.
+
+## Games Analyzed
+
+1. ✅ **Matching** (Memory Pairs)
+2. ✅ **Complement Race** (Math race game)
+3. ✅ **Memory Quiz** (Number memory game)
+4. ✅ **Card Sorting** (Sort abacus cards)
+5. ✅ **Rithmomachia** (Strategic board game)
+6. 🔍 **YJS Demo** (Multiplayer demo - skipping for now)
+
+---
+
+## Per-Game Analysis
+
+### 1. Matching (Memory Pairs)
+
+**Game Type**: Memory/Pattern Matching
+**Players**: 1-N (competitive multiplayer)
+**How to Win**: Most pairs matched (multiplayer) OR complete all pairs (solo)
+
+**Data Tracked**:
+```typescript
+{
+ scores: { [playerId]: matchCount }
+ moves: number
+ matchedPairs: number
+ totalPairs: number
+ gameTime: milliseconds
+ accuracy: percentage (matchedPairs / moves * 100)
+ grade: 'A+' | 'A' | 'B+' | ...
+ starRating: 1-5
+}
+```
+
+**Winner Determination**:
+- Solo: completed = won
+- Multiplayer: highest score wins
+
+**Fits GameResult?** ✅
+```typescript
+{
+ gameType: 'matching',
+ duration: gameTime,
+ playerResults: [{
+ playerId,
+ won: isWinner,
+ score: matchCount,
+ accuracy: 0.0-1.0,
+ metrics: { moves, matchedPairs, difficulty }
+ }]
+}
+```
+
+---
+
+### 2. Complement Race
+
+**Game Type**: Racing/Quiz hybrid
+**Players**: 1-N (competitive race)
+**How to Win**: Highest score OR reach finish line first (depending on mode)
+
+**Data Tracked**:
+```typescript
+{
+ players: {
+ [playerId]: {
+ score: number
+ streak: number
+ bestStreak: number
+ correctAnswers: number
+ totalQuestions: number
+ position: 0-100% (for practice/survival)
+ deliveredPassengers: number (sprint mode)
+ }
+ }
+ gameTime: milliseconds
+ winner: playerId | null
+ leaderboard: [{ playerId, score, rank }]
+}
+```
+
+**Winner Determination**:
+- Practice/Survival: reach 100% position
+- Sprint: highest score (delivered passengers)
+
+**Fits GameResult?** ✅
+```typescript
+{
+ gameType: 'complement-race',
+ duration: gameTime,
+ playerResults: [{
+ playerId,
+ won: winnerId === playerId,
+ score: player.score,
+ accuracy: player.correctAnswers / player.totalQuestions,
+ placement: leaderboard rank,
+ metrics: {
+ streak: player.bestStreak,
+ correctAnswers: player.correctAnswers,
+ totalQuestions: player.totalQuestions
+ }
+ }]
+}
+```
+
+---
+
+### 3. Memory Quiz
+
+**Game Type**: Memory/Recall
+**Players**: 1-N (cooperative OR competitive)
+**How to Win**:
+- Cooperative: team finds all numbers
+- Competitive: most correct answers
+
+**Data Tracked**:
+```typescript
+{
+ playerScores: {
+ [playerId]: { correct: number, incorrect: number }
+ }
+ foundNumbers: number[]
+ correctAnswers: number[]
+ selectedCount: 2 | 5 | 8 | 12 | 15
+ playMode: 'cooperative' | 'competitive'
+ gameTime: milliseconds
+}
+```
+
+**Winner Determination**:
+- Cooperative: ALL found = team wins
+- Competitive: highest correct count wins
+
+**Fits GameResult?** ✅ **BUT needs special handling for cooperative**
+```typescript
+{
+ gameType: 'memory-quiz',
+ duration: gameTime,
+ playerResults: [{
+ playerId,
+ won: playMode === 'cooperative'
+ ? foundAll // All players win or lose together
+ : hasHighestScore, // Individual winner
+ score: playerScores[playerId].correct,
+ accuracy: correct / (correct + incorrect),
+ metrics: {
+ correct: playerScores[playerId].correct,
+ incorrect: playerScores[playerId].incorrect,
+ difficulty: selectedCount
+ }
+ }],
+ metadata: {
+ playMode: 'cooperative' | 'competitive',
+ isTeamVictory: boolean // ← IMPORTANT for cooperative games
+ }
+}
+```
+
+**NEW INSIGHT**: Cooperative games need special handling - all players share win/loss!
+
+---
+
+### 4. Card Sorting
+
+**Game Type**: Sorting/Puzzle
+**Players**: 1-N (solo, collaborative, competitive, relay)
+**How to Win**:
+- Solo: achieve high score (0-100)
+- Collaborative: team achieves score
+- Competitive: highest individual score
+- Relay: TBD (not fully implemented)
+
+**Data Tracked**:
+```typescript
+{
+ scoreBreakdown: {
+ finalScore: 0-100
+ exactMatches: number
+ lcsLength: number // Longest common subsequence
+ inversions: number // Out-of-order pairs
+ relativeOrderScore: 0-100
+ exactPositionScore: 0-100
+ inversionScore: 0-100
+ elapsedTime: seconds
+ }
+ gameMode: 'solo' | 'collaborative' | 'competitive' | 'relay'
+}
+```
+
+**Winner Determination**:
+- Solo/Collaborative: score > threshold (e.g., 70+)
+- Competitive: highest score
+
+**Fits GameResult?** ✅ **Similar to Memory Quiz**
+```typescript
+{
+ gameType: 'card-sorting',
+ duration: elapsedTime * 1000,
+ playerResults: [{
+ playerId,
+ won: gameMode === 'collaborative'
+ ? scoreBreakdown.finalScore >= 70 // Team threshold
+ : hasHighestScore,
+ score: scoreBreakdown.finalScore,
+ accuracy: scoreBreakdown.exactMatches / cardCount,
+ metrics: {
+ exactMatches: scoreBreakdown.exactMatches,
+ inversions: scoreBreakdown.inversions,
+ lcsLength: scoreBreakdown.lcsLength
+ }
+ }],
+ metadata: {
+ gameMode,
+ isTeamVictory: gameMode === 'collaborative'
+ }
+}
+```
+
+---
+
+### 5. Rithmomachia
+
+**Game Type**: Strategic board game (2-player only)
+**Players**: Exactly 2 (White vs Black)
+**How to Win**: Multiple victory conditions (harmony, points, exhaustion, resignation)
+
+**Data Tracked**:
+```typescript
+{
+ winner: 'W' | 'B' | null
+ winCondition: 'HARMONY' | 'EXHAUSTION' | 'RESIGNATION' | 'POINTS' | ...
+ capturedPieces: { W: Piece[], B: Piece[] }
+ pointsCaptured: { W: number, B: number }
+ history: MoveRecord[]
+ gameTime: milliseconds (computed from history)
+}
+```
+
+**Winner Determination**:
+- Specific win condition triggered
+- No draws (or rare)
+
+**Fits GameResult?** ✅ **Needs win condition metadata**
+```typescript
+{
+ gameType: 'rithmomachia',
+ duration: gameTime,
+ playerResults: [
+ {
+ playerId: whitePlayerId,
+ won: winner === 'W',
+ score: capturedPieces.W.length, // or pointsCaptured.W
+ metrics: {
+ capturedPieces: capturedPieces.W.length,
+ points: pointsCaptured?.W || 0,
+ moves: history.filter(m => m.color === 'W').length
+ }
+ },
+ {
+ playerId: blackPlayerId,
+ won: winner === 'B',
+ score: capturedPieces.B.length,
+ metrics: {
+ capturedPieces: capturedPieces.B.length,
+ points: pointsCaptured?.B || 0,
+ moves: history.filter(m => m.color === 'B').length
+ }
+ }
+ ],
+ metadata: {
+ winCondition: 'HARMONY' | 'POINTS' | ...
+ }
+}
+```
+
+---
+
+## Cross-Game Patterns Identified
+
+### Pattern 1: Competitive (Most Common)
+**Games**: Matching (multiplayer), Complement Race, Memory Quiz (competitive), Card Sorting (competitive)
+
+**Characteristics**:
+- Each player has their own score
+- Winner = highest score
+- Players track individually
+
+**Stats to track per player**:
+- games_played ++
+- wins ++ (if winner)
+- losses ++ (if not winner)
+- best_time (if faster)
+- highest_accuracy (if better)
+
+---
+
+### Pattern 2: Cooperative (Team-Based)
+**Games**: Memory Quiz (cooperative), Card Sorting (collaborative)
+
+**Characteristics**:
+- All players share outcome
+- Team wins or loses together
+- Individual contributions still tracked
+
+**Stats to track per player**:
+- games_played ++
+- wins ++ (if TEAM won) ← Key difference
+- losses ++ (if TEAM lost)
+- Individual metrics still tracked (correct answers, etc.)
+
+**CRITICAL**: Check `metadata.isTeamVictory` to determine if all players get same win/loss!
+
+---
+
+### Pattern 3: Head-to-Head (Exactly 2 Players)
+**Games**: Rithmomachia
+
+**Characteristics**:
+- Always 2 players
+- One wins, one loses (rare draws)
+- Different win conditions
+
+**Stats to track per player**:
+- games_played ++
+- wins ++ (winner only)
+- losses ++ (loser only)
+- Game-specific metrics (captures, harmonies)
+
+---
+
+### Pattern 4: Solo Completion
+**Games**: Matching (solo), Complement Race (practice), Memory Quiz (solo), Card Sorting (solo)
+
+**Characteristics**:
+- Single player
+- Win = completion or threshold
+- Compete against self/time
+
+**Stats to track**:
+- games_played ++
+- wins ++ (if completed/threshold met)
+- losses ++ (if failed/gave up)
+- best_time, highest_accuracy
+
+---
+
+## Refined Universal Data Model
+
+### GameResult Type (UPDATED)
+
+```typescript
+export interface GameResult {
+ // Game identification
+ gameType: string // e.g., "matching", "complement-race", etc.
+
+ // Player results (supports 1-N players)
+ playerResults: PlayerGameResult[]
+
+ // Timing
+ completedAt: number // timestamp
+ duration: number // milliseconds
+
+ // Optional game-specific data
+ metadata?: {
+ // For cooperative games
+ isTeamVictory?: boolean // ← NEW: all players share win/loss
+
+ // For specific win conditions
+ winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT"
+
+ // For game modes
+ gameMode?: string // e.g., "solo", "competitive", "cooperative"
+
+ // Any other game-specific info
+ [key: string]: unknown
+ }
+}
+
+export interface PlayerGameResult {
+ playerId: string
+
+ // Outcome
+ won: boolean // For cooperative: all players same value
+ placement?: number // 1st, 2nd, 3rd (for competitive with >2 players)
+
+ // Performance
+ score?: number
+ accuracy?: number // 0.0 - 1.0
+ completionTime?: number // milliseconds (player-specific time)
+
+ // Game-specific metrics (optional, stored as JSON in DB)
+ metrics?: {
+ // Matching
+ moves?: number
+ matchedPairs?: number
+ difficulty?: number
+
+ // Complement Race
+ streak?: number
+ correctAnswers?: number
+ totalQuestions?: number
+
+ // Memory Quiz
+ correct?: number
+ incorrect?: number
+
+ // Card Sorting
+ exactMatches?: number
+ inversions?: number
+ lcsLength?: number
+
+ // Rithmomachia
+ capturedPieces?: number
+ points?: number
+
+ // Extensible for future games
+ [key: string]: unknown
+ }
+}
+```
+
+---
+
+## Stats Recording Logic (UPDATED)
+
+### For Each Player in GameResult
+
+```typescript
+// Fetch player stats
+const stats = await getPlayerStats(playerId)
+
+// Always increment
+stats.gamesPlayed++
+
+// Handle wins/losses based on game type
+if (gameResult.metadata?.isTeamVictory !== undefined) {
+ // COOPERATIVE: All players share outcome
+ if (playerResult.won) {
+ stats.totalWins++
+ } else {
+ stats.totalLosses++
+ }
+} else {
+ // COMPETITIVE/SOLO: Individual outcome
+ if (playerResult.won) {
+ stats.totalWins++
+ } else {
+ stats.totalLosses++
+ }
+}
+
+// Update performance metrics
+if (playerResult.completionTime && (
+ !stats.bestTime || playerResult.completionTime < stats.bestTime
+)) {
+ stats.bestTime = playerResult.completionTime
+}
+
+if (playerResult.accuracy && playerResult.accuracy > stats.highestAccuracy) {
+ stats.highestAccuracy = playerResult.accuracy
+}
+
+// Update per-game stats (JSON)
+stats.gameStats[gameResult.gameType] = {
+ gamesPlayed: (stats.gameStats[gameResult.gameType]?.gamesPlayed || 0) + 1,
+ wins: (stats.gameStats[gameResult.gameType]?.wins || 0) + (playerResult.won ? 1 : 0),
+ // ... other game-specific aggregates
+}
+
+// Update favorite game type (most played)
+stats.favoriteGameType = getMostPlayedGame(stats.gameStats)
+
+// Update timestamps
+stats.lastPlayedAt = gameResult.completedAt
+stats.updatedAt = Date.now()
+```
+
+---
+
+## Database Schema (CONFIRMED)
+
+No changes needed from original design! The `metrics` JSON field handles game-specific data perfectly.
+
+```sql
+CREATE TABLE player_stats (
+ player_id TEXT PRIMARY KEY,
+
+ -- Aggregates
+ games_played INTEGER NOT NULL DEFAULT 0,
+ total_wins INTEGER NOT NULL DEFAULT 0,
+ total_losses INTEGER NOT NULL DEFAULT 0,
+
+ -- Performance
+ best_time INTEGER,
+ highest_accuracy REAL NOT NULL DEFAULT 0,
+
+ -- Per-game breakdown (JSON)
+ game_stats TEXT NOT NULL DEFAULT '{}',
+
+ -- Meta
+ favorite_game_type TEXT,
+ last_played_at INTEGER,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+```
+
+---
+
+## Key Insights & Design Decisions
+
+### 1. Cooperative Games Need Special Flag
+**Problem**: Memory Quiz (cooperative) and Card Sorting (collaborative) - all players share win/loss.
+
+**Solution**: Add `metadata.isTeamVictory: boolean` to `GameResult`. When `true`, recording logic gives ALL players the same win/loss.
+
+### 2. Flexible Metrics Field
+**Problem**: Each game tracks different metrics (moves, streak, inversions, etc.).
+
+**Solution**: `PlayerGameResult.metrics` is an open object. Store game-specific data here, saved as JSON in DB.
+
+### 3. Placement for Tournaments
+**Problem**: 3+ player games need to track ranking (1st, 2nd, 3rd).
+
+**Solution**: `PlayerGameResult.placement` field. Useful for leaderboards.
+
+### 4. Win Conditions Matter
+**Problem**: Rithmomachia has multiple win conditions (harmony, points, etc.).
+
+**Solution**: `metadata.winCondition` stores how the game was won. Useful for achievements/stats breakdown.
+
+### 5. Score is Optional
+**Problem**: Not all games have scores (e.g., Rithmomachia can win by harmony without points enabled).
+
+**Solution**: Make `score` optional. Use `won` as primary outcome indicator.
+
+---
+
+## Testing Matrix
+
+### Scenarios to Test
+
+| Game | Mode | Players | Expected Outcome |
+|------|------|---------|------------------|
+| Matching | Solo | 1 | Player wins if completed |
+| Matching | Competitive | 2+ | Winner = highest score, others lose |
+| Complement Race | Sprint | 2+ | Winner = highest score |
+| Memory Quiz | Cooperative | 2+ | ALL win or ALL lose (team) |
+| Memory Quiz | Competitive | 2+ | Winner = most correct |
+| Card Sorting | Solo | 1 | Win if score >= 70 |
+| Card Sorting | Collaborative | 2+ | ALL win or ALL lose (team) |
+| Card Sorting | Competitive | 2+ | Winner = highest score |
+| Rithmomachia | PvP | 2 | One wins (by condition), one loses |
+
+---
+
+## Conclusion
+
+✅ **Universal `GameResult` type CONFIRMED to work for all games**
+
+**Key Requirements**:
+1. Support 1-N players (flexible array)
+2. Support cooperative games (isTeamVictory flag)
+3. Support game-specific metrics (open metrics object)
+4. Support multiple win conditions (winCondition metadata)
+5. Track both individual AND team performance
+
+**Next Steps**:
+1. Update `.claude/PER_PLAYER_STATS_ARCHITECTURE.md` with refined types
+2. Implement database schema
+3. Build API endpoints
+4. Create React hooks
+5. Integrate with each game (starting with Matching)
+
+---
+
+**Status**: ✅ Complete cross-game analysis
+**Result**: GameResult type is universal and robust
+**Date**: 2025-01-03
diff --git a/apps/web/.claude/MATCHING_GAME_STATS_INTEGRATION.md b/apps/web/.claude/MATCHING_GAME_STATS_INTEGRATION.md
new file mode 100644
index 00000000..16e84d38
--- /dev/null
+++ b/apps/web/.claude/MATCHING_GAME_STATS_INTEGRATION.md
@@ -0,0 +1,283 @@
+# Matching Game Stats Integration Guide
+
+## Quick Reference
+
+**Files to modify**: `src/arcade-games/matching/components/ResultsPhase.tsx`
+
+**What we're adding**: Call `useRecordGameResult()` when game completes to save per-player stats.
+
+## Current State Analysis
+
+### ResultsPhase.tsx (lines 9-29)
+
+Already has all the data we need:
+
+```typescript
+const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
+const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
+
+const gameTime = state.gameEndTime && state.gameStartTime
+ ? state.gameEndTime - state.gameStartTime
+ : 0
+
+const analysis = getPerformanceAnalysis(state)
+const multiplayerResult = gameMode === 'multiplayer'
+ ? getMultiplayerWinner(state, activePlayers)
+ : null
+```
+
+**Available data:**
+- ✅ `state.scores` - scores by player ID
+- ✅ `state.gameStartTime`, `state.gameEndTime` - timing
+- ✅ `state.matchedPairs`, `state.totalPairs` - completion
+- ✅ `state.moves` - total moves
+- ✅ `activePlayers` - array of player IDs
+- ✅ `multiplayerResult.winners` - who won
+- ✅ `analysis.statistics.accuracy` - accuracy percentage
+
+## Implementation Steps
+
+### Step 1: Add state flag to prevent duplicate recording
+
+Add `recorded: boolean` to `MatchingState` type:
+
+```typescript
+// src/arcade-games/matching/types.ts (add to MatchingState interface)
+
+export interface MatchingState extends GameState {
+ // ... existing fields ...
+
+ // Stats recording
+ recorded?: boolean // ← ADD THIS
+}
+```
+
+### Step 2: Import the hook in ResultsPhase.tsx
+
+```typescript
+// At top of src/arcade-games/matching/components/ResultsPhase.tsx
+
+import { useEffect } from 'react' // ← ADD if not present
+import { useRecordGameResult } from '@/hooks/useRecordGameResult'
+import type { GameResult } from '@/lib/arcade/stats/types'
+```
+
+### Step 3: Call the hook
+
+```typescript
+// Inside ResultsPhase component, after existing hooks
+
+export function ResultsPhase() {
+ const router = useRouter()
+ const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
+ const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
+
+ // ← ADD THIS
+ const { mutate: recordGame, isPending: isRecording } = useRecordGameResult()
+
+ // ... existing code ...
+```
+
+### Step 4: Record game result on mount
+
+Add this useEffect after the hook declarations:
+
+```typescript
+// Record game result once when entering results phase
+useEffect(() => {
+ // Only record if we haven't already
+ if (state.phase === 'results' && !state.recorded && !isRecording) {
+ const gameTime = state.gameEndTime && state.gameStartTime
+ ? state.gameEndTime - state.gameStartTime
+ : 0
+
+ const analysis = getPerformanceAnalysis(state)
+ const multiplayerResult = gameMode === 'multiplayer'
+ ? getMultiplayerWinner(state, activePlayers)
+ : null
+
+ // Build GameResult
+ const gameResult: GameResult = {
+ gameType: state.gameType === 'abacus-numeral'
+ ? 'matching-abacus'
+ : 'matching-complements',
+ completedAt: state.gameEndTime || Date.now(),
+ duration: gameTime,
+
+ playerResults: activePlayers.map(playerId => {
+ const score = state.scores[playerId] || 0
+ const won = multiplayerResult
+ ? multiplayerResult.winners.includes(playerId)
+ : state.matchedPairs === state.totalPairs // Solo = completed
+
+ // In multiplayer, calculate per-player accuracy from their score
+ // In single player, use overall accuracy
+ const playerAccuracy = gameMode === 'multiplayer'
+ ? score / state.totalPairs // Their score as fraction of total pairs
+ : analysis.statistics.accuracy / 100 // Convert percentage to 0-1
+
+ return {
+ playerId,
+ won,
+ score,
+ accuracy: playerAccuracy,
+ completionTime: gameTime,
+ metrics: {
+ moves: state.moves,
+ matchedPairs: state.matchedPairs,
+ difficulty: state.difficulty,
+ }
+ }
+ }),
+
+ metadata: {
+ gameType: state.gameType,
+ difficulty: state.difficulty,
+ grade: analysis.grade,
+ starRating: analysis.starRating,
+ }
+ }
+
+ // Record to database
+ recordGame(gameResult, {
+ onSuccess: (updates) => {
+ console.log('✅ Stats recorded:', updates)
+ // Mark as recorded to prevent duplicate saves
+ // Note: This assumes Provider has a way to update state.recorded
+ // We'll need to add an action for this
+ },
+ onError: (error) => {
+ console.error('❌ Failed to record stats:', error)
+ }
+ })
+ }
+}, [state.phase, state.recorded, isRecording, /* ... deps */])
+```
+
+### Step 5: Add loading state UI (optional)
+
+Show a subtle loading indicator while recording:
+
+```typescript
+// At the top of the return statement in ResultsPhase
+
+if (isRecording) {
+ return (
+
+ )
+}
+```
+
+Or keep it subtle and just disable buttons:
+
+```typescript
+// On the "Play Again" button
+
+```
+
+## Provider Changes Needed
+
+The Provider needs an action to mark the game as recorded:
+
+```typescript
+// src/arcade-games/matching/Provider.tsx
+
+// Add to the context type
+export interface MatchingContextType {
+ // ... existing ...
+ markAsRecorded: () => void // ← ADD THIS
+}
+
+// Add to the reducer or state update logic
+const markAsRecorded = useCallback(() => {
+ setState(prev => ({ ...prev, recorded: true }))
+}, [])
+
+// Add to the context value
+const contextValue: MatchingContextType = {
+ // ... existing ...
+ markAsRecorded,
+}
+```
+
+Then in ResultsPhase useEffect:
+
+```typescript
+onSuccess: (updates) => {
+ console.log('✅ Stats recorded:', updates)
+ markAsRecorded() // ← Use this instead
+}
+```
+
+## Testing Checklist
+
+### Solo Game
+- [ ] Play a game to completion
+- [ ] Check console for "✅ Stats recorded"
+- [ ] Refresh page
+- [ ] Go to `/games` page
+- [ ] Verify player's gamesPlayed incremented
+- [ ] Verify player's totalWins incremented (if completed)
+
+### Multiplayer Game
+- [ ] Activate 2+ players
+- [ ] Play a game to completion
+- [ ] Check console for stats for ALL players
+- [ ] Go to `/games` page
+- [ ] Verify each player's stats updated independently
+- [ ] Winner should have +1 win
+- [ ] All players should have +1 games played
+
+### Edge Cases
+- [ ] Incomplete game (exit early) - should NOT record
+- [ ] Play again from results - should NOT duplicate record
+- [ ] Network error during save - should show error, not mark as recorded
+
+## Common Issues
+
+### Issue: Stats recorded multiple times
+**Cause**: useEffect dependency array missing or incorrect
+**Fix**: Ensure `state.recorded` is in deps and checked in condition
+
+### Issue: Can't read property 'id' of undefined
+**Cause**: Player not found in playerMap
+**Fix**: Add null checks when mapping activePlayers
+
+### Issue: Accuracy is always 100% or 0%
+**Cause**: Wrong calculation or unit (percentage vs decimal)
+**Fix**: Ensure accuracy is 0.0 - 1.0, not 0-100
+
+### Issue: Single player never "wins"
+**Cause**: Wrong win condition for solo mode
+**Fix**: Solo player wins if they complete all pairs (`state.matchedPairs === state.totalPairs`)
+
+## Next Steps After Integration
+
+1. ✅ Verify stats save correctly
+2. ✅ Update `/games` page to fetch and display per-player stats
+3. ✅ Test with different game modes and difficulties
+4. 🔄 Repeat this pattern for other arcade games
+5. 📊 Add stats visualization/charts (future)
+
+---
+
+**Status**: Ready for implementation
+**Blocked by**:
+- Database schema (player_stats table)
+- API endpoints (/api/player-stats/record-game)
+- React hooks (useRecordGameResult)
diff --git a/apps/web/.claude/PER_PLAYER_STATS_ARCHITECTURE.md b/apps/web/.claude/PER_PLAYER_STATS_ARCHITECTURE.md
new file mode 100644
index 00000000..0d819782
--- /dev/null
+++ b/apps/web/.claude/PER_PLAYER_STATS_ARCHITECTURE.md
@@ -0,0 +1,594 @@
+# Per-Player Stats Architecture & Implementation Plan
+
+## Executive Summary
+
+This document outlines the architecture for tracking game statistics per-player (not per-user). Each local player profile will maintain their own game history, wins, losses, and performance metrics. We'll build a universal framework that any arcade game can use to record results.
+
+**Starting point**: Matching/Memory Lightning game
+
+## Current State Problems
+
+1. ❌ Global `user_stats` table exists but games never update it
+2. ❌ `/games` page shows same global stats for all players
+3. ❌ No framework for games to save results
+4. ❌ Players table has no stats fields
+
+## Architecture Design
+
+### 1. Database Schema
+
+#### New Table: `player_stats`
+
+```sql
+CREATE TABLE player_stats (
+ player_id TEXT PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE,
+
+ -- Aggregate stats
+ games_played INTEGER NOT NULL DEFAULT 0,
+ total_wins INTEGER NOT NULL DEFAULT 0,
+ total_losses INTEGER NOT NULL DEFAULT 0,
+
+ -- Performance metrics
+ best_time INTEGER, -- Best completion time (ms)
+ highest_accuracy REAL NOT NULL DEFAULT 0, -- 0.0 - 1.0
+
+ -- Game preferences
+ favorite_game_type TEXT, -- Most played game
+
+ -- Per-game stats (JSON)
+ game_stats TEXT NOT NULL DEFAULT '{}', -- { "matching": { wins: 5, played: 10 }, ... }
+
+ -- Timestamps
+ last_played_at INTEGER, -- timestamp
+ created_at INTEGER NOT NULL, -- timestamp
+ updated_at INTEGER NOT NULL -- timestamp
+);
+
+CREATE INDEX player_stats_last_played_idx ON player_stats(last_played_at);
+```
+
+#### Per-Game Stats Structure (JSON)
+
+```typescript
+type PerGameStats = {
+ [gameName: string]: {
+ gamesPlayed: number
+ wins: number
+ losses: number
+ bestTime: number | null
+ highestAccuracy: number
+ averageScore: number
+ lastPlayed: number // timestamp
+ }
+}
+```
+
+#### Keep `user_stats`?
+
+**Decision**: Deprecate `user_stats` table. All stats are now per-player.
+
+**Reasoning**:
+- Users can have multiple players
+- Aggregate "user level" stats can be computed by summing player stats
+- Simpler mental model: players compete, players have stats
+- `/games` page displays players, so showing player stats makes sense
+
+### 2. Universal Game Result Types
+
+**Analysis**: Examined 5 arcade games (Matching, Complement Race, Memory Quiz, Card Sorting, Rithmomachia)
+**Key Finding**: Cooperative games need special handling - all players share win/loss!
+**See**: `.claude/GAME_STATS_COMPARISON.md` for detailed cross-game analysis
+
+```typescript
+// src/lib/arcade/stats/types.ts
+
+/**
+ * Standard game result that all arcade games must provide
+ *
+ * Supports:
+ * - 1-N players
+ * - Competitive (individual winners)
+ * - Cooperative (team wins/losses)
+ * - Solo completion
+ * - Head-to-head (2-player)
+ */
+export interface GameResult {
+ // Game identification
+ gameType: string // e.g., "matching", "complement-race", "memory-quiz"
+
+ // Player results (for multiplayer, array of results)
+ playerResults: PlayerGameResult[]
+
+ // Game metadata
+ completedAt: number // timestamp
+ duration: number // milliseconds
+
+ // Optional game-specific data
+ metadata?: {
+ // For cooperative games (Memory Quiz, Card Sorting collaborative)
+ isTeamVictory?: boolean // All players share win/loss
+
+ // For specific win conditions (Rithmomachia)
+ winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT"
+
+ // For game modes
+ gameMode?: string // e.g., "solo", "competitive", "cooperative"
+
+ // Extensible for other game-specific info
+ [key: string]: unknown
+ }
+}
+
+export interface PlayerGameResult {
+ playerId: string
+
+ // Outcome
+ won: boolean // For cooperative: all players have same value
+ placement?: number // 1st, 2nd, 3rd place (for tournaments with 3+ players)
+
+ // Performance
+ score?: number
+ accuracy?: number // 0.0 - 1.0
+ completionTime?: number // milliseconds (player-specific)
+
+ // Game-specific metrics (stored as JSON in DB)
+ metrics?: {
+ // Matching
+ moves?: number
+ matchedPairs?: number
+ difficulty?: number
+
+ // Complement Race
+ streak?: number
+ correctAnswers?: number
+ totalQuestions?: number
+
+ // Memory Quiz
+ correct?: number
+ incorrect?: number
+
+ // Card Sorting
+ exactMatches?: number
+ inversions?: number
+ lcsLength?: number
+
+ // Rithmomachia
+ capturedPieces?: number
+ points?: number
+
+ // Extensible for future games
+ [key: string]: unknown
+ }
+}
+
+/**
+ * Stats update returned from API
+ */
+export interface StatsUpdate {
+ playerId: string
+ previousStats: PlayerStats
+ newStats: PlayerStats
+ changes: {
+ gamesPlayed: number
+ wins: number
+ losses: number
+ }
+}
+
+export interface PlayerStats {
+ playerId: string
+ gamesPlayed: number
+ totalWins: number
+ totalLosses: number
+ bestTime: number | null
+ highestAccuracy: number
+ favoriteGameType: string | null
+ gameStats: PerGameStats
+ lastPlayedAt: number | null
+ createdAt: number
+ updatedAt: number
+}
+```
+
+### 3. API Endpoints
+
+#### POST `/api/player-stats/record-game`
+
+Records a game result and updates player stats.
+
+**Request:**
+```typescript
+{
+ gameResult: GameResult
+}
+```
+
+**Response:**
+```typescript
+{
+ success: true,
+ updates: StatsUpdate[] // One per player
+}
+```
+
+**Logic:**
+1. Validate game result structure
+2. For each player result:
+ - Fetch or create player_stats record
+ - Increment games_played
+ - Increment wins/losses based on outcome
+ - **Special case**: If `metadata.isTeamVictory === true`, all players share win/loss
+ - Cooperative games: all win or all lose together
+ - Competitive games: individual outcomes
+ - Update best_time if improved
+ - Update highest_accuracy if improved
+ - Update game-specific stats in JSON
+ - Update favorite_game_type based on most played
+ - Set last_played_at
+3. Return updates for all players
+
+**Example pseudo-code**:
+```typescript
+for (const playerResult of gameResult.playerResults) {
+ const stats = await getPlayerStats(playerResult.playerId)
+
+ stats.gamesPlayed++
+
+ // Handle cooperative games specially
+ if (gameResult.metadata?.isTeamVictory !== undefined) {
+ // Cooperative: all players share outcome
+ if (playerResult.won) {
+ stats.totalWins++
+ } else {
+ stats.totalLosses++
+ }
+ } else {
+ // Competitive/Solo: individual outcome
+ if (playerResult.won) {
+ stats.totalWins++
+ } else {
+ stats.totalLosses++
+ }
+ }
+
+ // ... rest of stats update
+}
+```
+
+#### GET `/api/player-stats/:playerId`
+
+Fetch stats for a specific player.
+
+**Response:**
+```typescript
+{
+ stats: PlayerStats
+}
+```
+
+#### GET `/api/player-stats`
+
+Fetch stats for all current user's players.
+
+**Response:**
+```typescript
+{
+ playerStats: PlayerStats[]
+}
+```
+
+### 4. React Hooks
+
+#### `useRecordGameResult()`
+
+Main hook that games use to record results.
+
+```typescript
+// src/hooks/useRecordGameResult.ts
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { GameResult, StatsUpdate } from '@/lib/arcade/stats/types'
+
+export function useRecordGameResult() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (gameResult: GameResult): Promise => {
+ const res = await fetch('/api/player-stats/record-game', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ gameResult }),
+ })
+
+ if (!res.ok) throw new Error('Failed to record game result')
+
+ const data = await res.json()
+ return data.updates
+ },
+
+ onSuccess: (updates) => {
+ // Invalidate player stats queries to trigger refetch
+ queryClient.invalidateQueries({ queryKey: ['player-stats'] })
+
+ // Show success feedback (optional)
+ console.log('✅ Game result recorded:', updates)
+ },
+
+ onError: (error) => {
+ console.error('❌ Failed to record game result:', error)
+ },
+ })
+}
+```
+
+#### `usePlayerStats(playerId?)`
+
+Fetch stats for a player (or all players if no ID).
+
+```typescript
+// src/hooks/usePlayerStats.ts
+
+import { useQuery } from '@tanstack/react-query'
+import type { PlayerStats } from '@/lib/arcade/stats/types'
+
+export function usePlayerStats(playerId?: string) {
+ return useQuery({
+ queryKey: playerId ? ['player-stats', playerId] : ['player-stats'],
+ queryFn: async (): Promise => {
+ const url = playerId
+ ? `/api/player-stats/${playerId}`
+ : '/api/player-stats'
+
+ const res = await fetch(url)
+ if (!res.ok) throw new Error('Failed to fetch player stats')
+
+ const data = await res.json()
+ return playerId ? data.stats : data.playerStats
+ },
+ })
+}
+```
+
+### 5. Game Integration Pattern
+
+Every arcade game should follow this pattern when completing:
+
+```typescript
+// In results phase component (e.g., ResultsPhase.tsx)
+
+import { useRecordGameResult } from '@/hooks/useRecordGameResult'
+import type { GameResult } from '@/lib/arcade/stats/types'
+
+export function ResultsPhase() {
+ const { state, activePlayers } = useGameContext()
+ const { mutate: recordGame, isPending } = useRecordGameResult()
+
+ // Record game result on mount (once)
+ useEffect(() => {
+ if (state.phase === 'results' && !state.recorded) {
+ const gameResult: GameResult = {
+ gameType: 'matching',
+ completedAt: Date.now(),
+ duration: state.gameEndTime - state.gameStartTime,
+ playerResults: activePlayers.map(player => ({
+ playerId: player.id,
+ won: player.id === winnerId,
+ score: player.matchCount,
+ accuracy: player.matchCount / state.totalPairs,
+ completionTime: player.completionTime,
+ })),
+ }
+
+ recordGame(gameResult, {
+ onSuccess: () => {
+ // Mark as recorded to prevent duplicates
+ setState({ recorded: true })
+ }
+ })
+ }
+ }, [state.phase, state.recorded])
+
+ // Show loading state while recording
+ if (isPending) {
+ return Saving results...
+ }
+
+ // Show results UI
+ return ...
+}
+```
+
+## Implementation Plan
+
+### Phase 1: Foundation (Database & API)
+
+1. **Create database schema**
+ - File: `src/db/schema/player-stats.ts`
+ - Define `player_stats` table with Drizzle ORM
+ - Add type exports
+
+2. **Generate migration**
+ ```bash
+ npx drizzle-kit generate:sqlite
+ ```
+
+3. **Create type definitions**
+ - File: `src/lib/arcade/stats/types.ts`
+ - Define `GameResult`, `PlayerGameResult`, `StatsUpdate`, `PlayerStats`
+
+4. **Build API endpoint**
+ - File: `src/app/api/player-stats/record-game/route.ts`
+ - Implement POST handler with validation
+ - Handle per-player stat updates
+ - Transaction safety
+
+5. **Build query endpoints**
+ - File: `src/app/api/player-stats/route.ts` (GET all)
+ - File: `src/app/api/player-stats/[playerId]/route.ts` (GET one)
+
+### Phase 2: React Hooks & Integration
+
+6. **Create React hooks**
+ - File: `src/hooks/useRecordGameResult.ts`
+ - File: `src/hooks/usePlayerStats.ts`
+
+7. **Update GameModeContext**
+ - Expose helper to get player stats map
+ - Integrate with usePlayerStats hook
+
+### Phase 3: Matching Game Integration
+
+8. **Analyze matching game completion flow**
+ - Find where game completes
+ - Identify winner calculation
+ - Map state to GameResult format
+
+9. **Integrate stats recording**
+ - Add useRecordGameResult to ResultsPhase
+ - Build GameResult from game state
+ - Handle recording state to prevent duplicates
+
+10. **Test matching game stats**
+ - Play solo game, verify stats update
+ - Play multiplayer game, verify all players update
+ - Check accuracy calculations
+ - Check time tracking
+
+### Phase 4: UI Updates
+
+11. **Update /games page**
+ - Fetch per-player stats with usePlayerStats
+ - Display correct stats for each player card
+ - Remove dependency on global user profile
+
+12. **Add stats visualization**
+ - Per-game breakdown
+ - Win/loss ratio
+ - Performance trends
+
+### Phase 5: Documentation & Rollout
+
+13. **Document integration pattern**
+ - Create guide for adding stats to other games
+ - Code examples
+ - Common pitfalls
+
+14. **Roll out to other games**
+ - Complement Race
+ - Memory Quiz
+ - Card Sorting
+ - (Future games)
+
+## Data Migration Strategy
+
+### Handling Existing `user_stats`
+
+**Option A: Drop the table**
+- Simple, clean break
+- No historical data
+
+**Option B: Migrate to player stats**
+- For each user with stats, assign to their first/active player
+- More complex but preserves history
+
+**Recommendation**: Option A (drop it) since:
+- Very new feature, unlikely much data exists
+- Cleaner architecture
+- Users can rebuild stats by playing
+
+### Migration SQL
+
+```sql
+-- Drop old user_stats table
+DROP TABLE IF EXISTS user_stats;
+
+-- Create new player_stats table
+-- (Drizzle migration will handle this)
+```
+
+## Testing Strategy
+
+### Unit Tests
+
+- `GameResult` validation
+- Stats calculation logic
+- JSON merge for per-game stats
+- Favorite game detection
+
+### Integration Tests
+
+- API endpoint: record game, verify DB update
+- API endpoint: fetch stats, verify response
+- React hook: record game, verify cache invalidation
+
+### E2E Tests
+
+- Play matching game solo, check stats on /games page
+- Play matching game multiplayer, verify each player's stats
+- Verify stats persist across sessions
+
+## Success Criteria
+
+✅ Player stats save correctly after game completion
+✅ Each player maintains separate stats
+✅ /games page displays correct per-player stats
+✅ Stats survive page refresh
+✅ Multiplayer games update all participants
+✅ Framework is reusable for other games
+✅ No duplicate recordings
+✅ Performance acceptable (< 200ms to record)
+
+## Open Questions
+
+1. **Leaderboards?** - Future consideration, need global rankings
+2. **Historical games?** - Store individual game records or just aggregates?
+3. **Stats reset?** - Should users be able to reset player stats?
+4. **Achievements?** - Track milestones? (100 games, 50 wins, etc.)
+
+## File Structure
+
+```
+src/
+├── db/
+│ └── schema/
+│ └── player-stats.ts # NEW: Drizzle schema
+├── lib/
+│ └── arcade/
+│ └── stats/
+│ ├── types.ts # NEW: Type definitions
+│ └── utils.ts # NEW: Helper functions
+├── hooks/
+│ ├── useRecordGameResult.ts # NEW: Record game hook
+│ └── usePlayerStats.ts # NEW: Fetch stats hook
+├── app/
+│ └── api/
+│ └── player-stats/
+│ ├── route.ts # NEW: GET all
+│ ├── record-game/
+│ │ └── route.ts # NEW: POST record
+│ └── [playerId]/
+│ └── route.ts # NEW: GET one
+└── arcade-games/
+ └── matching/
+ └── components/
+ └── ResultsPhase.tsx # MODIFY: Add stats recording
+
+.claude/
+└── PER_PLAYER_STATS_ARCHITECTURE.md # THIS FILE
+```
+
+## Next Steps
+
+1. Review this plan with user
+2. Create database schema and types
+3. Build API endpoints
+4. Create React hooks
+5. Integrate with matching game
+6. Test thoroughly
+7. Roll out to other games
+
+---
+
+**Document Status**: Draft for review
+**Last Updated**: 2025-01-03
+**Owner**: Claude Code
diff --git a/apps/web/drizzle/0013_add_player_stats.sql b/apps/web/drizzle/0013_add_player_stats.sql
new file mode 100644
index 00000000..8a2c30dc
--- /dev/null
+++ b/apps/web/drizzle/0013_add_player_stats.sql
@@ -0,0 +1,17 @@
+-- Migration: Add player_stats table
+-- Per-player game statistics tracking
+
+CREATE TABLE `player_stats` (
+ `player_id` text PRIMARY KEY NOT NULL,
+ `games_played` integer DEFAULT 0 NOT NULL,
+ `total_wins` integer DEFAULT 0 NOT NULL,
+ `total_losses` integer DEFAULT 0 NOT NULL,
+ `best_time` integer,
+ `highest_accuracy` real DEFAULT 0 NOT NULL,
+ `favorite_game_type` text,
+ `game_stats` text DEFAULT '{}' NOT NULL,
+ `last_played_at` integer,
+ `created_at` integer NOT NULL,
+ `updated_at` integer NOT NULL,
+ FOREIGN KEY (`player_id`) REFERENCES `players`(`id`) ON UPDATE no action ON DELETE cascade
+);
diff --git a/apps/web/src/app/api/player-stats/[playerId]/route.ts b/apps/web/src/app/api/player-stats/[playerId]/route.ts
new file mode 100644
index 00000000..c20df36e
--- /dev/null
+++ b/apps/web/src/app/api/player-stats/[playerId]/route.ts
@@ -0,0 +1,111 @@
+import { eq } from 'drizzle-orm'
+import { NextResponse } from 'next/server'
+import { db } from '@/db'
+import type { GameStatsBreakdown } from '@/db/schema/player-stats'
+import { playerStats } from '@/db/schema/player-stats'
+import { players } from '@/db/schema/players'
+import type { GetPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types'
+import { getViewerId } from '@/lib/viewer'
+
+/**
+ * GET /api/player-stats/[playerId]
+ *
+ * Fetches stats for a specific player (must be owned by current user).
+ */
+export async function GET(_request: Request, { params }: { params: { playerId: string } }) {
+ try {
+ const { playerId } = params
+
+ // 1. Authenticate user
+ const viewerId = await getViewerId()
+ if (!viewerId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // 2. Verify player belongs to user
+ const player = await db
+ .select()
+ .from(players)
+ .where(eq(players.id, playerId))
+ .limit(1)
+ .then((rows) => rows[0])
+
+ if (!player) {
+ return NextResponse.json({ error: 'Player not found' }, { status: 404 })
+ }
+
+ if (player.userId !== viewerId) {
+ return NextResponse.json(
+ { error: 'Forbidden: player belongs to another user' },
+ { status: 403 }
+ )
+ }
+
+ // 3. Fetch player stats
+ const stats = await db
+ .select()
+ .from(playerStats)
+ .where(eq(playerStats.playerId, playerId))
+ .limit(1)
+ .then((rows) => rows[0])
+
+ const playerStatsData: PlayerStatsData = stats
+ ? convertToPlayerStatsData(stats)
+ : createDefaultPlayerStats(playerId)
+
+ // 4. Return response
+ const response: GetPlayerStatsResponse = {
+ stats: playerStatsData,
+ }
+
+ return NextResponse.json(response)
+ } catch (error) {
+ console.error('❌ Failed to fetch player stats:', error)
+ return NextResponse.json(
+ {
+ error: 'Failed to fetch player stats',
+ details: error instanceof Error ? error.message : String(error),
+ },
+ { status: 500 }
+ )
+ }
+}
+
+/**
+ * Convert DB record to PlayerStatsData
+ */
+function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData {
+ return {
+ playerId: dbStats.playerId,
+ gamesPlayed: dbStats.gamesPlayed,
+ totalWins: dbStats.totalWins,
+ totalLosses: dbStats.totalLosses,
+ bestTime: dbStats.bestTime,
+ highestAccuracy: dbStats.highestAccuracy,
+ favoriteGameType: dbStats.favoriteGameType,
+ gameStats: (dbStats.gameStats as Record) || {},
+ lastPlayedAt: dbStats.lastPlayedAt,
+ createdAt: dbStats.createdAt,
+ updatedAt: dbStats.updatedAt,
+ }
+}
+
+/**
+ * Create default player stats for new player
+ */
+function createDefaultPlayerStats(playerId: string): PlayerStatsData {
+ const now = new Date()
+ return {
+ playerId,
+ gamesPlayed: 0,
+ totalWins: 0,
+ totalLosses: 0,
+ bestTime: null,
+ highestAccuracy: 0,
+ favoriteGameType: null,
+ gameStats: {},
+ lastPlayedAt: null,
+ createdAt: now,
+ updatedAt: now,
+ }
+}
diff --git a/apps/web/src/app/api/player-stats/record-game/route.ts b/apps/web/src/app/api/player-stats/record-game/route.ts
new file mode 100644
index 00000000..c51dbf2b
--- /dev/null
+++ b/apps/web/src/app/api/player-stats/record-game/route.ts
@@ -0,0 +1,277 @@
+import { eq } from 'drizzle-orm'
+import { NextResponse } from 'next/server'
+import { db } from '@/db'
+import type { GameStatsBreakdown } from '@/db/schema/player-stats'
+import { playerStats } from '@/db/schema/player-stats'
+import type {
+ GameResult,
+ PlayerGameResult,
+ PlayerStatsData,
+ RecordGameRequest,
+ RecordGameResponse,
+ StatsUpdate,
+} from '@/lib/arcade/stats/types'
+import { getViewerId } from '@/lib/viewer'
+
+/**
+ * POST /api/player-stats/record-game
+ *
+ * Records a game result and updates player stats for all participants.
+ * Supports cooperative games (team wins/losses) and competitive games.
+ */
+export async function POST(request: Request) {
+ try {
+ // 1. Authenticate user
+ const viewerId = await getViewerId()
+ if (!viewerId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // 2. Parse and validate request
+ const body: RecordGameRequest = await request.json()
+ const { gameResult } = body
+
+ if (!gameResult || !gameResult.playerResults || gameResult.playerResults.length === 0) {
+ return NextResponse.json(
+ { error: 'Invalid game result: playerResults required' },
+ { status: 400 }
+ )
+ }
+
+ if (!gameResult.gameType) {
+ return NextResponse.json({ error: 'Invalid game result: gameType required' }, { status: 400 })
+ }
+
+ // 3. Process each player's result
+ const updates: StatsUpdate[] = []
+
+ for (const playerResult of gameResult.playerResults) {
+ const update = await recordPlayerResult(gameResult, playerResult)
+ updates.push(update)
+ }
+
+ // 4. Return success response
+ const response: RecordGameResponse = {
+ success: true,
+ updates,
+ }
+
+ return NextResponse.json(response)
+ } catch (error) {
+ console.error('❌ Failed to record game result:', error)
+ return NextResponse.json(
+ {
+ error: 'Failed to record game result',
+ details: error instanceof Error ? error.message : String(error),
+ },
+ { status: 500 }
+ )
+ }
+}
+
+/**
+ * Records stats for a single player's game result
+ */
+async function recordPlayerResult(
+ gameResult: GameResult,
+ playerResult: PlayerGameResult
+): Promise {
+ const { playerId } = playerResult
+
+ // 1. Fetch or create player stats
+ const existingStats = await db
+ .select()
+ .from(playerStats)
+ .where(eq(playerStats.playerId, playerId))
+ .limit(1)
+ .then((rows) => rows[0])
+
+ const previousStats: PlayerStatsData = existingStats
+ ? convertToPlayerStatsData(existingStats)
+ : createDefaultPlayerStats(playerId)
+
+ // 2. Calculate new stats
+ const newStats: PlayerStatsData = { ...previousStats }
+
+ // Always increment games played
+ newStats.gamesPlayed++
+
+ // Handle wins/losses (cooperative vs competitive)
+ if (gameResult.metadata?.isTeamVictory !== undefined) {
+ // Cooperative game: all players share outcome
+ if (playerResult.won) {
+ newStats.totalWins++
+ } else {
+ newStats.totalLosses++
+ }
+ } else {
+ // Competitive/Solo: individual outcome
+ if (playerResult.won) {
+ newStats.totalWins++
+ } else {
+ newStats.totalLosses++
+ }
+ }
+
+ // Update best time (if provided and improved)
+ if (playerResult.completionTime) {
+ if (!newStats.bestTime || playerResult.completionTime < newStats.bestTime) {
+ newStats.bestTime = playerResult.completionTime
+ }
+ }
+
+ // Update highest accuracy (if provided and improved)
+ if (playerResult.accuracy !== undefined && playerResult.accuracy > newStats.highestAccuracy) {
+ newStats.highestAccuracy = playerResult.accuracy
+ }
+
+ // Update per-game stats (JSON)
+ const gameType = gameResult.gameType
+ const currentGameStats: GameStatsBreakdown = newStats.gameStats[gameType] || {
+ gamesPlayed: 0,
+ wins: 0,
+ losses: 0,
+ bestTime: null,
+ highestAccuracy: 0,
+ averageScore: 0,
+ lastPlayed: 0,
+ }
+
+ currentGameStats.gamesPlayed++
+ if (playerResult.won) {
+ currentGameStats.wins++
+ } else {
+ currentGameStats.losses++
+ }
+
+ // Update game-specific best time
+ if (playerResult.completionTime) {
+ if (!currentGameStats.bestTime || playerResult.completionTime < currentGameStats.bestTime) {
+ currentGameStats.bestTime = playerResult.completionTime
+ }
+ }
+
+ // Update game-specific highest accuracy
+ if (
+ playerResult.accuracy !== undefined &&
+ playerResult.accuracy > currentGameStats.highestAccuracy
+ ) {
+ currentGameStats.highestAccuracy = playerResult.accuracy
+ }
+
+ // Update average score
+ if (playerResult.score !== undefined) {
+ const previousTotal = currentGameStats.averageScore * (currentGameStats.gamesPlayed - 1)
+ currentGameStats.averageScore =
+ (previousTotal + playerResult.score) / currentGameStats.gamesPlayed
+ }
+
+ currentGameStats.lastPlayed = gameResult.completedAt
+
+ newStats.gameStats[gameType] = currentGameStats
+
+ // Update favorite game type (most played)
+ newStats.favoriteGameType = getMostPlayedGame(newStats.gameStats)
+
+ // Update timestamps
+ newStats.lastPlayedAt = new Date(gameResult.completedAt)
+ newStats.updatedAt = new Date()
+
+ // 3. Save to database
+ if (existingStats) {
+ // Update existing record
+ await db
+ .update(playerStats)
+ .set({
+ gamesPlayed: newStats.gamesPlayed,
+ totalWins: newStats.totalWins,
+ totalLosses: newStats.totalLosses,
+ bestTime: newStats.bestTime,
+ highestAccuracy: newStats.highestAccuracy,
+ favoriteGameType: newStats.favoriteGameType,
+ gameStats: newStats.gameStats as any, // Drizzle JSON type
+ lastPlayedAt: newStats.lastPlayedAt,
+ updatedAt: newStats.updatedAt,
+ })
+ .where(eq(playerStats.playerId, playerId))
+ } else {
+ // Insert new record
+ await db.insert(playerStats).values({
+ playerId: newStats.playerId,
+ gamesPlayed: newStats.gamesPlayed,
+ totalWins: newStats.totalWins,
+ totalLosses: newStats.totalLosses,
+ bestTime: newStats.bestTime,
+ highestAccuracy: newStats.highestAccuracy,
+ favoriteGameType: newStats.favoriteGameType,
+ gameStats: newStats.gameStats as any,
+ lastPlayedAt: newStats.lastPlayedAt,
+ createdAt: newStats.createdAt,
+ updatedAt: newStats.updatedAt,
+ })
+ }
+
+ // 4. Return update summary
+ return {
+ playerId,
+ previousStats,
+ newStats,
+ changes: {
+ gamesPlayed: newStats.gamesPlayed - previousStats.gamesPlayed,
+ wins: newStats.totalWins - previousStats.totalWins,
+ losses: newStats.totalLosses - previousStats.totalLosses,
+ },
+ }
+}
+
+/**
+ * Convert DB record to PlayerStatsData
+ */
+function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData {
+ return {
+ playerId: dbStats.playerId,
+ gamesPlayed: dbStats.gamesPlayed,
+ totalWins: dbStats.totalWins,
+ totalLosses: dbStats.totalLosses,
+ bestTime: dbStats.bestTime,
+ highestAccuracy: dbStats.highestAccuracy,
+ favoriteGameType: dbStats.favoriteGameType,
+ gameStats: (dbStats.gameStats as Record) || {},
+ lastPlayedAt: dbStats.lastPlayedAt,
+ createdAt: dbStats.createdAt,
+ updatedAt: dbStats.updatedAt,
+ }
+}
+
+/**
+ * Create default player stats for new player
+ */
+function createDefaultPlayerStats(playerId: string): PlayerStatsData {
+ const now = new Date()
+ return {
+ playerId,
+ gamesPlayed: 0,
+ totalWins: 0,
+ totalLosses: 0,
+ bestTime: null,
+ highestAccuracy: 0,
+ favoriteGameType: null,
+ gameStats: {},
+ lastPlayedAt: null,
+ createdAt: now,
+ updatedAt: now,
+ }
+}
+
+/**
+ * Determine most-played game from game stats
+ */
+function getMostPlayedGame(gameStats: Record): string | null {
+ const games = Object.entries(gameStats)
+ if (games.length === 0) return null
+
+ return games.reduce((mostPlayed, [gameType, stats]) => {
+ const mostPlayedStats = gameStats[mostPlayed]
+ return stats.gamesPlayed > (mostPlayedStats?.gamesPlayed || 0) ? gameType : mostPlayed
+ }, games[0][0])
+}
diff --git a/apps/web/src/app/api/player-stats/route.ts b/apps/web/src/app/api/player-stats/route.ts
new file mode 100644
index 00000000..9cce5e62
--- /dev/null
+++ b/apps/web/src/app/api/player-stats/route.ts
@@ -0,0 +1,102 @@
+import { eq } from 'drizzle-orm'
+import { NextResponse } from 'next/server'
+import { db } from '@/db'
+import type { GameStatsBreakdown } from '@/db/schema/player-stats'
+import { playerStats } from '@/db/schema/player-stats'
+import { players } from '@/db/schema/players'
+import type { GetAllPlayerStatsResponse, PlayerStatsData } from '@/lib/arcade/stats/types'
+import { getViewerId } from '@/lib/viewer'
+
+/**
+ * GET /api/player-stats
+ *
+ * Fetches stats for all of the current user's players.
+ */
+export async function GET() {
+ try {
+ // 1. Authenticate user
+ const viewerId = await getViewerId()
+ if (!viewerId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // 2. Fetch all user's players
+ const userPlayers = await db.select().from(players).where(eq(players.userId, viewerId))
+
+ const playerIds = userPlayers.map((p) => p.id)
+
+ // 3. Fetch stats for all players
+ const allStats: PlayerStatsData[] = []
+
+ for (const playerId of playerIds) {
+ const stats = await db
+ .select()
+ .from(playerStats)
+ .where(eq(playerStats.playerId, playerId))
+ .limit(1)
+ .then((rows) => rows[0])
+
+ if (stats) {
+ allStats.push(convertToPlayerStatsData(stats))
+ } else {
+ // Player exists but has no stats yet
+ allStats.push(createDefaultPlayerStats(playerId))
+ }
+ }
+
+ // 4. Return response
+ const response: GetAllPlayerStatsResponse = {
+ playerStats: allStats,
+ }
+
+ return NextResponse.json(response)
+ } catch (error) {
+ console.error('❌ Failed to fetch player stats:', error)
+ return NextResponse.json(
+ {
+ error: 'Failed to fetch player stats',
+ details: error instanceof Error ? error.message : String(error),
+ },
+ { status: 500 }
+ )
+ }
+}
+
+/**
+ * Convert DB record to PlayerStatsData
+ */
+function convertToPlayerStatsData(dbStats: typeof playerStats.$inferSelect): PlayerStatsData {
+ return {
+ playerId: dbStats.playerId,
+ gamesPlayed: dbStats.gamesPlayed,
+ totalWins: dbStats.totalWins,
+ totalLosses: dbStats.totalLosses,
+ bestTime: dbStats.bestTime,
+ highestAccuracy: dbStats.highestAccuracy,
+ favoriteGameType: dbStats.favoriteGameType,
+ gameStats: (dbStats.gameStats as Record) || {},
+ lastPlayedAt: dbStats.lastPlayedAt,
+ createdAt: dbStats.createdAt,
+ updatedAt: dbStats.updatedAt,
+ }
+}
+
+/**
+ * Create default player stats for new player
+ */
+function createDefaultPlayerStats(playerId: string): PlayerStatsData {
+ const now = new Date()
+ return {
+ playerId,
+ gamesPlayed: 0,
+ totalWins: 0,
+ totalLosses: 0,
+ bestTime: null,
+ highestAccuracy: 0,
+ favoriteGameType: null,
+ gameStats: {},
+ lastPlayedAt: null,
+ createdAt: now,
+ updatedAt: now,
+ }
+}
diff --git a/apps/web/src/app/create/page.tsx b/apps/web/src/app/create/flashcards/page.tsx
similarity index 100%
rename from apps/web/src/app/create/page.tsx
rename to apps/web/src/app/create/flashcards/page.tsx
diff --git a/apps/web/src/app/games/page.tsx b/apps/web/src/app/games/page.tsx
index f3a54f79..6bebc1fa 100644
--- a/apps/web/src/app/games/page.tsx
+++ b/apps/web/src/app/games/page.tsx
@@ -5,13 +5,15 @@ import useEmblaCarousel from 'embla-carousel-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
-import React, { useCallback, useEffect, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
+import { GamePreview } from '@/components/GamePreview'
import { getAvailableGames } from '@/lib/arcade/game-registry'
import { css } from '../../../styled-system/css'
import { useFullscreen } from '../../contexts/FullscreenContext'
import { useGameMode } from '../../contexts/GameModeContext'
import { useUserProfile } from '../../contexts/UserProfileContext'
+import { useAllPlayerStats } from '@/hooks/usePlayerStats'
function GamesPageContent() {
const t = useTranslations('games')
@@ -27,11 +29,28 @@ function GamesPageContent() {
return aTime - bTime
})
+ // Fetch per-player stats
+ const { data: playerStatsArray, isLoading: statsLoading } = useAllPlayerStats()
+
+ // Create a map of playerId -> stats for easy lookup
+ const playerStatsMap = useMemo(() => {
+ const map = new Map()
+ if (playerStatsArray) {
+ for (const stats of playerStatsArray) {
+ map.set(stats.playerId, stats)
+ }
+ }
+ return map
+ }, [playerStatsArray])
+
// Get available games
const availableGames = getAvailableGames()
- // Check if user has any stats to show
- const hasStats = profile.gamesPlayed > 0
+ // Check if user has any stats to show (check if ANY player has stats)
+ const hasStats =
+ playerStatsArray &&
+ playerStatsArray.length > 0 &&
+ playerStatsArray.some((s) => s.gamesPlayed > 0)
// Embla carousel setup for games hero carousel with autoplay
const [gamesEmblaRef, gamesEmblaApi] = useEmblaCarousel(
@@ -113,859 +132,790 @@ function GamesPageContent() {
})}
/>
+ {/* Enter Arcade Button */}
+
+
+
+
{/* Games Hero Carousel - Full Width */}
-
- 🎮 Available Games
-
-
- {/* Carousel */}
+
+
+ {/* Dynamic Game Cards */}
+ {availableGames.map((game, index) => {
+ const isActive = index === gamesSelectedIndex
+ const manifest = game.manifest
+ const GameComp = game.GameComponent
+ const Provider = game.Provider
+
+ return (
+
gamesEmblaApi?.scrollTo(index)}
+ data-element={`game-card-${manifest.name}`}
+ >
+ {/* Live Game Demo */}
+
+
+
+
+ {/* Overlay with game info - shorter gradient that overlaps game */}
+
+ {/* Game Icon */}
+
+ {manifest.icon}
+
+
+ {/* Game Title */}
+
+ {manifest.displayName}
+
+
+ {/* Game Info Badges */}
+
+
+ {manifest.difficulty}
+
+
+ {manifest.maxPlayers === 1
+ ? '1 Player'
+ : `1-${manifest.maxPlayers} Players`}
+
+
+
+ {/* Description */}
+
+ {manifest.description}
+
+
+
+ )
+ })}
+
+
+
+ {/* Navigation Dots with Game Icons */}
- {availableGames.map((game) => {
- const gameIndex = availableGames.indexOf(game)
- const isActive = gameIndex === gamesSelectedIndex
-
- return (
-
-
-
- {/* Dark gradient overlay for readability */}
-
-
- {/* Content */}
-
- {/* Icon and Title */}
-
-
- {game.manifest.icon}
-
-
-
- {game.manifest.displayName}
-
-
- {game.manifest.difficulty} •{' '}
- {game.manifest.maxPlayers === 1
- ? 'Solo'
- : `1-${game.manifest.maxPlayers} Players`}
-
-
-
-
- {/* Description */}
-
- {game.manifest.description}
-
-
- {/* Chips */}
-
- {game.manifest.chips.map((chip) => (
-
- {chip}
-
- ))}
-
-
-
-
-
- )
- })}
+ {availableGames.map((game, index) => (
+
+ ))}
-
- {/* Navigation Dots */}
-
- {availableGames.map((game, index) => (
-
- ))}
-
- {/* Rest of content - constrained width */}
-
- {/* Enter Arcade Button */}
-
+ {/* Character Showcase Section - Only show if user has stats */}
+ {hasStats && (
+ <>
+ {/* Character Showcase Header */}
- {/* Gradient background */}
-
-
- {t('enterArcade.title')}
+ {t('champions.title')}
-
- {t('enterArcade.description')}
-
-
-
-
-
- {t('enterArcade.subtitle')}
+ {t('champions.subtitle')}
-
-
- {/* Character Showcase Section - Only show if user has stats */}
- {hasStats && (
- <>
- {/* Character Showcase Header */}
+ {/* Player Carousel */}
+
+
+ {/* Dynamic Player Character Cards */}
+ {allPlayers.map((player, index) => {
+ const isActive = index === selectedIndex
+
+ // Rotate through different color schemes for visual variety
+ const colorSchemes = [
+ {
+ border: 'rgba(59, 130, 246, 0.3)',
+ shadow: 'rgba(59, 130, 246, 0.1)',
+ gradient: 'linear-gradient(90deg, #3b82f6, #1d4ed8)',
+ statBg: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
+ statBorder: 'blue.200',
+ statColor: 'blue.800',
+ levelColor: 'blue.700',
+ },
+ {
+ border: 'rgba(139, 92, 246, 0.3)',
+ shadow: 'rgba(139, 92, 246, 0.1)',
+ gradient: 'linear-gradient(90deg, #8b5cf6, #7c3aed)',
+ statBg: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
+ statBorder: 'purple.200',
+ statColor: 'purple.800',
+ levelColor: 'purple.700',
+ },
+ {
+ border: 'rgba(16, 185, 129, 0.3)',
+ shadow: 'rgba(16, 185, 129, 0.1)',
+ gradient: 'linear-gradient(90deg, #10b981, #059669)',
+ statBg: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
+ statBorder: 'green.200',
+ statColor: 'green.800',
+ levelColor: 'green.700',
+ },
+ {
+ border: 'rgba(245, 158, 11, 0.3)',
+ shadow: 'rgba(245, 158, 11, 0.1)',
+ gradient: 'linear-gradient(90deg, #f59e0b, #d97706)',
+ statBg: 'linear-gradient(135deg, #fef3c7, #fde68a)',
+ statBorder: 'yellow.200',
+ statColor: 'yellow.800',
+ levelColor: 'yellow.700',
+ },
+ ]
+ const theme = colorSchemes[index % colorSchemes.length]
+
+ // Get per-player stats
+ const playerStats = playerStatsMap.get(player.id)
+ const gamesPlayed = playerStats?.gamesPlayed || 0
+ const totalWins = playerStats?.totalWins || 0
+
+ return (
+
emblaApi?.scrollTo(index)}
+ data-element={`player-card-${index}`}
+ >
+ {/* Gradient Border */}
+
+
+ {/* Character Display */}
+
+
+ {player.emoji}
+
+
+ {player.name}
+
+
+
+ {/* Stats */}
+
+
+
+ {gamesPlayed}
+
+
+ {t('champions.stats.gamesPlayed')}
+
+
+
+
+
+ {totalWins}
+
+
+ {t('champions.stats.victories')}
+
+
+
+
+ {/* Level Progress */}
+
+
+
+ {t('champions.stats.level', {
+ level: Math.floor(gamesPlayed / 5) + 1,
+ })}
+
+
+ {t('champions.stats.xp', {
+ current: gamesPlayed % 5,
+ total: 5,
+ })}
+
+
+
+
+
+ {/* Quick Customize Button */}
+
+
+ )
+ })}
+
+
+
+ {/* Navigation Dots */}
+
+ {allPlayers.map((player, index) => (
+
+ ))}
+
+
+
+ >
+ )}
+
+ {/* Character vs Character Dashboard - Only show if user has stats */}
+ {hasStats && (
+
+
+ {/* Head-to-Head Stats */}
+
-
- {t('champions.title')}
-
+ {t('dashboard.headToHead.title')}
+
- {t('champions.subtitle')}
+ {t('dashboard.headToHead.subtitle')}
- {/* Player Carousel */}
-
-
- {/* Dynamic Player Character Cards */}
- {allPlayers.map((player, index) => {
- const isActive = index === selectedIndex
-
- // Rotate through different color schemes for visual variety
- const colorSchemes = [
- {
- border: 'rgba(59, 130, 246, 0.3)',
- shadow: 'rgba(59, 130, 246, 0.1)',
- gradient: 'linear-gradient(90deg, #3b82f6, #1d4ed8)',
- statBg: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
- statBorder: 'blue.200',
- statColor: 'blue.800',
- levelColor: 'blue.700',
- },
- {
- border: 'rgba(139, 92, 246, 0.3)',
- shadow: 'rgba(139, 92, 246, 0.1)',
- gradient: 'linear-gradient(90deg, #8b5cf6, #7c3aed)',
- statBg: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
- statBorder: 'purple.200',
- statColor: 'purple.800',
- levelColor: 'purple.700',
- },
- {
- border: 'rgba(16, 185, 129, 0.3)',
- shadow: 'rgba(16, 185, 129, 0.1)',
- gradient: 'linear-gradient(90deg, #10b981, #059669)',
- statBg: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
- statBorder: 'green.200',
- statColor: 'green.800',
- levelColor: 'green.700',
- },
- {
- border: 'rgba(245, 158, 11, 0.3)',
- shadow: 'rgba(245, 158, 11, 0.1)',
- gradient: 'linear-gradient(90deg, #f59e0b, #d97706)',
- statBg: 'linear-gradient(135deg, #fef3c7, #fde68a)',
- statBorder: 'yellow.200',
- statColor: 'yellow.800',
- levelColor: 'yellow.700',
- },
- ]
- const theme = colorSchemes[index % colorSchemes.length]
-
- return (
-
emblaApi?.scrollTo(index)}
- data-element={`player-card-${index}`}
- >
- {/* Gradient Border */}
-
-
- {/* Character Display */}
-
-
- {player.emoji}
-
-
- {player.name}
-
-
-
- {/* Stats */}
-
-
-
- {profile.gamesPlayed}
-
-
- {t('champions.stats.gamesPlayed')}
-
-
-
-
-
- {profile.totalWins}
-
-
- {t('champions.stats.victories')}
-
-
-
-
- {/* Level Progress */}
-
-
-
- {t('champions.stats.level', {
- level: Math.floor(profile.gamesPlayed / 5) + 1,
- })}
-
-
- {t('champions.stats.xp', {
- current: profile.gamesPlayed % 5,
- total: 5,
- })}
-
-
-
-
-
- {/* Quick Customize Button */}
-
-
- )
- })}
-
-
-
- {/* Navigation Dots */}
-
- {allPlayers.map((player, index) => (
-
- ))}
-
-
-
- >
- )}
-
- {/* Character vs Character Dashboard - Only show if user has stats */}
- {hasStats && (
-
-
- {/* Head-to-Head Stats */}
-
-
-
- {t('dashboard.headToHead.title')}
-
-
- {t('dashboard.headToHead.subtitle')}
-
-
+ {allPlayers.slice(0, 2).map((player, idx) => {
+ const playerStats = playerStatsMap.get(player.id)
+ const totalWins = playerStats?.totalWins || 0
-
- {allPlayers.slice(0, 2).map((player, idx) => (
+ return (
- {Math.floor(profile.totalWins * (idx === 0 ? 0.6 : 0.4))}
+ {totalWins}
)}
- ))}
-
+ )
+ })}
+
-
+ {t('dashboard.headToHead.lastPlayed')}
+
+
+
+ {/* Recent Achievements */}
+
+
+
+ {t('dashboard.achievements.title')}
+
+
- {t('dashboard.headToHead.lastPlayed')}
-
+ {t('dashboard.achievements.subtitle')}
+
- {/* Recent Achievements */}
-
-
- {t('dashboard.achievements.title')}
-
-
- {t('dashboard.achievements.subtitle')}
-
-
-
-
- {allPlayers.slice(0, 2).map((player, idx) => (
-
-
{player.emoji}
-
-
- {idx === 0
- ? t('dashboard.achievements.firstWin.title')
- : t('dashboard.achievements.speedDemon.title')}
-
-
- {idx === 0
- ? t('dashboard.achievements.firstWin.description')
- : t('dashboard.achievements.speedDemon.description')}
-
-
-
- ))}
-
-
-
- {/* Challenge System */}
-
-
-
- {t('dashboard.challenges.title')}
-
-
- {t('dashboard.challenges.subtitle')}
-
-
-
- {allPlayers.length >= 2 && (
+ {allPlayers.slice(0, 2).map((player, idx) => (
-
-
{allPlayers[0].emoji}
-
{player.emoji}
+
+
- {t('dashboard.challenges.challengesText')}
-
- {allPlayers[1].emoji}
-
-
- "{t('dashboard.challenges.exampleChallenge')}"
-
-
- {t('dashboard.challenges.currentBest', { score: 850 })}
+ {idx === 0
+ ? t('dashboard.achievements.firstWin.title')
+ : t('dashboard.achievements.speedDemon.title')}
+
+
+ {idx === 0
+ ? t('dashboard.achievements.firstWin.description')
+ : t('dashboard.achievements.speedDemon.description')}
+
- )}
-
-
+ ))}
+
+ {/* Challenge System */}
+
+
+
+ {t('dashboard.challenges.title')}
+
+
+ {t('dashboard.challenges.subtitle')}
+
+
+
+ {allPlayers.length >= 2 && (
+
+
+ {allPlayers[0].emoji}
+
+ {t('dashboard.challenges.challengesText')}
+
+ {allPlayers[1].emoji}
+
+
+ "{t('dashboard.challenges.exampleChallenge')}"
+
+
+ {t('dashboard.challenges.currentBest', { score: 850 })}
+
+
+ )}
+
+
+
- )}
-
+
+ )}
)
}
diff --git a/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx b/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx
index 47e6924f..4264ae01 100644
--- a/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx
+++ b/apps/web/src/arcade-games/matching/components/ResultsPhase.tsx
@@ -1,15 +1,19 @@
'use client'
+import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../../styled-system/css'
import { useGameMode } from '@/contexts/GameModeContext'
import { useMatching } from '../Provider'
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
+import { useRecordGameResult } from '@/hooks/useRecordGameResult'
+import type { GameResult } from '@/lib/arcade/stats/types'
export function ResultsPhase() {
const router = useRouter()
const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
+ const { mutate: recordGameResult } = useRecordGameResult()
// Get active player data array
const activePlayerData = Array.from(activePlayerIds)
@@ -28,6 +32,45 @@ export function ResultsPhase() {
const multiplayerResult =
gameMode === 'multiplayer' ? getMultiplayerWinner(state, activePlayers) : null
+ // Record game stats when results are shown
+ useEffect(() => {
+ if (!state.gameEndTime || !state.gameStartTime) return
+
+ // Build game result
+ const gameResult: GameResult = {
+ gameType: 'matching',
+ playerResults: activePlayerData.map((player) => {
+ const isWinner = gameMode === 'single' || multiplayerResult?.winners.includes(player.id)
+ const score =
+ gameMode === 'multiplayer'
+ ? multiplayerResult?.scores[player.id] || 0
+ : state.matchedPairs
+
+ return {
+ playerId: player.id,
+ won: isWinner || false,
+ score,
+ accuracy: analysis.statistics.accuracy / 100, // Convert percentage to 0-1
+ completionTime: gameTime,
+ metrics: {
+ moves: state.moves,
+ matchedPairs: state.matchedPairs,
+ },
+ }
+ }),
+ completedAt: state.gameEndTime,
+ duration: gameTime,
+ metadata: {
+ gameMode,
+ starRating: analysis.starRating,
+ grade: analysis.grade,
+ },
+ }
+
+ console.log('📊 Recording matching game result:', gameResult)
+ recordGameResult(gameResult)
+ }, []) // Empty deps - only record once when component mounts
+
return (
players.id, { onDelete: 'cascade' }),
+
+ /** Total number of games played across all game types */
+ gamesPlayed: integer('games_played').notNull().default(0),
+
+ /** Total number of games won */
+ totalWins: integer('total_wins').notNull().default(0),
+
+ /** Total number of games lost */
+ totalLosses: integer('total_losses').notNull().default(0),
+
+ /** Best completion time in milliseconds (across all games) */
+ bestTime: integer('best_time'),
+
+ /** Highest accuracy percentage (0.0 - 1.0, across all games) */
+ highestAccuracy: real('highest_accuracy').notNull().default(0),
+
+ /** Player's most-played game type */
+ favoriteGameType: text('favorite_game_type'),
+
+ /**
+ * Per-game statistics breakdown (JSON)
+ *
+ * Structure:
+ * {
+ * "matching": {
+ * gamesPlayed: 10,
+ * wins: 5,
+ * losses: 5,
+ * bestTime: 45000,
+ * highestAccuracy: 0.95,
+ * averageScore: 12.5,
+ * lastPlayed: 1704326400000
+ * },
+ * "complement-race": { ... },
+ * ...
+ * }
+ */
+ gameStats: text('game_stats', { mode: 'json' })
+ .notNull()
+ .default('{}')
+ .$type
>(),
+
+ /** When this player last played any game */
+ lastPlayedAt: integer('last_played_at', { mode: 'timestamp' }),
+
+ /** When this record was created */
+ createdAt: integer('created_at', { mode: 'timestamp' })
+ .notNull()
+ .$defaultFn(() => new Date()),
+
+ /** When this record was last updated */
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
+ .notNull()
+ .$defaultFn(() => new Date()),
+})
+
+/**
+ * Per-game stats breakdown stored in JSON
+ */
+export interface GameStatsBreakdown {
+ gamesPlayed: number
+ wins: number
+ losses: number
+ bestTime: number | null
+ highestAccuracy: number
+ averageScore: number
+ lastPlayed: number // timestamp
+}
+
+export type PlayerStats = typeof playerStats.$inferSelect
+export type NewPlayerStats = typeof playerStats.$inferInsert
diff --git a/apps/web/src/hooks/usePlayerStats.ts b/apps/web/src/hooks/usePlayerStats.ts
new file mode 100644
index 00000000..cfb133d9
--- /dev/null
+++ b/apps/web/src/hooks/usePlayerStats.ts
@@ -0,0 +1,87 @@
+'use client'
+
+import { useQuery } from '@tanstack/react-query'
+import type {
+ GetAllPlayerStatsResponse,
+ GetPlayerStatsResponse,
+ PlayerStatsData,
+} from '@/lib/arcade/stats/types'
+import { api } from '@/lib/queryClient'
+
+/**
+ * Hook to fetch stats for a specific player or all user's players
+ *
+ * Usage:
+ * ```tsx
+ * // Fetch all players' stats
+ * const { data, isLoading } = usePlayerStats()
+ * // data is PlayerStatsData[]
+ *
+ * // Fetch specific player's stats
+ * const { data, isLoading } = usePlayerStats('player-id')
+ * // data is PlayerStatsData
+ * ```
+ */
+export function usePlayerStats(playerId?: string) {
+ return useQuery({
+ queryKey: playerId ? ['player-stats', playerId] : ['player-stats'],
+ queryFn: async () => {
+ const url = playerId ? `player-stats/${playerId}` : 'player-stats'
+
+ const res = await api(url)
+ if (!res.ok) {
+ throw new Error('Failed to fetch player stats')
+ }
+
+ const data: GetPlayerStatsResponse | GetAllPlayerStatsResponse = await res.json()
+
+ // Return single player stats or array of all stats
+ return 'stats' in data ? data.stats : data.playerStats
+ },
+ })
+}
+
+/**
+ * Hook to fetch stats for all user's players (typed as array)
+ *
+ * Convenience wrapper around usePlayerStats() with better typing.
+ */
+export function useAllPlayerStats() {
+ const query = useQuery({
+ queryKey: ['player-stats'],
+ queryFn: async () => {
+ const res = await api('player-stats')
+ if (!res.ok) {
+ throw new Error('Failed to fetch player stats')
+ }
+
+ const data: GetAllPlayerStatsResponse = await res.json()
+ return data.playerStats
+ },
+ })
+
+ return query
+}
+
+/**
+ * Hook to fetch stats for a specific player (typed as single object)
+ *
+ * Convenience wrapper around usePlayerStats() with better typing.
+ */
+export function useSinglePlayerStats(playerId: string) {
+ const query = useQuery({
+ queryKey: ['player-stats', playerId],
+ queryFn: async () => {
+ const res = await api(`player-stats/${playerId}`)
+ if (!res.ok) {
+ throw new Error('Failed to fetch player stats')
+ }
+
+ const data: GetPlayerStatsResponse = await res.json()
+ return data.stats
+ },
+ enabled: !!playerId, // Only run if playerId is provided
+ })
+
+ return query
+}
diff --git a/apps/web/src/hooks/useRecordGameResult.ts b/apps/web/src/hooks/useRecordGameResult.ts
new file mode 100644
index 00000000..4854fdef
--- /dev/null
+++ b/apps/web/src/hooks/useRecordGameResult.ts
@@ -0,0 +1,51 @@
+'use client'
+
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import type { GameResult, RecordGameResponse } from '@/lib/arcade/stats/types'
+import { api } from '@/lib/queryClient'
+
+/**
+ * Hook to record a game result and update player stats
+ *
+ * Usage:
+ * ```tsx
+ * const { mutate: recordGame, isPending } = useRecordGameResult()
+ *
+ * recordGame(gameResult, {
+ * onSuccess: (updates) => {
+ * console.log('Stats recorded:', updates)
+ * }
+ * })
+ * ```
+ */
+export function useRecordGameResult() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (gameResult: GameResult): Promise => {
+ const res = await api('player-stats/record-game', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ gameResult }),
+ })
+
+ if (!res.ok) {
+ const error = await res.json().catch(() => ({ error: 'Failed to record game result' }))
+ throw new Error(error.error || 'Failed to record game result')
+ }
+
+ return res.json()
+ },
+
+ onSuccess: (response) => {
+ // Invalidate player stats queries to trigger refetch
+ queryClient.invalidateQueries({ queryKey: ['player-stats'] })
+
+ console.log('✅ Game result recorded successfully:', response.updates)
+ },
+
+ onError: (error) => {
+ console.error('❌ Failed to record game result:', error)
+ },
+ })
+}
diff --git a/apps/web/src/lib/arcade/stats/types.ts b/apps/web/src/lib/arcade/stats/types.ts
new file mode 100644
index 00000000..2aa7004a
--- /dev/null
+++ b/apps/web/src/lib/arcade/stats/types.ts
@@ -0,0 +1,153 @@
+/**
+ * Universal game stats types
+ *
+ * These types are used across ALL arcade games to record player performance.
+ * Supports: solo, competitive, cooperative, and head-to-head game modes.
+ *
+ * See: .claude/GAME_STATS_COMPARISON.md for detailed cross-game analysis
+ */
+
+import type { GameStatsBreakdown } from '@/db/schema/player-stats'
+
+/**
+ * Standard game result that all arcade games must provide
+ *
+ * Supports:
+ * - 1-N players
+ * - Competitive (individual winners)
+ * - Cooperative (team wins/losses)
+ * - Solo completion
+ * - Head-to-head (2-player)
+ */
+export interface GameResult {
+ // Game identification
+ gameType: string // e.g., "matching", "complement-race", "memory-quiz"
+
+ // Player results (supports 1-N players)
+ playerResults: PlayerGameResult[]
+
+ // Timing
+ completedAt: number // timestamp
+ duration: number // milliseconds
+
+ // Optional game-specific data
+ metadata?: {
+ // For cooperative games (Memory Quiz, Card Sorting collaborative)
+ // When true: all players share win/loss outcome
+ isTeamVictory?: boolean
+
+ // For specific win conditions (Rithmomachia)
+ winCondition?: string // e.g., "HARMONY", "POINTS", "TIMEOUT"
+
+ // For game modes
+ gameMode?: string // e.g., "solo", "competitive", "cooperative"
+
+ // Extensible for other game-specific info
+ [key: string]: unknown
+ }
+}
+
+/**
+ * Individual player result within a game
+ */
+export interface PlayerGameResult {
+ playerId: string
+
+ // Outcome
+ won: boolean // For cooperative games: all players have same value
+ placement?: number // 1st, 2nd, 3rd place (for tournaments with 3+ players)
+
+ // Performance
+ score?: number
+ accuracy?: number // 0.0 - 1.0
+ completionTime?: number // milliseconds (player-specific)
+
+ // Game-specific metrics (stored as JSON in DB)
+ metrics?: {
+ // Matching
+ moves?: number
+ matchedPairs?: number
+ difficulty?: number
+
+ // Complement Race
+ streak?: number
+ correctAnswers?: number
+ totalQuestions?: number
+
+ // Memory Quiz
+ correct?: number
+ incorrect?: number
+
+ // Card Sorting
+ exactMatches?: number
+ inversions?: number
+ lcsLength?: number
+
+ // Rithmomachia
+ capturedPieces?: number
+ points?: number
+
+ // Extensible for future games
+ [key: string]: unknown
+ }
+}
+
+/**
+ * Stats update returned from API after recording a game
+ */
+export interface StatsUpdate {
+ playerId: string
+ previousStats: PlayerStatsData
+ newStats: PlayerStatsData
+ changes: {
+ gamesPlayed: number
+ wins: number
+ losses: number
+ }
+}
+
+/**
+ * Complete player stats data (from DB)
+ */
+export interface PlayerStatsData {
+ playerId: string
+ gamesPlayed: number
+ totalWins: number
+ totalLosses: number
+ bestTime: number | null
+ highestAccuracy: number
+ favoriteGameType: string | null
+ gameStats: Record
+ lastPlayedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+/**
+ * Request body for recording a game result
+ */
+export interface RecordGameRequest {
+ gameResult: GameResult
+}
+
+/**
+ * Response from recording a game result
+ */
+export interface RecordGameResponse {
+ success: boolean
+ updates: StatsUpdate[]
+}
+
+/**
+ * Response from fetching player stats
+ */
+export interface GetPlayerStatsResponse {
+ stats: PlayerStatsData
+}
+
+/**
+ * Response from fetching all user's player stats
+ */
+export interface GetAllPlayerStatsResponse {
+ playerStats: PlayerStatsData[]
+}