soroban-abacus-flashcards/apps/web/CARD_SORTING_PORT_PLAN.md

49 KiB

Card Sorting Challenge - Arcade Room Port Plan

Executive Summary

Porting the Card Sorting Challenge from standalone HTML to the arcade room platform as a single-player game. The game challenges players to arrange abacus cards in ascending order using only visual patterns (no numbers shown).

Complexity: Medium - Simpler than matching/memory-quiz (no multiplayer turn logic), but more complex scoring algorithm.

Timeline Estimate: 1-2 days for full implementation and testing


1. Game Analysis

1.1 Current Implementation (web_generator.py)

Core Mechanics:

  • Player selects difficulty (5, 8, 12, or 15 cards)
  • Cards displayed in random order with abacus SVGs only (numbers hidden)
  • Player arranges cards using click-to-select + click-to-place interaction
  • "Reveal Numbers" button available (affects scoring but not shown)
  • Timer tracks duration
  • Smart scoring algorithm (not just exact position matching)

Key Features:

  1. Card Pool: Uses existing flashcard data (AbacusReact components)
  2. Interaction Model:
    • Click card → select
    • Click position slot OR insert button (+) → place
    • Click placed card → remove back to available
  3. Position Slots: Visual gradient from dark (smallest) to light (largest)
  4. Scoring Algorithm (3 metrics):
    • Longest Common Subsequence (50% weight) - relative order
    • Exact Position Matches (30% weight) - correct positions
    • Inversion Count (20% weight) - overall organization
  5. Feedback: Detailed breakdown of score components
  6. Timer: Tracks elapsed time, displayed in results

State Management:

{
  cards: [],              // All available cards from flashcards
  sortingCards: [],       // Selected subset for this game
  selectedCount: 5,       // Difficulty setting
  currentOrder: [],       // Cards still available to place
  correctOrder: [],       // Sorted correct answer
  placedCards: [],        // Array of placed cards (null = empty slot)
  selectedCard: null,     // Currently selected card
  numbersRevealed: false, // Whether player used reveal button
  startTime: Date,        // For timer
  timerInterval: null     // Timer interval ID
}

1.2 Arcade Platform Requirements

Must implement:

  • SDK-compatible types (GameConfig, GameState, GameMove)
  • Server-side Validator class
  • Client-side Provider with useArcadeSession
  • React component structure (Setup, Playing, Results phases)
  • Config persistence to database
  • Move validation and state transitions

Platform Conventions:

  • Phase-based: setupplayingresults
  • Standard moves: START_GAME, GO_TO_SETUP, SET_CONFIG
  • Pause/Resume pattern (optional for single-player)
  • Config changes tracked and persisted

2. Architecture Design

2.1 Directory Structure

src/arcade-games/card-sorting/
├── index.ts                      # Game definition & registration
├── types.ts                      # TypeScript type definitions
├── Provider.tsx                  # Client-side state management
├── Validator.ts                  # Server-side game logic
├── components/
│   ├── index.ts                  # Component exports
│   ├── GameComponent.tsx         # Main wrapper component
│   ├── SetupPhase.tsx            # Configuration UI
│   ├── PlayingPhase.tsx          # Main game UI
│   └── ResultsPhase.tsx          # Score display & feedback
└── utils/
    ├── cardGeneration.ts         # Random card selection
    ├── scoringAlgorithm.ts       # LCS, inversions, etc.
    └── validation.ts             # Move validation helpers

2.2 Type Definitions (types.ts)

import type { GameConfig, GameState } from "@/lib/arcade/game-sdk/types";

// ============================================================================
// Configuration
// ============================================================================

export interface CardSortingConfig extends GameConfig {
  cardCount: 5 | 8 | 12 | 15; // Difficulty (number of cards)
  showNumbers: boolean; // Allow reveal numbers button
  timeLimit: number | null; // Optional time limit (seconds), null = unlimited
}

// ============================================================================
// Core Data Types
// ============================================================================

export type GamePhase = "setup" | "playing" | "results";

export interface SortingCard {
  id: string; // Unique ID for this card instance
  number: number; // The abacus value (0-99+)
  svgContent: string; // Serialized AbacusReact SVG
}

export interface PlacedCard {
  card: SortingCard; // The card data
  position: number; // Which slot it's in (0-indexed)
}

export interface ScoreBreakdown {
  finalScore: number; // 0-100 weighted average
  exactMatches: number; // Cards in exactly correct position
  lcsLength: number; // Longest common subsequence length
  inversions: number; // Number of out-of-order pairs
  relativeOrderScore: number; // 0-100 based on LCS
  exactPositionScore: number; // 0-100 based on exact matches
  inversionScore: number; // 0-100 based on inversions
  elapsedTime: number; // Seconds taken
  numbersRevealed: boolean; // Whether player used reveal
}

// ============================================================================
// Game State
// ============================================================================

export interface CardSortingState extends GameState {
  // Configuration
  cardCount: 5 | 8 | 12 | 15;
  showNumbers: boolean;
  timeLimit: number | null;

  // Game phase
  gamePhase: GamePhase;

  // Player & timing
  playerId: string; // Single player ID
  playerMetadata: PlayerMetadata; // Player display info
  gameStartTime: number | null;
  gameEndTime: number | null;

  // Cards
  selectedCards: SortingCard[]; // The N cards for this game
  correctOrder: SortingCard[]; // Sorted by number (answer key)
  availableCards: SortingCard[]; // Cards not yet placed
  placedCards: (SortingCard | null)[]; // Array of N slots (null = empty)

  // UI state (client-only, not in server state)
  selectedCardId: string | null; // Currently selected card
  numbersRevealed: boolean; // If player revealed numbers

  // Results
  scoreBreakdown: ScoreBreakdown | null; // Final score details

  // Pause/Resume (standard pattern)
  originalConfig?: CardSortingConfig;
  pausedGamePhase?: GamePhase;
  pausedGameState?: {
    selectedCards: SortingCard[];
    availableCards: SortingCard[];
    placedCards: (SortingCard | null)[];
    gameStartTime: number;
    numbersRevealed: boolean;
  };
}

