Skip to content

Instantly share code, notes, and snippets.

@nsdevaraj
Last active March 14, 2026 13:13
Show Gist options
  • Select an option

  • Save nsdevaraj/6b4d1e9e2d41a8acec78e531b74968d5 to your computer and use it in GitHub Desktop.

Select an option

Save nsdevaraj/6b4d1e9e2d41a8acec78e531b74968d5 to your computer and use it in GitHub Desktop.
Chess puzzles
<!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>
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