diff --git a/apps/web/docs/MATCHING_PAIRS_AUDIT.md b/apps/web/docs/MATCHING_PAIRS_AUDIT.md
new file mode 100644
index 00000000..76fdc1ca
--- /dev/null
+++ b/apps/web/docs/MATCHING_PAIRS_AUDIT.md
@@ -0,0 +1,299 @@
+# Matching Pairs Battle - Pre-Migration Audit Results
+
+**Date**: 2025-01-16
+**Phase**: 1 - Pre-Migration Audit
+**Status**: Complete ✅
+
+---
+
+## Executive Summary
+
+**Canonical Location**: `/src/app/arcade/matching/` is clearly the more advanced, feature-complete version.
+
+**Key Findings**:
+- Arcade version has pause/resume, networked presence, better player ownership
+- Utils are **identical** between locations (can use either)
+- **ResultsPhase.tsx** needs manual merge (arcade layout + games Performance Analysis)
+- **7 files** currently import from `/games/matching/` - must update during migration
+
+---
+
+## File-by-File Comparison
+
+### Components
+
+#### 1. GameCard.tsx
+**Differences**: Arcade has helper function `getPlayerIndex()` to reduce code duplication
+**Decision**: ✅ Use arcade version (better code organization)
+
+#### 2. PlayerStatusBar.tsx
+**Differences**:
+- Arcade: Distinguishes "Your turn" vs "Their turn" based on player ownership
+- Arcade: Uses `useViewerId()` for authorization
+- Games: Shows only "Your turn" for all players
+**Decision**: ✅ Use arcade version (more feature-complete)
+
+#### 3. ResultsPhase.tsx
+**Differences**:
+- Arcade: Modern responsive layout, exits via `exitSession()` to `/arcade`
+- Games: Has unique "Performance Analysis" section (strengths/improvements)
+- Games: Simple navigation to `/games`
+**Decision**: ⚠️ MERGE REQUIRED
+- Keep arcade's layout, navigation, responsive design
+- **Add** Performance Analysis section from games version (lines 245-317)
+
+#### 4. SetupPhase.tsx
+**Differences**:
+- Arcade: Full pause/resume with config change warnings
+- Arcade: Uses action creators (setGameType, setDifficulty, setTurnTimer)
+- Arcade: Sophisticated "Resume Game" vs "Start Game" button logic
+- Games: Simple dispatch pattern, no pause/resume
+**Decision**: ✅ Use arcade version (much more advanced)
+
+#### 5. EmojiPicker.tsx
+**Differences**: None (files identical)
+**Decision**: ✅ Use arcade version (same as games)
+
+#### 6. GamePhase.tsx
+**Differences**:
+- Arcade: Passes hoverCard, viewerId, gameMode to MemoryGrid
+- Arcade: `enableMultiplayerPresence={true}`
+- Games: No multiplayer presence features
+**Decision**: ✅ Use arcade version (has networked presence)
+
+#### 7. MemoryPairsGame.tsx
+**Differences**:
+- Arcade: Provides onExitSession, onSetup, onNewGame callbacks
+- Arcade: Uses router for navigation
+- Games: Simple component with just gameName prop
+**Decision**: ✅ Use arcade version (better integration)
+
+### Utilities
+
+#### 1. cardGeneration.ts
+**Differences**: None (files identical)
+**Decision**: ✅ Use arcade version (same as games)
+
+#### 2. matchValidation.ts
+**Differences**: None (files identical)
+**Decision**: ✅ Use arcade version (same as games)
+
+#### 3. gameScoring.ts
+**Differences**: None (files identical)
+**Decision**: ✅ Use arcade version (same as games)
+
+### Context/Types
+
+#### types.ts
+**Differences**:
+- Arcade: PlayerMetadata properly typed (vs `any` in games)
+- Arcade: Better documentation for pause/resume state
+- Arcade: Hover state not optional (`playerHovers: {}` vs `playerHovers?: {}`)
+- Arcade: More complete MemoryPairsContextValue interface
+**Decision**: ✅ Use arcade version (better types)
+
+---
+
+## External Dependencies on `/games/matching/`
+
+Found **7 imports** that reference `/games/matching/`:
+
+1. `/src/components/nav/PlayerConfigDialog.tsx`
+ - Imports: `EmojiPicker`
+ - **Action**: Update to `@/arcade-games/matching/components/EmojiPicker`
+
+2. `/src/lib/arcade/game-configs.ts`
+ - Imports: `Difficulty, GameType` types
+ - **Action**: Update to `@/arcade-games/matching/types`
+
+3. `/src/lib/arcade/__tests__/arcade-session-integration.test.ts`
+ - Imports: `MemoryPairsState` type
+ - **Action**: Update to `@/arcade-games/matching/types`
+
+4. `/src/lib/arcade/validation/MatchingGameValidator.ts` (3 imports)
+ - Imports: `GameCard, MemoryPairsState, Player` types
+ - Imports: `generateGameCards` util
+ - Imports: `canFlipCard, validateMatch` utils
+ - **Action**: Will be moved to `/src/arcade-games/matching/Validator.ts` in Phase 3
+ - Update imports to local `./types` and `./utils/*`
+
+---
+
+## Migration Strategy
+
+### Canonical Source
+**Use**: `/src/app/arcade/matching/` as the base for all files
+
+**Exception**: Merge Performance Analysis from `/src/app/games/matching/components/ResultsPhase.tsx`
+
+### Files to Move (from `/src/app/arcade/matching/`)
+
+**Components** (7 files):
+- ✅ GameCard.tsx (as-is)
+- ✅ PlayerStatusBar.tsx (as-is)
+- ⚠️ ResultsPhase.tsx (merge with games version)
+- ✅ SetupPhase.tsx (as-is)
+- ✅ EmojiPicker.tsx (as-is)
+- ✅ GamePhase.tsx (as-is)
+- ✅ MemoryPairsGame.tsx (as-is)
+
+**Utils** (3 files):
+- ✅ cardGeneration.ts (as-is)
+- ✅ matchValidation.ts (as-is)
+- ✅ gameScoring.ts (as-is)
+
+**Context**:
+- ✅ types.ts (as-is)
+- ✅ RoomMemoryPairsProvider.tsx (convert to modular Provider)
+
+**Tests**:
+- ✅ EmojiPicker.test.tsx
+- ✅ playerMetadata-userId.test.ts
+
+### Files to Delete (after migration)
+
+**From `/src/app/arcade/matching/`** (~13 files):
+- Components: 7 files + 1 test (move, then delete old location)
+- Context: LocalMemoryPairsProvider.tsx, MemoryPairsContext.tsx, index.ts
+- Utils: 3 files (move, then delete old location)
+- page.tsx (replace with redirect)
+
+**From `/src/app/games/matching/`** (~14 files):
+- Components: 7 files + 2 tests (delete)
+- Context: 2 files (delete)
+- Utils: 3 files (delete)
+- page.tsx (replace with redirect)
+
+**Validator**:
+- `/src/lib/arcade/validation/MatchingGameValidator.ts` (move to modular location)
+
+**Total files to delete**: ~27 files
+
+---
+
+## Special Merge: ResultsPhase.tsx
+
+### Keep from Arcade Version
+- Responsive layout (padding, fontSize with base/md breakpoints)
+- Modern stat cards design
+- exitSession() navigation to /arcade
+- Better button styling with gradients
+
+### Add from Games Version
+Lines 245-317: Performance Analysis section
+```tsx
+{/* Performance Analysis */}
+
+
+ Performance Analysis
+
+
+ {analysis.strengths.length > 0 && (
+
+
+ ✅ Strengths:
+
+
+ {analysis.strengths.map((strength, index) => (
+ - {strength}
+ ))}
+
+
+ )}
+
+ {analysis.improvements.length > 0 && (
+
+
+ 💡 Areas for Improvement:
+
+
+ {analysis.improvements.map((improvement, index) => (
+ - {improvement}
+ ))}
+
+
+ )}
+
+```
+
+**Note**: Need to ensure `analysis` variable is computed (may already exist in arcade version from `analyzePerformance` utility)
+
+---
+
+## Validator Assessment
+
+**Location**: `/src/lib/arcade/validation/MatchingGameValidator.ts`
+**Status**: ✅ Comprehensive and complete (570 lines)
+
+**Handles all move types**:
+- FLIP_CARD (with turn validation, player ownership)
+- START_GAME
+- CLEAR_MISMATCH
+- GO_TO_SETUP (with pause state)
+- SET_CONFIG (with validation)
+- RESUME_GAME (with config change detection)
+- HOVER_CARD (networked presence)
+
+**Ready for migration**: Yes, just needs import path updates
+
+---
+
+## Next Steps (Phase 2)
+
+1. Create `/src/arcade-games/matching/index.ts` with game definition
+2. Register in game registry
+3. Add type inference to game-configs.ts
+4. Update validator imports
+
+---
+
+## Risks Identified
+
+### Risk 1: Performance Analysis Feature Loss
+**Mitigation**: Must manually merge Performance Analysis from games/ResultsPhase.tsx
+
+### Risk 2: Import References
+**Mitigation**: 7 files import from games/matching - systematic update required
+
+### Risk 3: Test Coverage
+**Mitigation**: Move tests with components, verify they still pass
+
+---
+
+## Conclusion
+
+Phase 1 audit complete. Clear path forward:
+- **Arcade version is canonical** for all files
+- **Utils are identical** - no conflicts
+- **One manual merge required** (ResultsPhase Performance Analysis)
+- **7 import updates required** before deletion
+
+Ready to proceed to Phase 2: Create Modular Game Definition.
diff --git a/apps/web/src/arcade-games/matching/Validator.ts b/apps/web/src/arcade-games/matching/Validator.ts
new file mode 100644
index 00000000..d0151189
--- /dev/null
+++ b/apps/web/src/arcade-games/matching/Validator.ts
@@ -0,0 +1,575 @@
+/**
+ * Server-side validator for matching game
+ * Validates all game moves and state transitions
+ */
+
+import type {
+ GameCard,
+ MatchingConfig,
+ MatchingMove,
+ MatchingState,
+ Player,
+} from './types'
+import { generateGameCards } from './utils/cardGeneration'
+import { canFlipCard, validateMatch } from './utils/matchValidation'
+import type { GameValidator, ValidationResult } from '@/lib/arcade/validation/types'
+
+export class MatchingGameValidator implements GameValidator {
+ validateMove(
+ state: MatchingState,
+ move: MatchingMove,
+ context?: { userId?: string; playerOwnership?: Record }
+ ): ValidationResult {
+ switch (move.type) {
+ case 'FLIP_CARD':
+ return this.validateFlipCard(state, move.data.cardId, move.playerId, context)
+
+ case 'START_GAME':
+ return this.validateStartGame(
+ state,
+ move.data.activePlayers,
+ move.data.cards,
+ move.data.playerMetadata
+ )
+
+ case 'CLEAR_MISMATCH':
+ return this.validateClearMismatch(state)
+
+ case 'GO_TO_SETUP':
+ return this.validateGoToSetup(state)
+
+ case 'SET_CONFIG':
+ return this.validateSetConfig(state, move.data.field, move.data.value)
+
+ case 'RESUME_GAME':
+ return this.validateResumeGame(state)
+
+ case 'HOVER_CARD':
+ return this.validateHoverCard(state, move.data.cardId, move.playerId)
+
+ default:
+ return {
+ valid: false,
+ error: `Unknown move type: ${(move as any).type}`,
+ }
+ }
+ }
+
+ private validateFlipCard(
+ state: MatchingState,
+ cardId: string,
+ playerId: string,
+ context?: { userId?: string; playerOwnership?: Record }
+ ): ValidationResult {
+ // Game must be in playing phase
+ if (state.gamePhase !== 'playing') {
+ return {
+ valid: false,
+ error: 'Cannot flip cards outside of playing phase',
+ }
+ }
+
+ // Check if it's the player's turn (in multiplayer)
+ if (state.activePlayers.length > 1 && state.currentPlayer !== playerId) {
+ console.log('[Validator] Turn check failed:', {
+ activePlayers: state.activePlayers,
+ currentPlayer: state.currentPlayer,
+ currentPlayerType: typeof state.currentPlayer,
+ playerId,
+ playerIdType: typeof playerId,
+ matches: state.currentPlayer === playerId,
+ })
+ return {
+ valid: false,
+ error: 'Not your turn',
+ }
+ }
+
+ // Check player ownership authorization (if context provided)
+ if (context?.userId && context?.playerOwnership) {
+ const playerOwner = context.playerOwnership[playerId]
+ if (playerOwner && playerOwner !== context.userId) {
+ console.log('[Validator] Player ownership check failed:', {
+ playerId,
+ playerOwner,
+ requestingUserId: context.userId,
+ })
+ return {
+ valid: false,
+ error: 'You can only move your own players',
+ }
+ }
+ }
+
+ // Find the card
+ const card = state.gameCards.find((c) => c.id === cardId)
+ if (!card) {
+ return {
+ valid: false,
+ error: 'Card not found',
+ }
+ }
+
+ // Validate using existing game logic
+ if (!canFlipCard(card, state.flippedCards, state.isProcessingMove)) {
+ return {
+ valid: false,
+ error: 'Cannot flip this card',
+ }
+ }
+
+ // Calculate new state
+ const newFlippedCards = [...state.flippedCards, card]
+ let newState = {
+ ...state,
+ flippedCards: newFlippedCards,
+ isProcessingMove: newFlippedCards.length === 2,
+ // Clear mismatch feedback when player flips a new card
+ showMismatchFeedback: false,
+ }
+
+ // If two cards are flipped, check for match
+ if (newFlippedCards.length === 2) {
+ const [card1, card2] = newFlippedCards
+ const matchResult = validateMatch(card1, card2)
+
+ if (matchResult.isValid) {
+ // Match found - update cards
+ newState = {
+ ...newState,
+ gameCards: newState.gameCards.map((c) =>
+ c.id === card1.id || c.id === card2.id
+ ? { ...c, matched: true, matchedBy: state.currentPlayer }
+ : c
+ ),
+ matchedPairs: state.matchedPairs + 1,
+ scores: {
+ ...state.scores,
+ [state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
+ },
+ consecutiveMatches: {
+ ...state.consecutiveMatches,
+ [state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
+ },
+ moves: state.moves + 1,
+ flippedCards: [],
+ isProcessingMove: false,
+ }
+
+ // Check if game is complete
+ if (newState.matchedPairs === newState.totalPairs) {
+ newState = {
+ ...newState,
+ gamePhase: 'results',
+ gameEndTime: Date.now(),
+ }
+ }
+ } else {
+ // Match failed - keep cards flipped briefly so player can see them
+ // Client will handle clearing them after a delay
+ const shouldSwitchPlayer = state.activePlayers.length > 1
+ const nextPlayerIndex = shouldSwitchPlayer
+ ? (state.activePlayers.indexOf(state.currentPlayer) + 1) % state.activePlayers.length
+ : 0
+ const nextPlayer = shouldSwitchPlayer
+ ? state.activePlayers[nextPlayerIndex]
+ : state.currentPlayer
+
+ newState = {
+ ...newState,
+ currentPlayer: nextPlayer,
+ consecutiveMatches: {
+ ...state.consecutiveMatches,
+ [state.currentPlayer]: 0,
+ },
+ moves: state.moves + 1,
+ // Keep flippedCards so player can see both cards
+ flippedCards: newFlippedCards,
+ isProcessingMove: true, // Keep processing state so no more cards can be flipped
+ showMismatchFeedback: true,
+ // Clear hover state for the player whose turn is ending
+ playerHovers: {
+ ...state.playerHovers,
+ [state.currentPlayer]: null,
+ },
+ }
+ }
+ }
+
+ return {
+ valid: true,
+ newState,
+ }
+ }
+
+ private validateStartGame(
+ state: MatchingState,
+ activePlayers: Player[],
+ cards?: GameCard[],
+ playerMetadata?: { [playerId: string]: any }
+ ): ValidationResult {
+ // Allow starting a new game from any phase (for "New Game" button)
+
+ // Must have at least one player
+ if (!activePlayers || activePlayers.length === 0) {
+ return {
+ valid: false,
+ error: 'Must have at least one player',
+ }
+ }
+
+ // Use provided cards or generate new ones
+ const gameCards = cards || generateGameCards(state.gameType, state.difficulty)
+
+ const newState: MatchingState = {
+ ...state,
+ gameCards,
+ cards: gameCards,
+ activePlayers,
+ playerMetadata: playerMetadata || {}, // Store player metadata for cross-user visibility
+ gamePhase: 'playing',
+ gameStartTime: Date.now(),
+ currentPlayer: activePlayers[0],
+ flippedCards: [],
+ matchedPairs: 0,
+ moves: 0,
+ scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
+ consecutiveMatches: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
+ // PAUSE/RESUME: Save original config so we can detect changes
+ originalConfig: {
+ gameType: state.gameType,
+ difficulty: state.difficulty,
+ turnTimer: state.turnTimer,
+ },
+ // Clear any paused game state (starting fresh)
+ pausedGamePhase: undefined,
+ pausedGameState: undefined,
+ // Clear hover state when starting new game
+ playerHovers: {},
+ }
+
+ return {
+ valid: true,
+ newState,
+ }
+ }
+
+ private validateClearMismatch(state: MatchingState): ValidationResult {
+ // Only clear if there's actually a mismatch showing
+ // This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
+ if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
+ // Nothing to clear - return current state unchanged
+ return {
+ valid: true,
+ newState: state,
+ }
+ }
+
+ // Get the list of all non-current players whose hovers should be cleared
+ // (They're not playing this turn, so their hovers from previous turns should not show)
+ const clearedHovers = { ...state.playerHovers }
+ for (const playerId of state.activePlayers) {
+ // Clear hover for all players except the current player
+ // This ensures only the current player's active hover shows
+ if (playerId !== state.currentPlayer) {
+ clearedHovers[playerId] = null
+ }
+ }
+
+ // Clear mismatched cards and feedback
+ return {
+ valid: true,
+ newState: {
+ ...state,
+ flippedCards: [],
+ showMismatchFeedback: false,
+ isProcessingMove: false,
+ // Clear hovers for non-current players when cards are cleared
+ playerHovers: clearedHovers,
+ },
+ }
+ }
+
+ /**
+ * STANDARD ARCADE PATTERN: GO_TO_SETUP
+ *
+ * Transitions the game back to setup phase, allowing players to reconfigure
+ * the game. This is synchronized across all room members.
+ *
+ * Can be called from any phase (setup, playing, results).
+ *
+ * PAUSE/RESUME: If called from 'playing' or 'results', saves game state
+ * to allow resuming later (if config unchanged).
+ *
+ * Pattern for all arcade games:
+ * - Validates the move is allowed
+ * - Sets gamePhase to 'setup'
+ * - Preserves current configuration (gameType, difficulty, etc.)
+ * - Saves game state for resume if coming from active game
+ * - Resets game progression state (scores, cards, etc.)
+ */
+ private validateGoToSetup(state: MatchingState): ValidationResult {
+ // Determine if we're pausing an active game (for Resume functionality)
+ const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
+
+ return {
+ valid: true,
+ newState: {
+ ...state,
+ gamePhase: 'setup',
+
+ // Pause/Resume: Save game state if pausing from active game
+ pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
+ pausedGameState: isPausingGame
+ ? {
+ gameCards: state.gameCards,
+ currentPlayer: state.currentPlayer,
+ matchedPairs: state.matchedPairs,
+ moves: state.moves,
+ scores: state.scores,
+ activePlayers: state.activePlayers,
+ playerMetadata: state.playerMetadata,
+ consecutiveMatches: state.consecutiveMatches,
+ gameStartTime: state.gameStartTime,
+ }
+ : undefined,
+
+ // Keep originalConfig if it exists (was set when game started)
+ // This allows detecting if config changed while paused
+
+ // Reset visible game progression
+ gameCards: [],
+ cards: [],
+ flippedCards: [],
+ currentPlayer: '',
+ matchedPairs: 0,
+ moves: 0,
+ scores: {},
+ activePlayers: [],
+ playerMetadata: {},
+ consecutiveMatches: {},
+ gameStartTime: null,
+ gameEndTime: null,
+ currentMoveStartTime: null,
+ celebrationAnimations: [],
+ isProcessingMove: false,
+ showMismatchFeedback: false,
+ lastMatchedPair: null,
+ playerHovers: {}, // Clear hover state when returning to setup
+ // Preserve configuration - players can modify in setup
+ // gameType, difficulty, turnTimer stay as-is
+ },
+ }
+ }
+
+ /**
+ * STANDARD ARCADE PATTERN: SET_CONFIG
+ *
+ * Updates a configuration field during setup phase. This is synchronized
+ * across all room members in real-time, allowing collaborative setup.
+ *
+ * Pattern for all arcade games:
+ * - Only allowed during setup phase
+ * - Validates field name and value
+ * - Updates the configuration field
+ * - Other room members see the change immediately (optimistic + server validation)
+ *
+ * @param state Current game state
+ * @param field Configuration field name
+ * @param value New value for the field
+ */
+ private validateSetConfig(
+ state: MatchingState,
+ field: 'gameType' | 'difficulty' | 'turnTimer',
+ value: any
+ ): ValidationResult {
+ // Can only change config during setup phase
+ if (state.gamePhase !== 'setup') {
+ return {
+ valid: false,
+ error: 'Cannot change configuration outside of setup phase',
+ }
+ }
+
+ // Validate field-specific values
+ switch (field) {
+ case 'gameType':
+ if (value !== 'abacus-numeral' && value !== 'complement-pairs') {
+ return { valid: false, error: `Invalid gameType: ${value}` }
+ }
+ break
+
+ case 'difficulty':
+ if (![6, 8, 12, 15].includes(value)) {
+ return { valid: false, error: `Invalid difficulty: ${value}` }
+ }
+ break
+
+ case 'turnTimer':
+ if (typeof value !== 'number' || value < 5 || value > 300) {
+ return { valid: false, error: `Invalid turnTimer: ${value}` }
+ }
+ break
+
+ default:
+ return { valid: false, error: `Unknown config field: ${field}` }
+ }
+
+ // PAUSE/RESUME: If there's a paused game and config is changing,
+ // clear the paused game state (can't resume anymore)
+ const clearPausedGame = !!state.pausedGamePhase
+
+ // Apply the configuration change
+ return {
+ valid: true,
+ newState: {
+ ...state,
+ [field]: value,
+ // Update totalPairs if difficulty changes
+ ...(field === 'difficulty' ? { totalPairs: value } : {}),
+ // Clear paused game if config changed
+ ...(clearPausedGame
+ ? {
+ pausedGamePhase: undefined,
+ pausedGameState: undefined,
+ originalConfig: undefined,
+ }
+ : {}),
+ },
+ }
+ }
+
+ /**
+ * STANDARD ARCADE PATTERN: RESUME_GAME
+ *
+ * Resumes a paused game if configuration hasn't changed.
+ * Restores the saved game state from when GO_TO_SETUP was called.
+ *
+ * Pattern for all arcade games:
+ * - Validates there's a paused game
+ * - Validates config hasn't changed since pause
+ * - Restores game state and phase
+ * - Clears paused game state
+ */
+ private validateResumeGame(state: MatchingState): ValidationResult {
+ // Must be in setup phase
+ if (state.gamePhase !== 'setup') {
+ return {
+ valid: false,
+ error: 'Can only resume from setup phase',
+ }
+ }
+
+ // Must have a paused game
+ if (!state.pausedGamePhase || !state.pausedGameState) {
+ return {
+ valid: false,
+ error: 'No paused game to resume',
+ }
+ }
+
+ // Config must match original (no changes while paused)
+ if (state.originalConfig) {
+ const configChanged =
+ state.gameType !== state.originalConfig.gameType ||
+ state.difficulty !== state.originalConfig.difficulty ||
+ state.turnTimer !== state.originalConfig.turnTimer
+
+ if (configChanged) {
+ return {
+ valid: false,
+ error: 'Cannot resume - configuration has changed',
+ }
+ }
+ }
+
+ // Restore the paused game
+ return {
+ valid: true,
+ newState: {
+ ...state,
+ gamePhase: state.pausedGamePhase,
+ gameCards: state.pausedGameState.gameCards,
+ cards: state.pausedGameState.gameCards,
+ currentPlayer: state.pausedGameState.currentPlayer,
+ matchedPairs: state.pausedGameState.matchedPairs,
+ moves: state.pausedGameState.moves,
+ scores: state.pausedGameState.scores,
+ activePlayers: state.pausedGameState.activePlayers,
+ playerMetadata: state.pausedGameState.playerMetadata,
+ consecutiveMatches: state.pausedGameState.consecutiveMatches,
+ gameStartTime: state.pausedGameState.gameStartTime,
+ // Clear paused state
+ pausedGamePhase: undefined,
+ pausedGameState: undefined,
+ // Keep originalConfig for potential future pauses
+ },
+ }
+ }
+
+ /**
+ * Validate hover state update for networked presence
+ *
+ * Hover moves are lightweight and always valid - they just update
+ * which card a player is hovering over for UI feedback to other players.
+ */
+ private validateHoverCard(
+ state: MatchingState,
+ cardId: string | null,
+ playerId: string
+ ): ValidationResult {
+ // Hover is always valid - it's just UI state for networked presence
+ // Update the player's hover state
+ return {
+ valid: true,
+ newState: {
+ ...state,
+ playerHovers: {
+ ...state.playerHovers,
+ [playerId]: cardId,
+ },
+ },
+ }
+ }
+
+ isGameComplete(state: MatchingState): boolean {
+ return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
+ }
+
+ getInitialState(config: MatchingConfig): MatchingState {
+ return {
+ cards: [],
+ gameCards: [],
+ flippedCards: [],
+ gameType: config.gameType,
+ difficulty: config.difficulty,
+ turnTimer: config.turnTimer,
+ gamePhase: 'setup',
+ currentPlayer: '',
+ matchedPairs: 0,
+ totalPairs: config.difficulty,
+ moves: 0,
+ scores: {},
+ activePlayers: [],
+ playerMetadata: {}, // Initialize empty player metadata
+ consecutiveMatches: {},
+ gameStartTime: null,
+ gameEndTime: null,
+ currentMoveStartTime: null,
+ timerInterval: null,
+ celebrationAnimations: [],
+ isProcessingMove: false,
+ showMismatchFeedback: false,
+ lastMatchedPair: null,
+ // PAUSE/RESUME: Initialize paused game fields
+ originalConfig: undefined,
+ pausedGamePhase: undefined,
+ pausedGameState: undefined,
+ // HOVER: Initialize hover state
+ playerHovers: {},
+ }
+ }
+}
+
+// Singleton instance
+export const matchingGameValidator = new MatchingGameValidator()
diff --git a/apps/web/src/arcade-games/matching/index.ts b/apps/web/src/arcade-games/matching/index.ts
new file mode 100644
index 00000000..e52c2c66
--- /dev/null
+++ b/apps/web/src/arcade-games/matching/index.ts
@@ -0,0 +1,76 @@
+/**
+ * Matching Pairs Battle Game Definition
+ *
+ * A turn-based multiplayer memory game where players flip cards to find matching pairs.
+ * Supports both abacus-numeral matching and complement pairs modes.
+ */
+
+import { defineGame } from '@/lib/arcade/game-sdk'
+import type { GameManifest } from '@/lib/arcade/game-sdk'
+import { MemoryPairsGame } from './components/MemoryPairsGame'
+import { MatchingProvider } from './Provider'
+import type { MatchingConfig, MatchingMove, MatchingState } from './types'
+import { matchingGameValidator } from './Validator'
+
+const manifest: GameManifest = {
+ name: 'matching',
+ displayName: 'Matching Pairs Battle',
+ icon: '⚔️',
+ description: 'Multiplayer memory battle with friends',
+ longDescription:
+ 'Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience. ' +
+ 'Choose between abacus-numeral matching or complement pairs mode. Strategic thinking and quick memory are key to victory!',
+ maxPlayers: 4,
+ difficulty: 'Intermediate',
+ chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
+ color: 'purple',
+ gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
+ borderColor: 'purple.200',
+ available: true,
+}
+
+const defaultConfig: MatchingConfig = {
+ gameType: 'abacus-numeral',
+ difficulty: 6,
+ turnTimer: 30,
+}
+
+// Config validation function
+function validateMatchingConfig(config: unknown): config is MatchingConfig {
+ if (typeof config !== 'object' || config === null) {
+ return false
+ }
+
+ const c = config as any
+
+ // Validate gameType
+ if (!('gameType' in c) || !['abacus-numeral', 'complement-pairs'].includes(c.gameType)) {
+ return false
+ }
+
+ // Validate difficulty (number of pairs)
+ if (!('difficulty' in c) || ![6, 8, 12, 15].includes(c.difficulty)) {
+ return false
+ }
+
+ // Validate turnTimer
+ if (
+ !('turnTimer' in c) ||
+ typeof c.turnTimer !== 'number' ||
+ c.turnTimer < 5 ||
+ c.turnTimer > 300
+ ) {
+ return false
+ }
+
+ return true
+}
+
+export const matchingGame = defineGame({
+ manifest,
+ Provider: MatchingProvider,
+ GameComponent: MemoryPairsGame,
+ validator: matchingGameValidator,
+ defaultConfig,
+ validateConfig: validateMatchingConfig,
+})
diff --git a/apps/web/src/arcade-games/matching/types.ts b/apps/web/src/arcade-games/matching/types.ts
new file mode 100644
index 00000000..0b3da07f
--- /dev/null
+++ b/apps/web/src/arcade-games/matching/types.ts
@@ -0,0 +1,250 @@
+/**
+ * Matching Pairs Battle - Type Definitions
+ *
+ * SDK-compatible types for the matching game.
+ */
+
+import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
+
+// ============================================================================
+// Core Types
+// ============================================================================
+
+export type GameMode = 'single' | 'multiplayer'
+export type GameType = 'abacus-numeral' | 'complement-pairs'
+export type GamePhase = 'setup' | 'playing' | 'results'
+export type CardType = 'abacus' | 'number' | 'complement'
+export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
+export type Player = string // Player ID (UUID)
+export type TargetSum = 5 | 10 | 20
+
+// ============================================================================
+// Game Configuration (SDK-compatible)
+// ============================================================================
+
+/**
+ * Configuration for matching game
+ * Extends GameConfig for SDK compatibility
+ */
+export interface MatchingConfig extends GameConfig {
+ gameType: GameType
+ difficulty: Difficulty
+ turnTimer: number
+}
+
+// ============================================================================
+// Game Entities
+// ============================================================================
+
+export interface GameCard {
+ id: string
+ type: CardType
+ number: number
+ complement?: number // For complement pairs
+ targetSum?: TargetSum // For complement pairs
+ matched: boolean
+ matchedBy?: Player // For two-player mode
+ element?: HTMLElement | null // For animations
+}
+
+export interface PlayerMetadata {
+ id: string // Player ID (UUID)
+ name: string
+ emoji: string
+ userId: string // Which user owns this player
+ color?: string
+}
+
+export interface PlayerScore {
+ [playerId: string]: number
+}
+
+export interface CelebrationAnimation {
+ id: string
+ type: 'match' | 'win' | 'confetti'
+ x: number
+ y: number
+ timestamp: number
+}
+
+export interface GameStatistics {
+ totalMoves: number
+ matchedPairs: number
+ totalPairs: number
+ gameTime: number
+ accuracy: number // Percentage of successful matches
+ averageTimePerMove: number
+}
+
+// ============================================================================
+// Game State (SDK-compatible)
+// ============================================================================
+
+/**
+ * Main game state for matching pairs battle
+ * Extends GameState for SDK compatibility
+ */
+export interface MatchingState extends GameState {
+ // Core game data
+ cards: GameCard[]
+ gameCards: GameCard[]
+ flippedCards: GameCard[]
+
+ // Game configuration
+ gameType: GameType
+ difficulty: Difficulty
+ turnTimer: number // Seconds for turn timer
+
+ // Game progression
+ gamePhase: GamePhase
+ currentPlayer: Player
+ matchedPairs: number
+ totalPairs: number
+ moves: number
+ scores: PlayerScore
+ activePlayers: Player[] // Track active player IDs
+ playerMetadata: Record // Player metadata for cross-user visibility
+ consecutiveMatches: Record // Track consecutive matches per player
+
+ // Timing
+ gameStartTime: number | null
+ gameEndTime: number | null
+ currentMoveStartTime: number | null
+ timerInterval: NodeJS.Timeout | null
+
+ // UI state
+ celebrationAnimations: CelebrationAnimation[]
+ isProcessingMove: boolean
+ showMismatchFeedback: boolean
+ lastMatchedPair: [string, string] | null
+
+ // PAUSE/RESUME: Paused game state
+ originalConfig?: {
+ gameType: GameType
+ difficulty: Difficulty
+ turnTimer: number
+ }
+ pausedGamePhase?: GamePhase
+ pausedGameState?: {
+ gameCards: GameCard[]
+ currentPlayer: Player
+ matchedPairs: number
+ moves: number
+ scores: PlayerScore
+ activePlayers: Player[]
+ playerMetadata: Record
+ consecutiveMatches: Record
+ gameStartTime: number | null
+ }
+
+ // HOVER: Networked hover state
+ playerHovers: Record // playerId -> cardId (or null if not hovering)
+}
+
+// For backwards compatibility with existing code
+export type MemoryPairsState = MatchingState
+
+// ============================================================================
+// Game Moves (SDK-compatible)
+// ============================================================================
+
+/**
+ * All possible moves in the matching game
+ * These match the move types validated by MatchingGameValidator
+ */
+export type MatchingMove =
+ | {
+ type: 'FLIP_CARD'
+ playerId: string
+ userId: string
+ timestamp: number
+ data: {
+ cardId: string
+ }
+ }
+ | {
+ type: 'START_GAME'
+ playerId: string
+ userId: string
+ timestamp: number
+ data: {
+ cards: GameCard[]
+ activePlayers: string[]
+ playerMetadata: Record
+ }
+ }
+ | {
+ type: 'CLEAR_MISMATCH'
+ playerId: string
+ userId: string
+ timestamp: number
+ data: Record
+ }
+ | {
+ type: 'GO_TO_SETUP'
+ playerId: string
+ userId: string
+ timestamp: number
+ data: Record
+ }
+ | {
+ type: 'SET_CONFIG'
+ playerId: string
+ userId: string
+ timestamp: number
+ data: {
+ field: 'gameType' | 'difficulty' | 'turnTimer'
+ value: any
+ }
+ }
+ | {
+ type: 'RESUME_GAME'
+ playerId: string
+ userId: string
+ timestamp: number
+ data: Record
+ }
+ | {
+ type: 'HOVER_CARD'
+ playerId: string
+ userId: string
+ timestamp: number
+ data: {
+ cardId: string | null
+ }
+ }
+
+// ============================================================================
+// Component Props
+// ============================================================================
+
+export interface GameCardProps {
+ card: GameCard
+ isFlipped: boolean
+ isMatched: boolean
+ onClick: () => void
+ disabled?: boolean
+}
+
+export interface PlayerIndicatorProps {
+ player: Player
+ isActive: boolean
+ score: number
+ name?: string
+}
+
+export interface GameGridProps {
+ cards: GameCard[]
+ onCardClick: (cardId: string) => void
+ disabled?: boolean
+}
+
+// ============================================================================
+// Validation
+// ============================================================================
+
+export interface MatchValidationResult {
+ isValid: boolean
+ reason?: string
+ type: 'abacus-numeral' | 'complement' | 'invalid'
+}
diff --git a/apps/web/src/arcade-games/matching/utils/cardGeneration.ts b/apps/web/src/arcade-games/matching/utils/cardGeneration.ts
new file mode 100644
index 00000000..dd0b4ebe
--- /dev/null
+++ b/apps/web/src/arcade-games/matching/utils/cardGeneration.ts
@@ -0,0 +1,194 @@
+import type { Difficulty, GameCard, GameType } from '../types'
+
+// Utility function to generate unique random numbers
+function generateUniqueNumbers(count: number, options: { min: number; max: number }): number[] {
+ const numbers = new Set()
+ const { min, max } = options
+
+ while (numbers.size < count) {
+ const randomNum = Math.floor(Math.random() * (max - min + 1)) + min
+ numbers.add(randomNum)
+ }
+
+ return Array.from(numbers)
+}
+
+// Utility function to shuffle an array
+function shuffleArray(array: T[]): T[] {
+ const shuffled = [...array]
+ for (let i = shuffled.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1))
+ ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
+ }
+ return shuffled
+}
+
+// Generate cards for abacus-numeral game mode
+export function generateAbacusNumeralCards(pairs: Difficulty): GameCard[] {
+ // Generate unique numbers based on difficulty
+ // For easier games, use smaller numbers; for harder games, use larger ranges
+ const numberRanges: Record = {
+ 6: { min: 1, max: 50 }, // 6 pairs: 1-50
+ 8: { min: 1, max: 100 }, // 8 pairs: 1-100
+ 12: { min: 1, max: 200 }, // 12 pairs: 1-200
+ 15: { min: 1, max: 300 }, // 15 pairs: 1-300
+ }
+
+ const range = numberRanges[pairs]
+ const numbers = generateUniqueNumbers(pairs, range)
+
+ const cards: GameCard[] = []
+
+ numbers.forEach((number) => {
+ // Abacus representation card
+ cards.push({
+ id: `abacus_${number}`,
+ type: 'abacus',
+ number,
+ matched: false,
+ })
+
+ // Numerical representation card
+ cards.push({
+ id: `number_${number}`,
+ type: 'number',
+ number,
+ matched: false,
+ })
+ })
+
+ return shuffleArray(cards)
+}
+
+// Generate cards for complement pairs game mode
+export function generateComplementCards(pairs: Difficulty): GameCard[] {
+ // Define complement pairs for friends of 5 and friends of 10
+ const complementPairs = [
+ // Friends of 5
+ { pair: [0, 5], targetSum: 5 as const },
+ { pair: [1, 4], targetSum: 5 as const },
+ { pair: [2, 3], targetSum: 5 as const },
+
+ // Friends of 10
+ { pair: [0, 10], targetSum: 10 as const },
+ { pair: [1, 9], targetSum: 10 as const },
+ { pair: [2, 8], targetSum: 10 as const },
+ { pair: [3, 7], targetSum: 10 as const },
+ { pair: [4, 6], targetSum: 10 as const },
+ { pair: [5, 5], targetSum: 10 as const },
+
+ // Additional pairs for higher difficulties
+ { pair: [6, 4], targetSum: 10 as const },
+ { pair: [7, 3], targetSum: 10 as const },
+ { pair: [8, 2], targetSum: 10 as const },
+ { pair: [9, 1], targetSum: 10 as const },
+ { pair: [10, 0], targetSum: 10 as const },
+
+ // More challenging pairs (can be used for expert mode)
+ { pair: [11, 9], targetSum: 20 as const },
+ { pair: [12, 8], targetSum: 20 as const },
+ ]
+
+ // Select the required number of complement pairs
+ const selectedPairs = complementPairs.slice(0, pairs)
+ const cards: GameCard[] = []
+
+ selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
+ // First number in the pair
+ cards.push({
+ id: `comp1_${index}_${num1}`,
+ type: 'complement',
+ number: num1,
+ complement: num2,
+ targetSum,
+ matched: false,
+ })
+
+ // Second number in the pair
+ cards.push({
+ id: `comp2_${index}_${num2}`,
+ type: 'complement',
+ number: num2,
+ complement: num1,
+ targetSum,
+ matched: false,
+ })
+ })
+
+ return shuffleArray(cards)
+}
+
+// Main card generation function
+export function generateGameCards(gameType: GameType, difficulty: Difficulty): GameCard[] {
+ switch (gameType) {
+ case 'abacus-numeral':
+ return generateAbacusNumeralCards(difficulty)
+
+ case 'complement-pairs':
+ return generateComplementCards(difficulty)
+
+ default:
+ throw new Error(`Unknown game type: ${gameType}`)
+ }
+}
+
+// Utility function to get responsive grid configuration based on difficulty and screen size
+export function getGridConfiguration(difficulty: Difficulty) {
+ const configs: Record<
+ Difficulty,
+ {
+ totalCards: number
+ // Orientation-optimized responsive columns
+ mobileColumns: number // Portrait mobile
+ tabletColumns: number // Tablet
+ desktopColumns: number // Desktop/landscape
+ landscapeColumns: number // Landscape mobile/tablet
+ cardSize: { width: string; height: string }
+ gridTemplate: string
+ }
+ > = {
+ 6: {
+ totalCards: 12,
+ mobileColumns: 3, // 3x4 grid in portrait
+ tabletColumns: 4, // 4x3 grid on tablet
+ desktopColumns: 4, // 4x3 grid on desktop
+ landscapeColumns: 6, // 6x2 grid in landscape
+ cardSize: { width: '140px', height: '180px' },
+ gridTemplate: 'repeat(3, 1fr)',
+ },
+ 8: {
+ totalCards: 16,
+ mobileColumns: 3, // 3x6 grid in portrait (some spillover)
+ tabletColumns: 4, // 4x4 grid on tablet
+ desktopColumns: 4, // 4x4 grid on desktop
+ landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
+ cardSize: { width: '120px', height: '160px' },
+ gridTemplate: 'repeat(3, 1fr)',
+ },
+ 12: {
+ totalCards: 24,
+ mobileColumns: 3, // 3x8 grid in portrait
+ tabletColumns: 4, // 4x6 grid on tablet
+ desktopColumns: 6, // 6x4 grid on desktop
+ landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
+ cardSize: { width: '100px', height: '140px' },
+ gridTemplate: 'repeat(3, 1fr)',
+ },
+ 15: {
+ totalCards: 30,
+ mobileColumns: 3, // 3x10 grid in portrait
+ tabletColumns: 5, // 5x6 grid on tablet
+ desktopColumns: 6, // 6x5 grid on desktop
+ landscapeColumns: 10, // 10x3 grid in landscape
+ cardSize: { width: '90px', height: '120px' },
+ gridTemplate: 'repeat(3, 1fr)',
+ },
+ }
+
+ return configs[difficulty]
+}
+
+// Generate a unique ID for cards
+export function generateCardId(type: string, identifier: string | number): string {
+ return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
+}
diff --git a/apps/web/src/arcade-games/matching/utils/gameScoring.ts b/apps/web/src/arcade-games/matching/utils/gameScoring.ts
new file mode 100644
index 00000000..ea398f64
--- /dev/null
+++ b/apps/web/src/arcade-games/matching/utils/gameScoring.ts
@@ -0,0 +1,331 @@
+import type { GameStatistics, MemoryPairsState, Player } from '../types'
+
+// Calculate final game score based on multiple factors
+export function calculateFinalScore(
+ matchedPairs: number,
+ totalPairs: number,
+ moves: number,
+ gameTime: number,
+ difficulty: number,
+ gameMode: 'single' | 'two-player'
+): number {
+ // Base score for completing pairs
+ const baseScore = matchedPairs * 100
+
+ // Efficiency bonus (fewer moves = higher bonus)
+ const idealMoves = totalPairs * 2 // Perfect game would be 2 moves per pair
+ const efficiency = idealMoves / Math.max(moves, idealMoves)
+ const efficiencyBonus = Math.round(baseScore * efficiency * 0.5)
+
+ // Time bonus (faster completion = higher bonus)
+ const timeInMinutes = gameTime / (1000 * 60)
+ const timeBonus = Math.max(0, Math.round((1000 * difficulty) / timeInMinutes))
+
+ // Difficulty multiplier
+ const difficultyMultiplier = 1 + (difficulty - 6) * 0.1
+
+ // Two-player mode bonus
+ const modeMultiplier = gameMode === 'two-player' ? 1.2 : 1.0
+
+ const finalScore = Math.round(
+ (baseScore + efficiencyBonus + timeBonus) * difficultyMultiplier * modeMultiplier
+ )
+
+ return Math.max(0, finalScore)
+}
+
+// Calculate star rating (1-5 stars) based on performance
+export function calculateStarRating(
+ accuracy: number,
+ efficiency: number,
+ gameTime: number,
+ difficulty: number
+): number {
+ // Normalize time score (assuming reasonable time ranges)
+ const expectedTime = difficulty * 30000 // 30 seconds per pair as baseline
+ const timeScore = Math.max(0, Math.min(100, (expectedTime / gameTime) * 100))
+
+ // Weighted average of different factors
+ const overallScore = accuracy * 0.4 + efficiency * 0.4 + timeScore * 0.2
+
+ // Convert to stars
+ if (overallScore >= 90) return 5
+ if (overallScore >= 80) return 4
+ if (overallScore >= 70) return 3
+ if (overallScore >= 60) return 2
+ return 1
+}
+
+// Get achievement badges based on performance
+export interface Achievement {
+ id: string
+ name: string
+ description: string
+ icon: string
+ earned: boolean
+}
+
+export function getAchievements(
+ state: MemoryPairsState,
+ gameMode: 'single' | 'multiplayer'
+): Achievement[] {
+ const { matchedPairs, totalPairs, moves, scores, gameStartTime, gameEndTime } = state
+ const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
+ const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
+ const gameTimeInSeconds = gameTime / 1000
+
+ const achievements: Achievement[] = [
+ {
+ id: 'perfect_game',
+ name: 'Perfect Memory',
+ description: 'Complete a game with 100% accuracy',
+ icon: '🧠',
+ earned: matchedPairs === totalPairs && moves === totalPairs * 2,
+ },
+ {
+ id: 'speed_demon',
+ name: 'Speed Demon',
+ description: 'Complete a game in under 2 minutes',
+ icon: '⚡',
+ earned: gameTimeInSeconds > 0 && gameTimeInSeconds < 120 && matchedPairs === totalPairs,
+ },
+ {
+ id: 'accuracy_ace',
+ name: 'Accuracy Ace',
+ description: 'Achieve 90% accuracy or higher',
+ icon: '🎯',
+ earned: accuracy >= 90 && matchedPairs === totalPairs,
+ },
+ {
+ id: 'marathon_master',
+ name: 'Marathon Master',
+ description: 'Complete the hardest difficulty (15 pairs)',
+ icon: '🏃',
+ earned: totalPairs === 15 && matchedPairs === totalPairs,
+ },
+ {
+ id: 'complement_champion',
+ name: 'Complement Champion',
+ description: 'Master complement pairs mode',
+ icon: '🤝',
+ earned:
+ state.gameType === 'complement-pairs' && matchedPairs === totalPairs && accuracy >= 85,
+ },
+ {
+ id: 'two_player_triumph',
+ name: 'Two-Player Triumph',
+ description: 'Win a two-player game',
+ icon: '👥',
+ earned:
+ gameMode === 'multiplayer' &&
+ matchedPairs === totalPairs &&
+ Object.keys(scores).length > 1 &&
+ Math.max(...Object.values(scores)) > 0,
+ },
+ {
+ id: 'shutout_victory',
+ name: 'Shutout Victory',
+ description: 'Win a two-player game without opponent scoring',
+ icon: '🛡️',
+ earned:
+ gameMode === 'multiplayer' &&
+ matchedPairs === totalPairs &&
+ Object.values(scores).some((score) => score === totalPairs) &&
+ Object.values(scores).some((score) => score === 0),
+ },
+ {
+ id: 'comeback_kid',
+ name: 'Comeback Kid',
+ description: 'Win after being behind by 3+ points',
+ icon: '🔄',
+ earned: false, // This would need more complex tracking during the game
+ },
+ {
+ id: 'first_timer',
+ name: 'First Timer',
+ description: 'Complete your first game',
+ icon: '🌟',
+ earned: matchedPairs === totalPairs,
+ },
+ {
+ id: 'consistency_king',
+ name: 'Consistency King',
+ description: 'Achieve 80%+ accuracy in 5 consecutive games',
+ icon: '👑',
+ earned: false, // This would need persistent game history
+ },
+ ]
+
+ return achievements
+}
+
+// Get performance metrics and analysis
+export function getPerformanceAnalysis(state: MemoryPairsState): {
+ statistics: GameStatistics
+ grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F'
+ strengths: string[]
+ improvements: string[]
+ starRating: number
+} {
+ const { matchedPairs, totalPairs, moves, difficulty, gameStartTime, gameEndTime } = state
+ const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
+
+ // Calculate statistics
+ const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
+ const averageTimePerMove = moves > 0 ? gameTime / moves : 0
+ const statistics: GameStatistics = {
+ totalMoves: moves,
+ matchedPairs,
+ totalPairs,
+ gameTime,
+ accuracy,
+ averageTimePerMove,
+ }
+
+ // Calculate efficiency (ideal vs actual moves)
+ const idealMoves = totalPairs * 2
+ const efficiency = (idealMoves / Math.max(moves, idealMoves)) * 100
+
+ // Determine grade
+ let grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F' = 'F'
+ if (accuracy >= 95 && efficiency >= 90) grade = 'A+'
+ else if (accuracy >= 90 && efficiency >= 85) grade = 'A'
+ else if (accuracy >= 85 && efficiency >= 80) grade = 'B+'
+ else if (accuracy >= 80 && efficiency >= 75) grade = 'B'
+ else if (accuracy >= 75 && efficiency >= 70) grade = 'C+'
+ else if (accuracy >= 70 && efficiency >= 65) grade = 'C'
+ else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
+
+ // Calculate star rating
+ const starRating = calculateStarRating(accuracy, efficiency, gameTime, difficulty)
+
+ // Analyze strengths and areas for improvement
+ const strengths: string[] = []
+ const improvements: string[] = []
+
+ if (accuracy >= 90) {
+ strengths.push('Excellent memory and pattern recognition')
+ } else if (accuracy < 70) {
+ improvements.push('Focus on remembering card positions more carefully')
+ }
+
+ if (efficiency >= 85) {
+ strengths.push('Very efficient with minimal unnecessary moves')
+ } else if (efficiency < 60) {
+ improvements.push('Try to reduce random guessing and use memory strategies')
+ }
+
+ const avgTimePerMoveSeconds = averageTimePerMove / 1000
+ if (avgTimePerMoveSeconds < 3) {
+ strengths.push('Quick decision making')
+ } else if (avgTimePerMoveSeconds > 8) {
+ improvements.push('Practice to improve decision speed')
+ }
+
+ if (difficulty >= 12) {
+ strengths.push('Tackled challenging difficulty levels')
+ }
+
+ if (state.gameType === 'complement-pairs' && accuracy >= 80) {
+ strengths.push('Strong mathematical complement skills')
+ }
+
+ // Fallback messages
+ if (strengths.length === 0) {
+ strengths.push('Keep practicing to improve your skills!')
+ }
+ if (improvements.length === 0) {
+ improvements.push('Great job! Continue challenging yourself with harder difficulties.')
+ }
+
+ return {
+ statistics,
+ grade,
+ strengths,
+ improvements,
+ starRating,
+ }
+}
+
+// Format time duration for display
+export function formatGameTime(milliseconds: number): string {
+ const seconds = Math.floor(milliseconds / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const remainingSeconds = seconds % 60
+
+ if (minutes > 0) {
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
+ }
+ return `${remainingSeconds}s`
+}
+
+// Get two-player game winner
+// @deprecated Use getMultiplayerWinner instead which supports N players
+export function getTwoPlayerWinner(
+ state: MemoryPairsState,
+ activePlayers: Player[]
+): {
+ winner: Player | 'tie'
+ winnerScore: number
+ loserScore: number
+ margin: number
+} {
+ const { scores } = state
+ const [player1, player2] = activePlayers
+
+ if (!player1 || !player2) {
+ throw new Error('getTwoPlayerWinner requires at least 2 active players')
+ }
+
+ const score1 = scores[player1] || 0
+ const score2 = scores[player2] || 0
+
+ if (score1 > score2) {
+ return {
+ winner: player1,
+ winnerScore: score1,
+ loserScore: score2,
+ margin: score1 - score2,
+ }
+ } else if (score2 > score1) {
+ return {
+ winner: player2,
+ winnerScore: score2,
+ loserScore: score1,
+ margin: score2 - score1,
+ }
+ } else {
+ return {
+ winner: 'tie',
+ winnerScore: score1,
+ loserScore: score2,
+ margin: 0,
+ }
+ }
+}
+
+// Get multiplayer game winner (supports N players)
+export function getMultiplayerWinner(
+ state: MemoryPairsState,
+ activePlayers: Player[]
+): {
+ winners: Player[]
+ winnerScore: number
+ scores: { [playerId: string]: number }
+ isTie: boolean
+} {
+ const { scores } = state
+
+ // Find the highest score
+ const maxScore = Math.max(...activePlayers.map((playerId) => scores[playerId] || 0))
+
+ // Find all players with the highest score
+ const winners = activePlayers.filter((playerId) => (scores[playerId] || 0) === maxScore)
+
+ return {
+ winners,
+ winnerScore: maxScore,
+ scores,
+ isTie: winners.length > 1,
+ }
+}
diff --git a/apps/web/src/arcade-games/matching/utils/matchValidation.ts b/apps/web/src/arcade-games/matching/utils/matchValidation.ts
new file mode 100644
index 00000000..f706b490
--- /dev/null
+++ b/apps/web/src/arcade-games/matching/utils/matchValidation.ts
@@ -0,0 +1,222 @@
+import type { GameCard, MatchValidationResult } from '../types'
+
+// Validate abacus-numeral match (abacus card matches with number card of same value)
+export function validateAbacusNumeralMatch(
+ card1: GameCard,
+ card2: GameCard
+): MatchValidationResult {
+ // Both cards must have the same number
+ if (card1.number !== card2.number) {
+ return {
+ isValid: false,
+ reason: 'Numbers do not match',
+ type: 'invalid',
+ }
+ }
+
+ // Cards must be different types (one abacus, one number)
+ if (card1.type === card2.type) {
+ return {
+ isValid: false,
+ reason: 'Both cards are the same type',
+ type: 'invalid',
+ }
+ }
+
+ // One must be abacus, one must be number
+ const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus'
+ const hasNumber = card1.type === 'number' || card2.type === 'number'
+
+ if (!hasAbacus || !hasNumber) {
+ return {
+ isValid: false,
+ reason: 'Must match abacus with number representation',
+ type: 'invalid',
+ }
+ }
+
+ // Neither should be complement type for this game mode
+ if (card1.type === 'complement' || card2.type === 'complement') {
+ return {
+ isValid: false,
+ reason: 'Complement cards not valid in abacus-numeral mode',
+ type: 'invalid',
+ }
+ }
+
+ return {
+ isValid: true,
+ type: 'abacus-numeral',
+ }
+}
+
+// Validate complement match (two numbers that add up to target sum)
+export function validateComplementMatch(card1: GameCard, card2: GameCard): MatchValidationResult {
+ // Both cards must be complement type
+ if (card1.type !== 'complement' || card2.type !== 'complement') {
+ return {
+ isValid: false,
+ reason: 'Both cards must be complement type',
+ type: 'invalid',
+ }
+ }
+
+ // Both cards must have the same target sum
+ if (card1.targetSum !== card2.targetSum) {
+ return {
+ isValid: false,
+ reason: 'Cards have different target sums',
+ type: 'invalid',
+ }
+ }
+
+ // Check if the numbers are actually complements
+ if (!card1.complement || !card2.complement) {
+ return {
+ isValid: false,
+ reason: 'Complement information missing',
+ type: 'invalid',
+ }
+ }
+
+ // Verify the complement relationship
+ if (card1.number !== card2.complement || card2.number !== card1.complement) {
+ return {
+ isValid: false,
+ reason: 'Numbers are not complements of each other',
+ type: 'invalid',
+ }
+ }
+
+ // Verify the sum equals the target
+ const sum = card1.number + card2.number
+ if (sum !== card1.targetSum) {
+ return {
+ isValid: false,
+ reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
+ type: 'invalid',
+ }
+ }
+
+ return {
+ isValid: true,
+ type: 'complement',
+ }
+}
+
+// Main validation function that determines which validation to use
+export function validateMatch(card1: GameCard, card2: GameCard): MatchValidationResult {
+ // Cannot match the same card with itself
+ if (card1.id === card2.id) {
+ return {
+ isValid: false,
+ reason: 'Cannot match card with itself',
+ type: 'invalid',
+ }
+ }
+
+ // Cannot match already matched cards
+ if (card1.matched || card2.matched) {
+ return {
+ isValid: false,
+ reason: 'Cannot match already matched cards',
+ type: 'invalid',
+ }
+ }
+
+ // Determine which type of match to validate based on card types
+ const hasComplement = card1.type === 'complement' || card2.type === 'complement'
+
+ if (hasComplement) {
+ // If either card is complement type, use complement validation
+ return validateComplementMatch(card1, card2)
+ } else {
+ // Otherwise, use abacus-numeral validation
+ return validateAbacusNumeralMatch(card1, card2)
+ }
+}
+
+// Helper function to check if a card can be flipped
+export function canFlipCard(
+ card: GameCard,
+ flippedCards: GameCard[],
+ isProcessingMove: boolean
+): boolean {
+ // Cannot flip if processing a move
+ if (isProcessingMove) return false
+
+ // Cannot flip already matched cards
+ if (card.matched) return false
+
+ // Cannot flip if already flipped
+ if (flippedCards.some((c) => c.id === card.id)) return false
+
+ // Cannot flip if two cards are already flipped
+ if (flippedCards.length >= 2) return false
+
+ return true
+}
+
+// Get hint for what kind of match the player should look for
+export function getMatchHint(card: GameCard): string {
+ switch (card.type) {
+ case 'abacus':
+ return `Find the number ${card.number}`
+
+ case 'number':
+ return `Find the abacus showing ${card.number}`
+
+ case 'complement':
+ if (card.complement !== undefined && card.targetSum !== undefined) {
+ return `Find ${card.complement} to make ${card.targetSum}`
+ }
+ return 'Find the matching complement'
+
+ default:
+ return 'Find the matching card'
+ }
+}
+
+// Calculate match score based on difficulty and time
+export function calculateMatchScore(
+ difficulty: number,
+ timeForMatch: number,
+ isComplementMatch: boolean
+): number {
+ const baseScore = isComplementMatch ? 15 : 10 // Complement matches worth more
+ const difficultyMultiplier = difficulty / 6 // Scale with difficulty
+ const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000) // Bonus for speed
+
+ return Math.round(baseScore * difficultyMultiplier + timeBonus)
+}
+
+// Analyze game performance
+export function analyzeGamePerformance(
+ totalMoves: number,
+ matchedPairs: number,
+ totalPairs: number,
+ gameTime: number
+): {
+ accuracy: number
+ efficiency: number
+ averageTimePerMove: number
+ grade: 'A' | 'B' | 'C' | 'D' | 'F'
+} {
+ const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0
+ const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0 // Ideal is 100% (each pair found in 2 moves)
+ const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0
+
+ // Calculate grade based on accuracy and efficiency
+ let grade: 'A' | 'B' | 'C' | 'D' | 'F' = 'F'
+ if (accuracy >= 90 && efficiency >= 80) grade = 'A'
+ else if (accuracy >= 80 && efficiency >= 70) grade = 'B'
+ else if (accuracy >= 70 && efficiency >= 60) grade = 'C'
+ else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
+
+ return {
+ accuracy,
+ efficiency,
+ averageTimePerMove,
+ grade,
+ }
+}
diff --git a/apps/web/src/lib/arcade/game-configs.ts b/apps/web/src/lib/arcade/game-configs.ts
index 64290817..96436a48 100644
--- a/apps/web/src/lib/arcade/game-configs.ts
+++ b/apps/web/src/lib/arcade/game-configs.ts
@@ -2,8 +2,8 @@
* Shared game configuration types
*
* ARCHITECTURE: Phase 3 - Type Inference
- * - Modern games (number-guesser, math-sprint, memory-quiz): Types inferred from game definitions
- * - Legacy games (matching, complement-race): Manual types until migrated
+ * - Modern games (number-guesser, math-sprint, memory-quiz, matching): Types inferred from game definitions
+ * - Legacy games (complement-race): Manual types until migrated
*
* These types are used across:
* - Database storage (room_game_configs table)
@@ -12,12 +12,11 @@
* - Helper functions (reading/writing configs)
*/
-import type { Difficulty, GameType } from '@/app/games/matching/context/types'
-
// Type-only imports (won't load React components at runtime)
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
import type { mathSprintGame } from '@/arcade-games/math-sprint'
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
+import type { matchingGame } from '@/arcade-games/matching'
/**
* Utility type: Extract config type from a game definition
@@ -47,20 +46,17 @@ export type MathSprintGameConfig = InferGameConfig
*/
export type MemoryQuizGameConfig = InferGameConfig
+/**
+ * Configuration for matching (memory pairs battle) game
+ * INFERRED from matchingGame.defaultConfig
+ */
+export type MatchingGameConfig = InferGameConfig
+
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
// ============================================================================
-/**
- * Configuration for matching (memory pairs) game
- */
-export interface MatchingGameConfig {
- gameType: GameType
- difficulty: Difficulty
- turnTimer: number
-}
-
/**
* Configuration for complement-race game
* TODO: Define when implementing complement-race settings
@@ -83,9 +79,9 @@ export type GameConfigByName = {
'number-guesser': NumberGuesserGameConfig
'math-sprint': MathSprintGameConfig
'memory-quiz': MemoryQuizGameConfig
+ matching: MatchingGameConfig
// Legacy games (manual types)
- matching: MatchingGameConfig
'complement-race': ComplementRaceGameConfig
}
diff --git a/apps/web/src/lib/arcade/game-registry.ts b/apps/web/src/lib/arcade/game-registry.ts
index 4561eb5e..c7238fe1 100644
--- a/apps/web/src/lib/arcade/game-registry.ts
+++ b/apps/web/src/lib/arcade/game-registry.ts
@@ -109,7 +109,9 @@ export function clearRegistry(): void {
import { numberGuesserGame } from '@/arcade-games/number-guesser'
import { mathSprintGame } from '@/arcade-games/math-sprint'
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
+import { matchingGame } from '@/arcade-games/matching'
registerGame(numberGuesserGame)
registerGame(mathSprintGame)
registerGame(memoryQuizGame)
+registerGame(matchingGame)