// ============================================================================
// Game Moves
// ============================================================================

export type CardSortingMove =
  | {
      type: "START_GAME";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {
        playerMetadata: PlayerMetadata;
        selectedCards: SortingCard[]; // Pre-selected random cards
      };
    }
  | {
      type: "PLACE_CARD";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {
        cardId: string; // Which card to place
        position: number; // Which slot (0-indexed)
      };
    }
  | {
      type: "REMOVE_CARD";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {
        position: number; // Which slot to remove from
      };
    }
  | {
      type: "REVEAL_NUMBERS";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {};
    }
  | {
      type: "CHECK_SOLUTION";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {};
    }
  | {
      type: "GO_TO_SETUP";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {};
    }
  | {
      type: "SET_CONFIG";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {
        field: "cardCount" | "showNumbers" | "timeLimit";
        value: any;
      };
    }
  | {
      type: "RESUME_GAME";
      playerId: string;
      userId: string;
      timestamp: number;
      data: {};
    };

// ============================================================================
// Component Props
// ============================================================================

export interface SortingCardProps {
  card: SortingCard;
  isSelected: boolean;
  isPlaced: boolean;
  isCorrect?: boolean; // After checking solution
  onClick: () => void;
  showNumber: boolean; // If revealed
}

export interface PositionSlotProps {
  position: number;
  card: SortingCard | null;
  isActive: boolean; // If slot is clickable
  isCorrect?: boolean; // After checking solution
  gradientStyle: React.CSSProperties;
  onClick: () => void;
}

export interface ScoreDisplayProps {
  breakdown: ScoreBreakdown;
  correctOrder: SortingCard[];
  userOrder: SortingCard[];
  onNewGame: () => void;
  onExit: () => void;
}

2.3 Validator (Validator.ts)

Responsibilities:

  • Validate all moves server-side
  • Calculate scores when checking solution
  • Manage state transitions
  • Generate initial state from config

Key Methods:

class CardSortingValidator
  implements GameValidator<CardSortingState, CardSortingMove>
{
  validateMove(state, move, context): ValidationResult {
    switch (move.type) {
      case "START_GAME":
        return this.validateStartGame(state, move.data);
      case "PLACE_CARD":
        return this.validatePlaceCard(
          state,
          move.data.cardId,
          move.data.position,
        );
      case "REMOVE_CARD":
        return this.validateRemoveCard(state, move.data.position);
      case "REVEAL_NUMBERS":
        return this.validateRevealNumbers(state);
      case "CHECK_SOLUTION":
        return this.validateCheckSolution(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);
    }
  }

  // Core validation methods
  private validatePlaceCard(state, cardId, position): ValidationResult {
    // Must be in playing phase
    // Card must exist in availableCards
    // Position must be valid (0 to cardCount-1)
    // Position can be null (insert) or occupied (swap logic)

    const card = state.availableCards.find((c) => c.id === cardId);
    if (!card) return { valid: false, error: "Card not found" };

    if (position < 0 || position >= state.cardCount) {
      return { valid: false, error: "Invalid position" };
    }

    // Create new state with card placed
    const newAvailable = state.availableCards.filter((c) => c.id !== cardId);
    const newPlaced = [...state.placedCards];

    // Shift logic (compress gaps to left)
    // ... implementation similar to web_generator.py

    return {
      valid: true,
      newState: {
        ...state,
        availableCards: newAvailable,
        placedCards: newPlaced,
      },
    };
  }

  private validateCheckSolution(state): ValidationResult {
    // All slots must be filled
    if (state.placedCards.some((c) => c === null)) {
      return { valid: false, error: "Must place all cards first" };
    }

    // Calculate score using scoring algorithms
    const userSequence = state.placedCards.map((c) => c!.number);
    const correctSequence = state.correctOrder.map((c) => c.number);

    const scoreBreakdown = this.calculateScore(
      userSequence,
      correctSequence,
      state.gameStartTime,
      state.numbersRevealed,
    );

    return {
      valid: true,
      newState: {
        ...state,
        gamePhase: "results",
        gameEndTime: Date.now(),
        scoreBreakdown,
      },
    };
  }

  // Scoring algorithm (port from web_generator.py)
  private calculateScore(
    userSeq,
    correctSeq,
    startTime,
    revealed,
  ): ScoreBreakdown {
    const lcs = this.longestCommonSubsequence(userSeq, correctSeq);
    const exactMatches = userSeq.filter((n, i) => n === correctSeq[i]).length;
    const inversions = this.countInversions(userSeq, correctSeq);

    const relativeOrderScore = (lcs / correctSeq.length) * 100;
    const exactPositionScore = (exactMatches / correctSeq.length) * 100;
    const maxInversions = (correctSeq.length * (correctSeq.length - 1)) / 2;
    const inversionScore = Math.max(
      0,
      ((maxInversions - inversions) / maxInversions) * 100,
    );

    const finalScore = Math.round(
      relativeOrderScore * 0.5 +
        exactPositionScore * 0.3 +
        inversionScore * 0.2,
    );

    return {
      finalScore,
      exactMatches,
      lcsLength: lcs,
      inversions,
      relativeOrderScore: Math.round(relativeOrderScore),
      exactPositionScore: Math.round(exactPositionScore),
      inversionScore: Math.round(inversionScore),
      elapsedTime: Math.floor((Date.now() - startTime) / 1000),
      numbersRevealed: revealed,
    };
  }

  private longestCommonSubsequence(seq1, seq2): number {
    // Dynamic programming LCS algorithm
    // Port from web_generator.py lines 9470-9486
  }

  private countInversions(userSeq, correctSeq): number {
    // Count out-of-order pairs
    // Port from web_generator.py lines 9488-9509
  }

  getInitialState(config: CardSortingConfig): CardSortingState {
    return {
      cardCount: config.cardCount,
      showNumbers: config.showNumbers,
      timeLimit: config.timeLimit,
      gamePhase: "setup",
      playerId: "",
      playerMetadata: {},
      gameStartTime: null,
      gameEndTime: null,
      selectedCards: [],
      correctOrder: [],
      availableCards: [],
      placedCards: new Array(config.cardCount).fill(null),
      selectedCardId: null,
      numbersRevealed: false,
      scoreBreakdown: null,
    };
  }
}

