Last active
March 14, 2026 13:13
-
-
Save nsdevaraj/6b4d1e9e2d41a8acec78e531b74968d5 to your computer and use it in GitHub Desktop.
Chess puzzles
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Offline Chess Puzzles</title> | |
| <!-- Tailwind CDN (play/browser v4 or classic v3, pick one) --> | |
| <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> | |
| <!-- or classic: | |
| <link href="https://unpkg.com/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> | |
| --> | |
| </head> | |
| <body class="bg-slate-900 text-slate-100 min-h-screen flex flex-col"> | |
| <header class="p-3 border-b border-slate-800"> | |
| <h1 class="text-lg font-semibold text-center">Offline Chess Puzzles</h1> | |
| </header> | |
| <main class="flex-1 flex flex-col gap-3 p-3 max-w-xl w-full mx-auto"> | |
| <!-- Controls (stacked on mobile) --> | |
| <section class="space-y-3"> | |
| <div class="bg-slate-800 rounded-lg p-3 space-y-2"> | |
| <h2 class="text-sm font-semibold">Import Lichess CSV</h2> | |
| <input | |
| id="csvInput" | |
| type="file" | |
| accept=".csv" | |
| class="block w-full text-xs text-slate-200" | |
| /> | |
| <p class="text-[11px] text-slate-400"> | |
| Use <code>lichess_db_puzzle.csv</code> from database.lichess.org. | |
| </p> | |
| </div> | |
| <div class="bg-slate-800 rounded-lg p-3 space-y-2"> | |
| <h2 class="text-sm font-semibold">Filters</h2> | |
| <div class="grid grid-cols-3 gap-2 text-[11px] items-center"> | |
| <label class="flex flex-col gap-1"> | |
| <span>Min rating</span> | |
| <input | |
| id="minRating" | |
| type="number" | |
| value="1200" | |
| class="bg-slate-900 border border-slate-700 rounded px-1 py-1 text-xs" | |
| /> | |
| </label> | |
| <label class="flex flex-col gap-1"> | |
| <span>Max rating</span> | |
| <input | |
| id="maxRating" | |
| type="number" | |
| value="2200" | |
| class="bg-slate-900 border border-slate-700 rounded px-1 py-1 text-xs" | |
| /> | |
| </label> | |
| <label class="flex flex-col gap-1"> | |
| <span>Limit</span> | |
| <input | |
| id="limit" | |
| type="number" | |
| value="500" | |
| class="bg-slate-900 border border-slate-700 rounded px-1 py-1 text-xs" | |
| /> | |
| </label> | |
| </div> | |
| <button | |
| id="applyFilters" | |
| class="mt-2 w-full bg-emerald-600 hover:bg-emerald-500 text-xs py-1.5 rounded" | |
| > | |
| Search puzzles | |
| </button> | |
| <div id="counts" class="text-[11px] text-slate-400 mt-1"></div> | |
| </div> | |
| </section> | |
| <!-- Board + status --> | |
| <section class="flex flex-col gap-2 items-center"> | |
| <div | |
| id="board" | |
| class="grid grid-cols-8 w-full max-w-xs aspect-square rounded-lg overflow-hidden border border-slate-700" | |
| ></div> | |
| <div class="flex items-center justify-between w-full max-w-xs text-[12px]"> | |
| <span id="status" class="text-slate-300">Load CSV to start</span> | |
| <button | |
| id="nextPuzzle" | |
| class="px-2 py-1 bg-sky-600 hover:bg-sky-500 rounded text-[11px]" | |
| > | |
| Next | |
| </button> | |
| </div> | |
| <div id="meta" class="text-[11px] text-slate-400 max-w-xs w-full"></div> | |
| </section> | |
| </main> | |
| <script type="module" src="./main.js"></script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Chess } from 'https://cdn.jsdelivr.net/npm/chess.js@1.0.0-beta.6/+esm' | |
| // Types | |
| interface Puzzle { | |
| PuzzleId: string | |
| FEN: string | |
| Moves: string | |
| Rating: number | |
| RatingDeviation: number | |
| Popularity: number | |
| NbPlays: number | |
| Themes: string | |
| GameUrl: string | |
| OpeningTags?: string | |
| } | |
| type Color = 'w' | 'b' | |
| type Piece = { type: string; color: Color } | null | |
| let allPuzzles: Puzzle[] = [] | |
| let currentList: Puzzle[] = [] | |
| let currentIndex = 0 | |
| // 1. chess.js Integration State | |
| const chess = new Chess() | |
| let expectedMoves: string[] = [] | |
| let currentMoveIndex = 0 | |
| // 2. IndexedDB Setup for Offline Storage | |
| const DB_NAME = 'OfflineChessDB' | |
| const STORE_NAME = 'puzzles' | |
| function initDB(): Promise<IDBDatabase> { | |
| return new Promise((resolve, reject) => { | |
| const req = indexedDB.open(DB_NAME, 1) | |
| req.onupgradeneeded = () => { | |
| req.result.createObjectStore(STORE_NAME) | |
| } | |
| req.onsuccess = () => resolve(req.result) | |
| req.onerror = () => reject(req.error) | |
| }) | |
| } | |
| async function savePuzzlesToDB(puzzles: Puzzle[]) { | |
| const db = await initDB() | |
| return new Promise<void>((resolve, reject) => { | |
| const tx = db.transaction(STORE_NAME, 'readwrite') | |
| tx.objectStore(STORE_NAME).put(puzzles, 'all_puzzles') | |
| tx.oncomplete = () => resolve() | |
| tx.onerror = () => reject(tx.error) | |
| }) | |
| } | |
| async function loadPuzzlesFromDB(): Promise<Puzzle[]> { | |
| const db = await initDB() | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_NAME, 'readonly') | |
| const req = tx.objectStore(STORE_NAME).get('all_puzzles') | |
| req.onsuccess = () => resolve(req.result || []) | |
| req.onerror = () => reject(req.error) | |
| }) | |
| } | |
| // Basic CSV parser for Lichess format | |
| function parseCsv(text: string): Puzzle[] { | |
| const lines = text.trim().split('\n') | |
| const header = lines[0].split(',') | |
| const out: Puzzle[] = [] | |
| for (let i = 1; i < lines.length; i++) { | |
| const row = lines[i].split(',') | |
| if (row.length < 8) continue | |
| const get = (name: string) => row[header.indexOf(name)] || '' | |
| out.push({ | |
| PuzzleId: get('PuzzleId'), | |
| FEN: get('FEN'), | |
| Moves: get('Moves'), | |
| Rating: parseInt(get('Rating')) || 0, | |
| RatingDeviation: parseInt(get('RatingDeviation')) || 0, | |
| Popularity: parseInt(get('Popularity')) || 0, | |
| NbPlays: parseInt(get('NbPlays')) || 0, | |
| Themes: get('Themes'), | |
| GameUrl: get('GameUrl'), | |
| OpeningTags: get('OpeningTags') || undefined | |
| }) | |
| } | |
| return out | |
| } | |
| function filterPuzzles( | |
| puzzles: Puzzle[], | |
| minRating: number, | |
| maxRating: number, | |
| limit: number | |
| ): Puzzle[] { | |
| let list = puzzles.filter( | |
| p => p.Rating >= minRating && p.Rating <= maxRating | |
| ) | |
| // Shuffle | |
| for (let i = list.length - 1; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)) | |
| ;[list[i], list[j]] = [list[j], list[i]] | |
| } | |
| if (limit && list.length > limit) list = list.slice(0, limit) | |
| return list | |
| } | |
| // Basic FEN parser mapping for generic UI | |
| function boardFromFen(fen: string): Piece[][] { | |
| const parts = fen.split(' ') | |
| const rows = parts[0].split('/') | |
| const board: Piece[][] = [] | |
| for (let r = 0; r < 8; r++) { | |
| const row = rows[r] | |
| const line: Piece[] = [] | |
| for (const ch of row) { | |
| if (/[1-8]/.test(ch)) { | |
| const n = parseInt(ch) | |
| for (let k = 0; k < n; k++) line.push(null) | |
| } else { | |
| const color: Color = ch === ch.toLowerCase() ? 'b' : 'w' | |
| line.push({ type: ch.toLowerCase(), color }) | |
| } | |
| } | |
| board.push(line) | |
| } | |
| return board | |
| } | |
| function sideToMove(fen: string): Color { | |
| return (fen.split(' ')[1] as Color) || 'w' | |
| } | |
| // DOM helpers | |
| const boardEl = document.getElementById('board') as HTMLDivElement | |
| const statusEl = document.getElementById('status') as HTMLSpanElement | |
| const metaEl = document.getElementById('meta') as HTMLDivElement | |
| const countsEl = document.getElementById('counts') as HTMLDivElement | |
| const fileInput = document.getElementById('csvInput') as HTMLInputElement | |
| const minRatingInput = document.getElementById('minRating') as HTMLInputElement | |
| const maxRatingInput = document.getElementById('maxRating') as HTMLInputElement | |
| const limitInput = document.getElementById('limit') as HTMLInputElement | |
| const applyBtn = document.getElementById('applyFilters') as HTMLButtonElement | |
| const nextBtn = document.getElementById('nextPuzzle') as HTMLButtonElement | |
| let selectedSquare: string | null = null | |
| function coordToSquare(file: number, rank: number): string { | |
| const f = String.fromCharCode('a'.charCodeAt(0) + file) | |
| const r = 8 - rank | |
| return `${f}${r}` | |
| } | |
| function pieceSymbol(p: Piece): string { | |
| if (!p) return '' | |
| const key = p.color + p.type | |
| const map: Record<string, string> = { | |
| wp: '♙', wn: '♘', wb: '♗', wr: '♖', wq: '♕', wk: '♔', | |
| bp: '♟', bn: '♞', bb: '♝', br: '♜', bq: '♛', bk: '♚' | |
| } | |
| return map[key] || '' | |
| } | |
| function renderBoard(fen: string) { | |
| const board = boardFromFen(fen) | |
| const turn = sideToMove(fen) | |
| boardEl.innerHTML = '' | |
| selectedSquare = null | |
| for (let rank = 0; rank < 8; rank++) { | |
| for (let file = 0; file < 8; file++) { | |
| const sq = coordToSquare(file, rank) | |
| const p = board[rank][file] | |
| const dark = (rank + file) % 2 === 1 | |
| const div = document.createElement('div') | |
| div.dataset.square = sq | |
| div.className = | |
| (dark ? 'bg-emerald-700' : 'bg-emerald-200') + | |
| ' flex items-center justify-center text-2xl select-none border border-slate-800' | |
| div.textContent = pieceSymbol(p) | |
| div.addEventListener('click', () => onSquareClick(sq, p, turn)) | |
| boardEl.appendChild(div) | |
| } | |
| } | |
| } | |
| function updateHighlights() { | |
| const children = Array.from(boardEl.children) as HTMLDivElement[] | |
| for (const div of children) { | |
| const sq = div.dataset.square | |
| if (sq === selectedSquare) { | |
| div.classList.add('ring-2', 'ring-yellow-400') | |
| } else { | |
| div.classList.remove('ring-2', 'ring-yellow-400') | |
| } | |
| } | |
| } | |
| // Move handling with chess.js validation | |
| function onSquareClick(square: string, piece: Piece, turn: Color) { | |
| if (currentMoveIndex >= expectedMoves.length) return // puzzle finished | |
| if (selectedSquare === square) { | |
| selectedSquare = null | |
| updateHighlights() | |
| return | |
| } | |
| if (!selectedSquare) { | |
| // only allow selecting pieces of the side to move | |
| if (piece && piece.color === turn) { | |
| selectedSquare = square | |
| updateHighlights() | |
| } | |
| } else { | |
| const from = selectedSquare | |
| const to = square | |
| selectedSquare = null | |
| updateHighlights() | |
| const expectedUCI = expectedMoves[currentMoveIndex] | |
| // Check for promotion logic | |
| const movingPiece = chess.get(from as any) | |
| const isPromotion = movingPiece?.type === 'p' && (to[1] === '8' || to[1] === '1') | |
| // Default to queen if promotion, unless the puzzle explicitly demands an underpromotion | |
| const promotionPiece = (isPromotion && expectedUCI.length === 5) ? expectedUCI[4] : (isPromotion ? 'q' : undefined) | |
| const moveUCI = from + to + (promotionPiece ? promotionPiece : '') | |
| try { | |
| // 1. Verify move legality using chess.js | |
| const validMove = chess.move({ from, to, promotion: promotionPiece }) | |
| if (!validMove) { | |
| statusEl.textContent = 'Illegal move.' | |
| return | |
| } | |
| // Revert the test move to check correctness before permanently applying | |
| chess.undo() | |
| // 2. Validate against expected Lichess UCI puzzle move | |
| if (moveUCI === expectedUCI) { | |
| // Apply Correct move | |
| const moveRes = chess.move({ from, to, promotion: promotionPiece }) | |
| currentMoveIndex++ | |
| renderBoard(chess.fen()) | |
| if (currentMoveIndex >= expectedMoves.length) { | |
| statusEl.textContent = `Correct! You played ${moveRes.san}. Puzzle solved!` | |
| } else { | |
| statusEl.textContent = `Correct! You played ${moveRes.san}. Opponent is thinking...` | |
| setTimeout(() => playOpponentMove(), 600) // Delay for UX | |
| } | |
| } else { | |
| statusEl.textContent = `Incorrect move. Try again.` | |
| } | |
| } catch (e) { | |
| statusEl.textContent = 'Invalid/Illegal move.' | |
| } | |
| } | |
| } | |
| function playOpponentMove() { | |
| if (currentMoveIndex >= expectedMoves.length) return | |
| const moveUCI = expectedMoves[currentMoveIndex] | |
| const from = moveUCI.substring(0, 2) | |
| const to = moveUCI.substring(2, 4) | |
| const promotion = moveUCI.length === 5 ? moveUCI[4] : undefined | |
| try { | |
| const moveRes = chess.move({ from, to, promotion }) | |
| currentMoveIndex++ | |
| renderBoard(chess.fen()) | |
| // UCI-to-SAN conversion via chess.js return object | |
| statusEl.textContent = `Opponent played ${moveRes.san}. Your turn.` | |
| } catch (err) { | |
| statusEl.textContent = `Error playing opponent move: ${moveUCI}` | |
| } | |
| } | |
| function showCurrentPuzzle() { | |
| if (!currentList.length) { | |
| statusEl.textContent = 'No puzzles. Adjust filters or load CSV.' | |
| boardEl.innerHTML = '' | |
| metaEl.textContent = '' | |
| return | |
| } | |
| if (currentIndex >= currentList.length) currentIndex = 0 | |
| const p = currentList[currentIndex] | |
| // Reset chess.js state using the puzzle's baseline FEN | |
| chess.load(p.FEN) | |
| expectedMoves = p.Moves.split(' ') | |
| currentMoveIndex = 0 | |
| // The first move in Lichess puzzles is always the opponent's "blunder"/starting move | |
| playOpponentMove() | |
| metaEl.textContent = `#${p.PuzzleId} • Rating: ${p.Rating} • Themes: ${p.Themes?.split(' ').slice(0, 3).join(', ')}` | |
| } | |
| function nextPuzzle() { | |
| if (!currentList.length) return | |
| currentIndex = (currentIndex + 1) % currentList.length | |
| showCurrentPuzzle() | |
| } | |
| // Event wiring | |
| fileInput.addEventListener('change', async e => { | |
| const file = (e.target as HTMLInputElement).files?.[0] | |
| if (!file) return | |
| statusEl.textContent = 'Reading CSV...' | |
| const text = await file.text() | |
| allPuzzles = parseCsv(text) | |
| countsEl.textContent = `Loaded ${allPuzzles.length} puzzles from file. Saving offline...` | |
| await savePuzzlesToDB(allPuzzles) | |
| countsEl.textContent = `Loaded ${allPuzzles.length} puzzles from file.` | |
| statusEl.textContent = 'CSV loaded & stored offline. Set filters, then Search.' | |
| }) | |
| applyBtn.addEventListener('click', () => { | |
| if (!allPuzzles.length) { | |
| statusEl.textContent = 'Load CSV first.' | |
| return | |
| } | |
| const min = Number(minRatingInput.value) || 0 | |
| const max = Number(maxRatingInput.value) || 9999 | |
| const limit = Number(limitInput.value) || 500 | |
| currentList = filterPuzzles(allPuzzles, min, max, limit) | |
| currentIndex = 0 | |
| countsEl.textContent = | |
| `Loaded ${allPuzzles.length} puzzles • ` + | |
| `Current set: ${currentList.length}` | |
| showCurrentPuzzle() | |
| }) | |
| nextBtn.addEventListener('click', () => nextPuzzle()) | |
| // Initialize: Attempt to load from IndexedDB when DOM loads | |
| async function initOfflineMode() { | |
| try { | |
| statusEl.textContent = 'Checking offline storage...' | |
| const stored = await loadPuzzlesFromDB() | |
| if (stored && stored.length > 0) { | |
| allPuzzles = stored | |
| countsEl.textContent = `Loaded ${allPuzzles.length} puzzles from offline storage.` | |
| statusEl.textContent = 'Offline Puzzles Loaded! Set filters, then click Search.' | |
| } else { | |
| statusEl.textContent = 'No offline puzzles found. Load Lichess CSV to start.' | |
| } | |
| } catch (err) { | |
| console.error('Failed to load from DB:', err) | |
| statusEl.textContent = 'Load CSV to start' | |
| } | |
| } | |
| initOfflineMode() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment