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:
- Card Pool: Uses existing flashcard data (AbacusReact components)
- Interaction Model:
- Click card → select
- Click position slot OR insert button (+) → place
- Click placed card → remove back to available
- Position Slots: Visual gradient from dark (smallest) to light (largest)
- Scoring Algorithm (3 metrics):
- Longest Common Subsequence (50% weight) - relative order
- Exact Position Matches (30% weight) - correct positions
- Inversion Count (20% weight) - overall organization
- Feedback: Detailed breakdown of score components
- 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:
setup→playing→results - 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:
-
Header (sticky):
- Timer display (MM:SS)
- Status message ("5/8 cards placed", etc.)
- Action buttons: Check Solution, Reveal Numbers, End Game
-
Two-column layout:
- Left: Available cards grid
- Right: Position slots (sequential, gradient background)
-
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
selectedCardIdin local state (Provider'slocalUIState) - 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
gameStartTimein server state - Calculate
elapsedTimeas computed value in Provider - Use
useEffect+setIntervalfor 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