2.4 Provider (Provider.tsx)

Responsibilities:

  • Wrap children with context provider
  • Integrate with useArcadeSession hook
  • Provide action creators (startGame, placeCard, etc.)
  • Handle optimistic updates for smooth UX
  • Manage local UI state (selected card highlight, etc.)

Key Implementation:

export function CardSortingProvider({ children }: { children: ReactNode }) {
  const { data: viewerId } = useViewerId()
  const { roomData } = useRoomData()
  const { activePlayers, players } = useGameMode()
  const { mutate: updateGameConfig } = useUpdateGameConfig()

  // Get local player (single player game)
  const localPlayerId = useMemo(() => {
    return Array.from(activePlayers).find(id => {
      const player = players.get(id)
      return player?.isLocal
    })
  }, [activePlayers, players])

  // Merge saved config with defaults
  const initialState = useMemo((): CardSortingState => {
    const savedConfig = roomData?.gameConfig?.['card-sorting']

    return {
      cardCount: savedConfig?.cardCount ?? 5,
      showNumbers: savedConfig?.showNumbers ?? true,
      timeLimit: savedConfig?.timeLimit ?? null,
      gamePhase: 'setup',
      // ... rest of initial state
    }
  }, [roomData?.gameConfig])

  // Arcade session integration
  const {
    state: serverState,
    sendMove,
    exitSession,
    lastError,
    clearError,
  } = useArcadeSession<CardSortingState>({
    userId: viewerId || '',
    roomId: roomData?.id,
    initialState,
    applyMove: applyMoveOptimistically,  // For responsive UI
  })

  // Local UI state (not synced)
  const [localUIState, setLocalUIState] = useState({
    selectedCardId: null as string | null,
    highlightedSlot: null as number | null,
  })

  // Action creators
  const startGame = useCallback(() => {
    if (!localPlayerId) return

    const playerMetadata = buildPlayerMetadata(
      [localPlayerId],
      {},
      players,
      viewerId
    )

    // Generate random cards
    const selectedCards = generateRandomCards(serverState.cardCount)

    sendMove({
      type: 'START_GAME',
      playerId: localPlayerId,
      userId: viewerId || '',
      data: { playerMetadata, selectedCards }
    })
  }, [localPlayerId, serverState.cardCount, sendMove])

  const placeCard = useCallback((cardId: string, position: number) => {
    if (!localPlayerId) return

    sendMove({
      type: 'PLACE_CARD',
      playerId: localPlayerId,
      userId: viewerId || '',
      data: { cardId, position }
    })

    // Clear local selection
    setLocalUIState(prev => ({ ...prev, selectedCardId: null }))
  }, [localPlayerId, sendMove])

  const removeCard = useCallback((position: number) => {
    if (!localPlayerId) return

    sendMove({
      type: 'REMOVE_CARD',
      playerId: localPlayerId,
      userId: viewerId || '',
      data: { position }
    })
  }, [localPlayerId, sendMove])

  const checkSolution = useCallback(() => {
    if (!localPlayerId) return

    sendMove({
      type: 'CHECK_SOLUTION',
      playerId: localPlayerId,
      userId: viewerId || '',
      data: {}
    })
  }, [localPlayerId, sendMove])

  const revealNumbers = useCallback(() => {
    if (!localPlayerId) return

    sendMove({
      type: 'REVEAL_NUMBERS',
      playerId: localPlayerId,
      userId: viewerId || '',
      data: {}
    })
  }, [localPlayerId, sendMove])

  // ... other action creators (goToSetup, setConfig, etc.)

  // Computed values
  const canCheckSolution = serverState.placedCards.every(c => c !== null)
  const placedCount = serverState.placedCards.filter(c => c !== null).length
  const elapsedTime = serverState.gameStartTime
    ? Math.floor((Date.now() - serverState.gameStartTime) / 1000)
    : 0

  const contextValue = {
    state: serverState,
    localUIState,
    setLocalUIState,

    // Actions
    startGame,
    placeCard,
    removeCard,
    checkSolution,
    revealNumbers,
    goToSetup,
    setConfig,
    resumeGame,
    exitSession,

    // Computed
    canCheckSolution,
    placedCount,
    elapsedTime,

    // Helpers
    selectCard: (cardId: string | null) => {
      setLocalUIState(prev => ({ ...prev, selectedCardId: cardId }))
    },
  }

  return (
    <CardSortingContext.Provider value={contextValue}>
      {children}
    </CardSortingContext.Provider>
  )
}

2.5 Component Structure

SetupPhase.tsx

Purpose: Configure game before starting

UI Elements:

  • Card count selector (5, 8, 12, 15) - button group
  • Show numbers toggle - checkbox
  • Time limit selector - dropdown (unlimited, 1min, 2min, 3min, 5min)
  • Start Game button
  • Resume Game button (if paused game exists)

Implementation:

export function SetupPhase() {
  const { state, setConfig, startGame, resumeGame } = useCardSorting()
  const canResume = state.pausedGamePhase && !hasConfigChanged()

  return (
    <div className={css({ ... })}>
      <h2>Card Sorting Challenge</h2>
      <p>Arrange cards in order using only abacus patterns</p>

      <div className={css({ /* card count buttons */ })}>
        {[5, 8, 12, 15].map(count => (
          <button
            key={count}
            onClick={() => setConfig('cardCount', count)}
            className={css({ /* active if selected */ })}
          >
            {count} Cards
          </button>
        ))}
      </div>

      <label>
        <input
          type="checkbox"
          checked={state.showNumbers}
          onChange={(e) => setConfig('showNumbers', e.target.checked)}
        />
        Allow "Reveal Numbers" button
      </label>

      <div className={css({ /* button group */ })}>
        {canResume && (
          <button onClick={resumeGame}>Resume Game</button>
        )}
        <button onClick={startGame}>Start New Game</button>
      </div>
    </div>
  )
}

