Claude-skill-registry convex-guess-validation
Validate and score player guesses against drawing cards with fuzzy matching and scoring logic. Use when implementing guess submission, determining correctness, calculating bonus points, and handling scoring edge cases.
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/convex-guess-validation" ~/.claude/skills/majiayu000-claude-skill-registry-convex-guess-validation && rm -rf "$T"
manifest:
skills/data/convex-guess-validation/SKILL.mdsource content
Convex Guess Validation
Overview
This skill handles guess validation and scoring for PictionAI, including exact/fuzzy matching against card words, time-based bonus calculation, and atomic score updates for both guesser and drawer.
Guess Validation Logic
Validation Steps
- Exact Match: Check if guess exactly matches card word (case-insensitive)
- Fuzzy Match: If no exact match, apply fuzzy matching algorithm (Levenshtein distance)
- Partial Match: Allow word containment (e.g., "gray elephant" matches "elephant")
- Synonym Recognition (Optional): Check against known synonyms
String Matching Algorithms
Exact Match
function isExactMatch(guess: string, cardWord: string): boolean { return guess.toLowerCase().trim() === cardWord.toLowerCase().trim(); }
Fuzzy Match (Levenshtein Distance)
function levenshteinDistance(str1: string, str2: string): number { const len1 = str1.length; const len2 = str2.length; const matrix: number[][] = []; for (let i = 0; i <= len2; i++) { matrix[i] = [i]; } for (let j = 0; j <= len1; j++) { matrix[0][j] = j; } for (let i = 1; i <= len2; i++) { for (let j = 1; j <= len1; j++) { const cost = str1[j - 1] === str2[i - 1] ? 0 : 1; matrix[i][j] = Math.min( matrix[i][j - 1] + 1, // Insertion matrix[i - 1][j] + 1, // Deletion matrix[i - 1][j - 1] + cost // Substitution ); } } return matrix[len2][len1]; } function isFuzzyMatch(guess: string, cardWord: string): boolean { const maxDistance = Math.ceil(cardWord.length * 0.3); // 30% tolerance const distance = levenshteinDistance( guess.toLowerCase().trim(), cardWord.toLowerCase().trim() ); return distance <= maxDistance; }
Partial Match (Word Containment)
function isPartialMatch(guess: string, cardWord: string): boolean { const words = guess.toLowerCase().split(/\s+/); return words.some( (word) => cardWord.toLowerCase().includes(word) && word.length > 2 ); }
Guess Data Structure
interface Guess { guesser_id: Id<"users">; guess: string; // Raw user input timestamp: number; // When submitted (server time) is_correct: boolean; // Validation result match_type: "exact" | "fuzzy" | "partial" | "none"; points_awarded?: number; // Scorer earns drawer_points?: number; // Drawer earns }
Scoring System
Guesser Points (When Correct)
function calculateGuesserScore( elapsedSeconds: number, timeLimit: number ): number { // Base score: 50 points // Time bonus: 50 - elapsed_seconds (minimum 5 points) const timeBonus = Math.max(5, timeLimit - elapsedSeconds); const baseScore = 50; const totalScore = baseScore + timeBonus; return Math.round(totalScore); } // Examples: // Guess in 10 seconds (120s limit): 50 + (120 - 10) = 160 points // Guess in 100 seconds: 50 + (120 - 100) = 70 points // Guess in 119 seconds: 50 + (120 - 119) = 51 points
Drawer Points (When Guesser Correct)
function calculateDrawerScore(guesserScore: number): number { // Drawer gets 25% of guesser's score, minimum 10 points const percentage = Math.floor(guesserScore * 0.25); return Math.max(10, percentage); } // Examples: // Guesser: 160 points → Drawer: max(40, 10) = 40 points // Guesser: 55 points → Drawer: max(13, 10) = 13 points
Manual Winner Selection (Time Expired)
// When drawer selects a guesser as winner after time expires const guesserScore = 30; // Fixed const drawerScore = 25; // Fixed
Mutation: validateGuess
Validate a guess submission and update scores.
export const validateGuess = mutation({ args: { game_id: v.id("games"), turn_id: v.id("turns"), guesser_id: v.id("users"), guess: v.string(), elapsed_time: v.number(), card_word: v.string(), }, handler: async (ctx, args) => { // 1. Check match type let isCorrect = false; let matchType: "exact" | "fuzzy" | "partial" | "none" = "none"; if (isExactMatch(args.guess, args.card_word)) { isCorrect = true; matchType = "exact"; } else if (isFuzzyMatch(args.guess, args.card_word)) { isCorrect = true; matchType = "fuzzy"; } else if (isPartialMatch(args.guess, args.card_word)) { isCorrect = true; matchType = "partial"; } // 2. Calculate scores const guesserPoints = isCorrect ? calculateGuesserScore(args.elapsed_time, 120) : 0; const drawerPoints = isCorrect ? calculateDrawerScore(guesserPoints) : 0; // 3. Store guess in database const turn = await ctx.db.get(args.turn_id); const guesses = turn?.guesses || []; guesses.push({ guesser_id: args.guesser_id, guess: args.guess, timestamp: Date.now(), is_correct: isCorrect, match_type: matchType, points_awarded: guesserPoints, }); // 4. Update turn with guess await ctx.db.patch(args.turn_id, { guesses }); return { is_correct: isCorrect, match_type: matchType, guesser_points: guesserPoints, drawer_points: drawerPoints, }; }, });
Complete Turn Submission Flow
export const submitGuessAndCompleteTurn = mutation({ args: { game_id: v.id("games"), turn_id: v.id("turns"), guesser_id: v.id("users"), guess: v.string(), elapsed_time: v.number(), }, handler: async (ctx, args) => { const turn = await ctx.db.get(args.turn_id); const card = await ctx.db.get(turn.card_id); const game = await ctx.db.get(args.game_id); // 1. Validate guess let isCorrect = false; let guesserPoints = 0; let drawerPoints = 0; if ( isExactMatch(args.guess, card.word) || isFuzzyMatch(args.guess, card.word) ) { isCorrect = true; guesserPoints = calculateGuesserScore(args.elapsed_time, 120); drawerPoints = calculateDrawerScore(guesserPoints); } // 2. Update scores atomically if (isCorrect) { // Update guesser await ctx.db.patch(args.guesser_id, { total_score: guesser.total_score + guesserPoints, }); // Update drawer const drawer = await ctx.db.get(turn.drawer_id); await ctx.db.patch(turn.drawer_id, { total_score: drawer.total_score + drawerPoints, }); // Mark turn as completed await ctx.db.patch(args.turn_id, { state: "completed", correct_guesser_id: args.guesser_id, }); } return { is_correct: isCorrect, guesser_points: guesserPoints, drawer_points: drawerPoints, }; }, });
React Integration
const submitGuess = useMutation(api.mutations.game.submitGuessAndCompleteTurn); async function handleGuessSubmit(guess: string, elapsedTime: number) { const result = await submitGuess({ game_id: gameId, turn_id: turnId, guesser_id: userId, guess, elapsed_time: elapsedTime, }); if (result.is_correct) { showMessage(`🎉 Correct! +${result.guesser_points} points`); } else { showMessage(`❌ Incorrect. Try again!`); } }
Edge Cases & Handling
Multiple Guesses Per Player
// Only count once per player per turn const existingGuess = guesses.find((g) => g.guesser_id === args.guesser_id); if (existingGuess) { // Either: reject, or replace with new guess if (existingGuess.is_correct) { return { error: "Already guessed correctly" }; } // Replace with new guess attempt guesses = guesses.filter((g) => g.guesser_id !== args.guesser_id); }
Guess After Timer Expires
// Server validates elapsed_time on submission if (args.elapsed_time > timeLimit) { return { error: "Guess submitted after timer expired" }; }
Case & Whitespace Handling
// Normalize before comparison const normalized = guess.toLowerCase().trim(); // Remove extra spaces const cleaned = normalized.replace(/\s+/g, " ");
Special Characters
// Option 1: Strict (require exact special chars) // Option 2: Lenient (strip special chars before matching) const stripped = guess.replace(/[^a-z0-9\s]/gi, "");
Configuration Parameters
const MATCHING_CONFIG = { EXACT_MATCH_ENABLED: true, FUZZY_MATCH_ENABLED: true, FUZZY_TOLERANCE: 0.3, // 30% character mismatch allowed PARTIAL_MATCH_ENABLED: true, MIN_PARTIAL_WORD_LENGTH: 3, // Words must be 3+ chars BASE_GUESSER_SCORE: 50, MIN_TIME_BONUS: 5, DRAWER_PERCENTAGE: 0.25, MIN_DRAWER_SCORE: 10, MANUAL_WINNER_GUESSER_SCORE: 30, MANUAL_WINNER_DRAWER_SCORE: 25, };
Testing Scenarios
// Test cases for fuzzy matching const tests = [ // Exact { guess: "elephant", word: "elephant", expected: true, type: "exact" }, { guess: "ELEPHANT", word: "elephant", expected: true, type: "exact" }, // Fuzzy (typos) { guess: "elefant", word: "elephant", expected: true, type: "fuzzy" }, { guess: "elepant", word: "elephant", expected: true, type: "fuzzy" }, // Partial { guess: "large gray elephant", word: "elephant", expected: true, type: "partial", }, // No match { guess: "giraffe", word: "elephant", expected: false, type: "none" }, ];
See Also
- Complete submission logicconvex/mutations/game.ts
- UI for guess submissioncomponents/game/guess-input.tsx- Levenshtein Distance: https://en.wikipedia.org/wiki/Levenshtein_distance