PlayingPhase.tsx

Purpose: Main game interface

UI Sections:

  1. Header (sticky):

    • Timer display (MM:SS)
    • Status message ("5/8 cards placed", etc.)
    • Action buttons: Check Solution, Reveal Numbers, End Game
  2. Two-column layout:

    • Left: Available cards grid
    • Right: Position slots (sequential, gradient background)
  3. Card interaction:

    • Click card → select (highlight border)
    • Click slot → place selected card
    • Click + button → insert at position
    • Click placed card → remove to available

Implementation:

export function PlayingPhase() {
  const {
    state,
    localUIState,
    selectCard,
    placeCard,
    removeCard,
    checkSolution,
    revealNumbers,
    goToSetup,
    canCheckSolution,
    placedCount,
    elapsedTime
  } = useCardSorting()

  const handleCardClick = (card: SortingCard) => {
    if (localUIState.selectedCardId === card.id) {
      selectCard(null)  // Deselect
    } else {
      selectCard(card.id)
    }
  }

  const handleSlotClick = (position: number) => {
    if (!localUIState.selectedCardId) {
      // Remove card if slot is occupied
      if (state.placedCards[position]) {
        removeCard(position)
      }
    } else {
      // Place selected card
      placeCard(localUIState.selectedCardId, position)
    }
  }

  const formatTime = (seconds: number) => {
    const m = Math.floor(seconds / 60)
    const s = seconds % 60
    return `${m}:${s.toString().padStart(2, '0')}`
  }

  const getGradientStyle = (position: number, total: number) => {
    const intensity = position / (total - 1)
    const lightness = 30 + (intensity * 45)
    return {
      background: `hsl(220, 8%, ${lightness}%)`,
      color: lightness > 60 ? '#2c3e50' : '#ffffff',
    }
  }

  return (
    <div>
      {/* Sticky header */}
      <div className={css({ position: 'sticky', top: 0, ... })}>
        <div>
          <span>Time: {formatTime(elapsedTime)}</span>
          <span>{placedCount}/{state.cardCount} placed</span>
        </div>
        <div>
          <button
            onClick={checkSolution}
            disabled={!canCheckSolution}
          >
            Check Solution
          </button>
          {state.showNumbers && !state.numbersRevealed && (
            <button onClick={revealNumbers}>
              Reveal Numbers
            </button>
          )}
          <button onClick={goToSetup}>End Game</button>
        </div>
      </div>

      {/* Main game area */}
      <div className={css({ display: 'flex', gap: '2rem' })}>
        {/* Available cards */}
        <div className={css({ flex: 1 })}>
          <h3>Available Cards</h3>
          <div className={css({ display: 'grid', gap: '1rem', ... })}>
            {state.availableCards.map(card => (
              <SortingCard
                key={card.id}
                card={card}
                isSelected={localUIState.selectedCardId === card.id}
                isPlaced={false}
                showNumber={state.numbersRevealed}
                onClick={() => handleCardClick(card)}
              />
            ))}
          </div>
        </div>

        {/* Position slots */}
        <div className={css({ flex: 2 })}>
          <h3>Sort Positions (Smallest  Largest)</h3>
          <div className={css({ display: 'flex', flexDirection: 'column', gap: '0.5rem' })}>
            {state.placedCards.map((card, index) => (
              <div key={index} className={css({ display: 'flex', alignItems: 'center' })}>
                <PositionSlot
                  position={index}
                  card={card}
                  isActive={!!localUIState.selectedCardId || !!card}
                  gradientStyle={getGradientStyle(index, state.cardCount)}
                  onClick={() => handleSlotClick(index)}
                />
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}

ResultsPhase.tsx

Purpose: Display score and feedback

UI Elements:

  • Final score (0-100%)
  • Performance message (Perfect! / Excellent! / Good! / Keep practicing!)
  • Detailed breakdown:
    • Exact position matches (X/N cards)
    • Relative order score (LCS-based)
    • Organization score (inversion-based)
    • Time taken
    • Whether numbers were revealed
  • Visual comparison: user order vs correct order
  • Action buttons: New Game, Change Settings, Exit

Implementation:

export function ResultsPhase() {
  const { state, startGame, goToSetup, exitSession } = useCardSorting()
  const { scoreBreakdown } = state

  if (!scoreBreakdown) return null

  const getMessage = (score: number) => {
    if (score === 100) return '🎉 Perfect! All cards in correct order!'
    if (score >= 80) return '👍 Excellent! Very close to perfect!'
    if (score >= 60) return '👍 Good job! You understand the pattern!'
    return '💪 Keep practicing! Focus on reading each abacus carefully.'
  }

  const getEmoji = (score: number) => {
    if (score === 100) return '🏆'
    if (score >= 80) return '⭐'
    if (score >= 60) return '👍'
    return '📈'
  }

  return (
    <div className={css({ ... })}>
      {/* Score display */}
      <div className={css({ textAlign: 'center', mb: '2rem' })}>
        <div className={css({ fontSize: '4rem' })}>
          {getEmoji(scoreBreakdown.finalScore)}
        </div>
        <h2>Your Score: {scoreBreakdown.finalScore}%</h2>
        <p>{getMessage(scoreBreakdown.finalScore)}</p>
      </div>

      {/* Detailed breakdown */}
      <div className={css({ ... })}>
        <h3>Score Breakdown</h3>

        <div>
          <label>Exact Position Matches (30%)</label>
          <progress value={scoreBreakdown.exactPositionScore} max={100} />
          <span>{scoreBreakdown.exactMatches}/{state.cardCount} cards</span>
        </div>

        <div>
          <label>Relative Order (50%)</label>
          <progress value={scoreBreakdown.relativeOrderScore} max={100} />
          <span>{scoreBreakdown.lcsLength}/{state.cardCount} in correct sequence</span>
        </div>

        <div>
          <label>Organization (20%)</label>
          <progress value={scoreBreakdown.inversionScore} max={100} />
          <span>{scoreBreakdown.inversions} out-of-order pairs</span>
        </div>

        <div>
          <label>Time Taken</label>
          <span>{scoreBreakdown.elapsedTime}s</span>
        </div>

        {scoreBreakdown.numbersRevealed && (
          <div className={css({ color: 'orange' })}>
            ⚠️ Numbers were revealed during play
          </div>
        )}
      </div>

      {/* Visual comparison */}
      <div className={css({ ... })}>
        <h3>Comparison</h3>
        <div>
          <h4>Your Answer:</h4>
          <div className={css({ display: 'flex', gap: '0.5rem' })}>
            {state.placedCards.map((card, i) => (
              <MiniCard
                key={i}
                card={card!}
                isCorrect={card!.number === state.correctOrder[i].number}
              />
            ))}
          </div>
        </div>
        <div>
          <h4>Correct Order:</h4>
          <div className={css({ display: 'flex', gap: '0.5rem' })}>
            {state.correctOrder.map((card, i) => (
              <MiniCard key={i} card={card} showNumber />
            ))}
          </div>
        </div>
      </div>

      {/* Actions */}
      <div className={css({ ... })}>
        <button onClick={startGame}>New Game (Same Settings)</button>
        <button onClick={goToSetup}>Change Settings</button>
        <button onClick={exitSession}>Exit to Room</button>
      </div>
    </div>
  )
}

3. Implementation Details

3.1 Card Generation

Source: AbacusReact components from the existing flashcard system

Approach:

// utils/cardGeneration.ts

import { AbacusReact } from '@soroban/abacus-react'
import { renderToString } from 'react-dom/server'
import type { SortingCard } from '../types'

/**
 * Generate random cards for sorting game
 * @param count Number of cards to generate
 * @param minValue Minimum abacus value (default 0)
 * @param maxValue Maximum abacus value (default 99)
 */
export function generateRandomCards(
  count: number,
  minValue: number = 0,
  maxValue: number = 99
): SortingCard[] {
  // Generate pool of unique random numbers
  const numbers = new Set<number>()
  while (numbers.size < count) {
    const num = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
    numbers.add(num)
  }

  // Convert to sorted array (for answer key)
  const sortedNumbers = Array.from(numbers).sort((a, b) => a - b)

  // Create card objects with SVG content
  return sortedNumbers.map((number, index) => {
    // Render AbacusReact to SVG string
    const svgContent = renderToString(
      <AbacusReact
        value={number}
        width={200}
        height={120}
        // ... other props for consistent styling
      />
    )

    return {
      id: `card-${index}-${number}`,
      number,
      svgContent
    }
  })
}

/**
 * Shuffle array for random order
 */
export function shuffleCards(cards: SortingCard[]): SortingCard[] {
  const shuffled = [...cards]
  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
}

3.2 Scoring Algorithms

Port from web_generator.py (lines 9425-9509)

// utils/scoringAlgorithm.ts

/**
 * Calculate Longest Common Subsequence length
 * Measures how many cards are in correct relative order
 */
export function longestCommonSubsequence(
  seq1: number[],
  seq2: number[],
): number {
  const m = seq1.length;
  const n = seq2.length;
  const dp: number[][] = Array(m + 1)
    .fill(0)
    .map(() => Array(n + 1).fill(0));

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (seq1[i - 1] === seq2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }

  return dp[m][n];
}

/**
 * Count inversions (out-of-order pairs)
 * Measures how scrambled the sequence is
 */
export function countInversions(
  userSeq: number[],
  correctSeq: number[],
): number {
  // Create mapping from value to correct position
  const correctPositions: Record<number, number> = {};
  correctSeq.forEach((val, idx) => {
    correctPositions[val] = idx;
  });

  // Convert user sequence to correct-position sequence
  const userCorrectPositions = userSeq.map((val) => correctPositions[val]);

  // Count inversions
  let inversions = 0;
  for (let i = 0; i < userCorrectPositions.length; i++) {
    for (let j = i + 1; j < userCorrectPositions.length; j++) {
      if (userCorrectPositions[i] > userCorrectPositions[j]) {
        inversions++;
      }
    }
  }

  return inversions;
}

/**
 * Calculate comprehensive score breakdown
 */
export function calculateScore(
  userSequence: number[],
  correctSequence: number[],
  startTime: number,
  numbersRevealed: boolean,
): ScoreBreakdown {
  // LCS-based score (relative order)
  const lcsLength = longestCommonSubsequence(userSequence, correctSequence);
  const relativeOrderScore = (lcsLength / correctSequence.length) * 100;

  // Exact position matches
  let exactMatches = 0;
  for (let i = 0; i < userSequence.length; i++) {
    if (userSequence[i] === correctSequence[i]) {
      exactMatches++;
    }
  }
  const exactPositionScore = (exactMatches / correctSequence.length) * 100;

  // Inversion-based score (organization)
  const inversions = countInversions(userSequence, correctSequence);
  const maxInversions =
    (correctSequence.length * (correctSequence.length - 1)) / 2;
  const inversionScore = Math.max(
    0,
    ((maxInversions - inversions) / maxInversions) * 100,
  );

  // Weighted final score
  // - 50% for relative order (LCS)
  // - 30% for exact positions
  // - 20% for organization (inversions)
  const finalScore = Math.round(
    relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2,
  );

  return {
    finalScore,
    exactMatches,
    lcsLength,
    inversions,
    relativeOrderScore: Math.round(relativeOrderScore),
    exactPositionScore: Math.round(exactPositionScore),
    inversionScore: Math.round(inversionScore),
    elapsedTime: Math.floor((Date.now() - startTime) / 1000),
    numbersRevealed,
  };
}

3.3 Card Placement Logic

Challenge: Maintaining array compaction (no gaps) when placing/removing cards

Approach:

// utils/validation.ts

/**
 * Place a card at a specific position, shifting existing cards
 * Returns new placedCards array with no gaps
 */
export function placeCardAtPosition(
  placedCards: (SortingCard | null)[],
  cardToPlace: SortingCard,
  position: number,
  totalSlots: number,
): (SortingCard | null)[] {
  // Create working array
  const newPlaced = new Array(totalSlots).fill(null);

  // Copy existing cards, shifting those at/after position
  for (let i = 0; i < placedCards.length; i++) {
    if (placedCards[i] !== null) {
      if (i < position) {
        // Before insert position - stays same
        newPlaced[i] = placedCards[i];
      } else {
        // At or after position - shift right
        if (i + 1 < totalSlots) {
          newPlaced[i + 1] = placedCards[i];
        }
      }
    }
  }

  // Place new card
  newPlaced[position] = cardToPlace;

  // Compact to remove gaps (shift all cards left)
  const compacted: SortingCard[] = [];
  for (const card of newPlaced) {
    if (card !== null) {
      compacted.push(card);
    }
  }

  // Fill final array
  const result = new Array(totalSlots).fill(null);
  for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) {
    result[i] = compacted[i];
  }

  // Any excess cards are returned (shouldn't happen)
  const excess = compacted.slice(totalSlots);

  return { placedCards: result, excessCards: excess };
}

/**
 * Remove card at position
 */
export function removeCardAtPosition(
  placedCards: (SortingCard | null)[],
  position: number,
): { placedCards: (SortingCard | null)[]; removedCard: SortingCard | null } {
  const removedCard = placedCards[position];

  if (!removedCard) {
    return { placedCards, removedCard: null };
  }

  // Remove card and compact
  const compacted: SortingCard[] = [];
  for (let i = 0; i < placedCards.length; i++) {
    if (i !== position && placedCards[i] !== null) {
      compacted.push(placedCards[i]!);
    }
  }

  // Fill new array
  const newPlaced = new Array(placedCards.length).fill(null);
  for (let i = 0; i < compacted.length; i++) {
    newPlaced[i] = compacted[i];
  }

  return { placedCards: newPlaced, removedCard };
}

3.4 Styling with Panda CSS

Pattern: Use Panda CSS css() function for all styling

Key Style Patterns:

// Gradient for position slots
const slotGradient = (position: number, total: number) => {
  const intensity = position / (total - 1);
  const lightness = 30 + intensity * 45; // 30% to 75%

  return css({
    background: `hsl(220, 8%, ${lightness}%)`,
    color: lightness > 60 ? "#2c3e50" : "#ffffff",
    borderColor: lightness > 60 ? "#2c5f76" : "rgba(255,255,255,0.4)",
    padding: "1rem",
    borderRadius: "8px",
    border: "2px solid",
    transition: "all 0.3s ease",
    cursor: "pointer",

    "&:hover": {
      transform: "translateY(-2px)",
      boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
    },
  });
};

// Card styling
const cardStyle = css({
  background: "white",
  borderRadius: "8px",
  padding: "0.5rem",
  border: "2px solid",
  borderColor: "gray.300",
  cursor: "pointer",
  transition: "all 0.2s ease",
  position: "relative",

  "&:hover": {
    transform: "translateY(-4px)",
    boxShadow: "0 6px 16px rgba(0,0,0,0.2)",
    borderColor: "blue.500",
  },

  "&.selected": {
    borderColor: "blue.600",
    background: "blue.50",
    transform: "scale(1.05)",
  },

  "&.placed": {
    opacity: 0.7,
    transform: "scale(0.95)",
  },

  "&.correct": {
    borderColor: "green.500",
    background: "green.50",
  },

  "&.incorrect": {
    borderColor: "red.500",
    background: "red.50",
    animation: "shake 0.5s ease",
  },
});

// Shake animation (for incorrect cards)
const shakeAnimation = css.raw({
  "@keyframes shake": {
    "0%, 100%": { transform: "translateX(0)" },
    "25%": { transform: "translateX(-10px)" },
    "75%": { transform: "translateX(10px)" },
  },
});

4. Game Registration

4.1 Game Definition (index.ts)

import { defineGame } from "@/lib/arcade/game-sdk";
import type { GameManifest } from "@/lib/arcade/game-sdk";
import { GameComponent } from "./components/GameComponent";
import { CardSortingProvider } from "./Provider";
import { cardSortingValidator } from "./Validator";
import type {
  CardSortingConfig,
  CardSortingMove,
  CardSortingState,
} from "./types";

const manifest: GameManifest = {
  name: "card-sorting",
  displayName: "Card Sorting Challenge",
  icon: "🔢",
  description: "Sort abacus cards using pattern recognition",
  longDescription:
    "Challenge your abacus reading skills! Arrange cards in ascending order using only " +
    "the visual patterns - no numbers shown. Perfect for practicing number recognition and " +
    "developing mental math intuition.",
  maxPlayers: 1, // Single player only
  difficulty: "Intermediate",
  chips: ["🧠 Pattern Recognition", "🎯 Solo Challenge", "📊 Smart Scoring"],
  color: "teal",
  gradient: "linear-gradient(135deg, #99f6e4, #5eead4)",
  borderColor: "teal.200",
  available: true,
};

const defaultConfig: CardSortingConfig = {
  cardCount: 8,
  showNumbers: true,
  timeLimit: null,
};

function validateCardSortingConfig(
  config: unknown,
): config is CardSortingConfig {
  if (typeof config !== "object" || config === null) return false;

  const c = config as any;

  if (!("cardCount" in c) || ![5, 8, 12, 15].includes(c.cardCount)) {
    return false;
  }

  if (!("showNumbers" in c) || typeof c.showNumbers !== "boolean") {
    return false;
  }

  if ("timeLimit" in c) {
    if (
      c.timeLimit !== null &&
      (typeof c.timeLimit !== "number" || c.timeLimit < 30)
    ) {
      return false;
    }
  }

  return true;
}

export const cardSortingGame = defineGame<
  CardSortingConfig,
  CardSortingState,
  CardSortingMove
>({
  manifest,
  Provider: CardSortingProvider,
  GameComponent,
  validator: cardSortingValidator,
  defaultConfig,
  validateConfig: validateCardSortingConfig,
});

4.2 Registration in game-registry.ts

// Add import
import { cardSortingGame } from "@/arcade-games/card-sorting";

// Add registration
registerGame(cardSortingGame);

4.3 Validator Registration

// src/lib/arcade/validators.ts

import { cardSortingValidator } from "@/arcade-games/card-sorting/Validator";

export const validatorRegistry = {
  matching: matchingGameValidator,
  "memory-quiz": memoryQuizGameValidator,
  "complement-race": complementRaceValidator,
  "card-sorting": cardSortingValidator, // ADD THIS
} as const;

4.4 Config Types

// src/lib/arcade/game-configs.ts

import type { cardSortingGame } from "@/arcade-games/card-sorting";

export type CardSortingGameConfig = InferGameConfig<typeof cardSortingGame>;

export type GameConfigByName = {
  "memory-quiz": MemoryQuizGameConfig;
  matching: MatchingGameConfig;
  "complement-race": ComplementRaceGameConfig;
  "card-sorting": CardSortingGameConfig; // ADD THIS
};

export const DEFAULT_CARD_SORTING_CONFIG: CardSortingGameConfig = {
  cardCount: 8,
  showNumbers: true,
  timeLimit: null,
};

5. Testing Strategy

5.1 Unit Tests

Scoring algorithms (utils/scoringAlgorithm.test.ts):

describe("longestCommonSubsequence", () => {
  it("should return length of identical sequences", () => {
    expect(longestCommonSubsequence([1, 2, 3], [1, 2, 3])).toBe(3);
  });

  it("should find LCS in scrambled sequence", () => {
    expect(longestCommonSubsequence([3, 1, 2], [1, 2, 3])).toBe(2); // [1,2]
  });
});

describe("countInversions", () => {
  it("should return 0 for sorted sequence", () => {
    expect(countInversions([1, 2, 3], [1, 2, 3])).toBe(0);
  });

  it("should count inversions correctly", () => {
    expect(countInversions([3, 2, 1], [1, 2, 3])).toBe(3);
  });
});

describe("calculateScore", () => {
  it("should return 100% for perfect solution", () => {
    const result = calculateScore([1, 2, 3], [1, 2, 3], Date.now(), false);
    expect(result.finalScore).toBe(100);
  });

  it("should apply weighted scoring correctly", () => {
    const result = calculateScore([2, 1, 3], [1, 2, 3], Date.now(), false);
    // Should be less than 100 but greater than 0
    expect(result.finalScore).toBeGreaterThan(0);
    expect(result.finalScore).toBeLessThan(100);
  });
});

Card placement logic (utils/validation.test.ts):

describe("placeCardAtPosition", () => {
  it("should place card in empty slot", () => {
    const placed = [null, null, null];
    const card = { id: "1", number: 5, svgContent: "" };
    const result = placeCardAtPosition(placed, card, 0, 3);

    expect(result.placedCards[0]).toBe(card);
    expect(result.placedCards[1]).toBe(null);
  });

  it("should shift existing cards when inserting", () => {
    const card1 = { id: "1", number: 5, svgContent: "" };
    const card2 = { id: "2", number: 3, svgContent: "" };
    const placed = [card1, null, null];

    const result = placeCardAtPosition(placed, card2, 0, 3);

    expect(result.placedCards[0]).toBe(card2);
    expect(result.placedCards[1]).toBe(card1);
  });
});

5.2 Integration Tests

Validator (Validator.test.ts):

describe('CardSortingValidator', () => {
  const validator = new CardSortingValidator()

  describe('validateMove', () => {
    it('should validate PLACE_CARD move', () => {
      const state = validator.getInitialState({ cardCount: 5, ... })
      // ... setup state with cards

      const move = {
        type: 'PLACE_CARD',
        data: { cardId: 'card-1', position: 0 }
      }

      const result = validator.validateMove(state, move)
      expect(result.valid).toBe(true)
    })

    it('should reject CHECK_SOLUTION when not all cards placed', () => {
      const state = validator.getInitialState({ cardCount: 5, ... })
      // ... partial placement

      const move = { type: 'CHECK_SOLUTION', ... }
      const result = validator.validateMove(state, move)

      expect(result.valid).toBe(false)
      expect(result.error).toContain('place all cards')
    })
  })
})

5.3 E2E Tests (Playwright)

Full game flow:

test("complete card sorting game flow", async ({ page }) => {
  // Navigate to arcade room
  await page.goto("/arcade/room/test-room");

  // Start card sorting game
  await page.click('[data-game="card-sorting"]');

  // Setup phase
  await page.click('[data-card-count="5"]');
  await page.click('button:has-text("Start New Game")');

  // Playing phase
  await expect(page.locator(".available-cards")).toBeVisible();
  await expect(page.locator(".position-slots")).toBeVisible();

  // Place all 5 cards (automated for test)
  for (let i = 0; i < 5; i++) {
    await page.click(".available-cards .sort-card").first();
    await page.click(`.position-slot[data-position="${i}"]`);
  }

  // Check solution
  await page.click('button:has-text("Check Solution")');

  // Results phase
  await expect(page.locator(".results-phase")).toBeVisible();
  await expect(page.locator(':text("Your Score:")')).toBeVisible();
});

6. Migration Checklist

Phase 1: Setup & Types (Day 1 Morning)

  • Create directory structure
  • Define types.ts (Config, State, Moves)
  • Create Validator.ts skeleton
  • Set up unit test files
  • Register game in game-registry.ts
  • Add to validators.ts
  • Add config types to game-configs.ts

Phase 2: Core Logic (Day 1 Afternoon)

  • Implement scoring algorithms (LCS, inversions)
  • Write unit tests for scoring
  • Implement card generation utilities
  • Implement placement logic utilities
  • Write tests for placement logic
  • Complete Validator implementation
  • Write validator integration tests

Phase 3: Provider & Components (Day 2 Morning)

  • Implement Provider.tsx
  • Create SetupPhase.tsx
  • Create PlayingPhase.tsx component structure
  • Implement card selection/placement interaction
  • Test with mock state

Phase 4: UI Polish & Results (Day 2 Afternoon)

  • Complete ResultsPhase.tsx
  • Implement timer display
  • Add gradient styling for slots
  • Add animations (card placement, shake for incorrect)
  • Responsive design testing
  • Accessibility (keyboard navigation)

Phase 5: Testing & Refinement (Day 2 Evening)

  • Manual testing full flow
  • Write E2E tests
  • Test config persistence
  • Test pause/resume
  • Performance testing (15 cards)
  • Fix any bugs
  • Code review & cleanup

Phase 6: Documentation (Bonus)

  • Update README.md
  • Add JSDoc comments
  • Create gameplay screenshots
  • Update arcade games list

7. Known Challenges & Solutions

Challenge 1: Rendering AbacusReact to String

Issue: Need SVG string for serialization, but AbacusReact is a React component

Solution: Use renderToString from react-dom/server

import { renderToString } from 'react-dom/server'

const svgContent = renderToString(
  <AbacusReact value={number} width={200} height={120} />
)

Alternative: Pre-generate SVG strings for common values (0-99) and cache them

Challenge 2: Card Array Compaction

Issue: Complex logic for maintaining no-gap array when inserting/removing

Solution:

  • Extract to utility function with thorough tests
  • Use two-pass approach: calculate new positions, then compact
  • Return excess cards separately if overflow occurs

Challenge 3: State Synchronization (Selected Card)

Issue: Selected card is UI-only state, shouldn't be in server state

Solution:

  • Keep selectedCardId in local state (Provider's localUIState)
  • Don't include in moves or server state
  • Clear on successful placement

Challenge 4: Timer in Single-Player

Issue: Timer should run client-side, not in server state

Solution:

  • Store only gameStartTime in server state
  • Calculate elapsedTime as computed value in Provider
  • Use useEffect + setInterval for live updates

Challenge 5: Scoring Algorithm Performance

Issue: LCS algorithm is O(n²), might be slow for 15 cards

Solution:

  • 15 cards = 225 operations, negligible
  • Run on server-side (Validator) only when checking solution
  • No real-time performance concerns

8. Future Enhancements (Out of Scope)

Multiplayer Mode:

  • Race mode: First to complete wins
  • Turn-based: Take turns placing cards
  • Collaborative: Work together on same puzzle

Advanced Features:

  • Leaderboard (best scores per difficulty)
  • Daily challenge (same cards for everyone)
  • Hint system (show correct position for one card)
  • Undo/redo functionality
  • Custom card ranges (two-digit only, three-digit, etc.)

Accessibility:

  • Keyboard-only controls (arrow keys + space)
  • Screen reader announcements
  • High contrast mode
  • Configurable card sizes

9. Success Criteria

Functional Requirements: Player can select difficulty (5, 8, 12, 15 cards) Cards display only abacus patterns (no numbers) Cards can be placed/removed via click interaction Reveal numbers button works (if enabled) Timer tracks elapsed time Scoring algorithm matches original (LCS + exact + inversions) Results show detailed breakdown Config persists to database Pause/resume works

Technical Requirements: Follows arcade SDK patterns (defineGame, Validator, Provider) Uses Panda CSS for styling Type-safe (no any types) Passes all unit tests Passes validator tests E2E test covers full flow No console errors/warnings Responsive on mobile/tablet/desktop

UX Requirements: Smooth animations (card placement, results) Clear visual feedback (selected card, correct/incorrect) Intuitive controls (click-to-select, click-to-place) Accessible (keyboard, screen readers) Performant (60fps animations, instant interaction)


10. Appendix

A. File Sizes Estimate

index.ts                 ~80 lines
types.ts                 ~350 lines
Provider.tsx             ~500 lines
Validator.ts             ~600 lines
GameComponent.tsx        ~100 lines
SetupPhase.tsx           ~150 lines
PlayingPhase.tsx         ~400 lines
ResultsPhase.tsx         ~300 lines
SortingCard.tsx          ~100 lines
PositionSlot.tsx         ~80 lines
cardGeneration.ts        ~100 lines
scoringAlgorithm.ts      ~200 lines
validation.ts            ~150 lines
-----------------------------------
TOTAL:                   ~3,110 lines

B. Dependencies

New dependencies: None - all required packages already in project

  • @soroban/abacus-react (already installed)
  • react-dom/server (already available)
  • Panda CSS (already configured)

C. Performance Benchmarks

Expected performance:

  • Card generation (15 cards): <50ms
  • LCS calculation (15 cards): <5ms
  • Inversion count (15 cards): <2ms
  • Full score calculation: <10ms
  • Card placement validation: <1ms
  • State update cycle: <16ms (60fps)

D. Accessibility Checklist

  • All interactive elements keyboard accessible
  • Tab order logical (cards → slots → buttons)
  • Focus indicators visible
  • ARIA labels for abacus SVGs
  • Screen reader announcements for state changes
  • Color contrast meets WCAG AA
  • No reliance on color alone for feedback

Summary

This plan provides a complete blueprint for porting the Card Sorting Challenge to the arcade platform. The game is well-scoped for single-player, has clear mechanics, and leverages existing patterns from matching/memory-quiz games.

Key Simplifications vs Multiplayer Games:

  • No turn management
  • No player-vs-player scoring
  • Simpler state (one player, one set of cards)
  • No real-time synchronization needs

Key Complexities:

  • Sophisticated scoring algorithm (3 metrics)
  • Card placement/removal logic (array compaction)
  • Detailed results visualization

Timeline: 1-2 days for experienced developer familiar with the codebase

Risk Level: Low - well-defined scope, proven algorithms, existing patterns to follow