Step‑by‑step tutorial · No jQuery · Native Drag & Drop · Modules · AI opponent
We start with a clean HTML5 document. The viewport meta tag makes sure the board scales nicely on any device (mobile, tablet, desktop).
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<title>Modern Chess</title>
</head>
We use CSS Grid to create an 8×8 board. Each square gets a light/dark class. The .square is a flex container that will hold the piece image. We also add position: relative so that the green circle (legal move indicator) can be placed with ::after.
.board {
display: grid;
grid-template-columns: repeat(8, 1fr);
width: min(70vw, 70vh, 560px);
height: min(70vw, 70vh, 560px);
}
.square { position: relative; aspect-ratio: 1 / 1; }
.square.light { background-color: #f0d9b5; }
.square.dark { background-color: #b58863; }
.square.legal-move::after {
content: '';
position: absolute;
width: 26%;
height: 26%;
background: radial-gradient(circle, rgba(0,255,0,0.7), transparent);
border-radius: 50%;
}
We use an import map to tell the browser where to find the chess.js library. This library handles all chess rules (legal moves, check, checkmate, etc.). We load it as an ES module from a CDN that supports ESM (esm.sh).
<script type="importmap">
{
"imports": {
"chess.js": "https://esm.sh/chess.js@0.12.1"
}
}
</script>
import map is a modern way to control module resolution. It keeps the import statements clean: import { Chess } from 'chess.js'.We write a class ModernChess. In the constructor we create the 64 squares using JavaScript. Each square gets a data-square attribute (e.g. "e2") and is made draggable (draggable="true").
#initBoardUI() {
this.#boardElement.innerHTML = '';
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 8; col++) {
const file = String.fromCharCode(97 + col);
const rank = 8 - row;
const squareName = `${file}${rank}`;
const square = document.createElement('div');
square.className = `square ${(row+col)%2 === 0 ? 'light' : 'dark'}`;
square.dataset.square = squareName;
square.setAttribute('draggable', 'true');
this.#boardElement.appendChild(square);
}
}
}
We attach event listeners to the board container (event delegation). For drag & drop we store the source square in dataTransfer. For clicks we handle selection and moves. Only white pieces can be dragged/clicked when it's White's turn.
#attachEvents() {
this.#boardElement.addEventListener('click', (e) => {
const square = e.target.closest('.square');
if (square) this.#handleSquareClick(square.dataset.square);
});
this.#boardElement.addEventListener('dragstart', (e) => {
const square = e.target.closest('.square');
const piece = this.#game.get(square.dataset.square);
if (!piece || piece.color !== 'w' || this.#game.turn() !== 'w') {
e.preventDefault(); return false;
}
e.dataTransfer.setData('text/plain', square.dataset.square);
});
this.#boardElement.addEventListener('drop', (e) => {
e.preventDefault();
const target = e.target.closest('.square');
const from = e.dataTransfer.getData('text/plain');
if (from && target) this.#tryMove(from, target.dataset.square);
});
}
When a square is selected, we ask chess.js for all legal moves (game.moves({ verbose: true })), filter those that start from the selected square, and add the legal-move class to the destination squares.
#setSelected(square) {
this.#clearSelection();
this.#selectedSquare = square;
const moves = this.#game.moves({ verbose: true });
this.#legalMovesForSelected = moves.filter(m => m.from === square).map(m => m.to);
this.#legalMovesForSelected.forEach(to => {
document.querySelector(`[data-square="${to}"]`).classList.add('legal-move');
});
}
The #tryMove method calls game.move(). If valid, we redraw the board. Then if the game is not over and it's Black's turn, the AI picks a random legal move and plays it after a short delay. We use async/await with a timeout to make it feel natural.
#tryMove(from, to) {
const move = this.#game.move({ from, to, promotion: 'q' });
if (!move) return false;
this.#renderBoard();
if (this.#game.turn() === 'b' && !this.#game.game_over()) {
setTimeout(() => this.#aiMove(), 100);
}
return true;
}
async #aiMove() {
const moves = this.#game.moves({ verbose: true });
const random = moves[Math.floor(Math.random() * moves.length)];
this.#game.move(random);
this.#renderBoard();
}
We use a reliable CDN for chess piece images (chessboardjs.com/img/chesspieces/wikipedia/{piece}.png). The #renderBoard method reads the board state from chess.js and creates a div.piece with the correct background image for each occupied square.
#renderBoard() {
const board = this.#game.board();
for (let i = 0; i < 8; i++) {
for (let j = 0; j < 8; j++) {
const piece = board[i][j];
const squareDiv = document.querySelector(`[data-square="${String.fromCharCode(97+j)}${8-i}"]`);
if (piece) {
const imgUrl = `https://chessboardjs.com/img/chesspieces/wikipedia/${piece.color}${piece.type.toUpperCase()}.png`;
const pieceImg = document.createElement('div');
pieceImg.className = 'piece';
pieceImg.style.backgroundImage = `url('${imgUrl}')`;
squareDiv.appendChild(pieceImg);
}
}
}
}
We update the panel with the current turn, check status, and a list of all legal moves in SAN notation (e.g. "Nf3", "e5"). This helps beginners learn chess notation.
#updateUI() {
const turn = this.#game.turn();
let status = turn === 'w' ? '🤍 White (you)' : '🖤 Black (AI)';
if (this.#game.in_check()) status += ' ⚠️ Check!';
document.getElementById('gameStatus').innerHTML = `🎲 Turn: ${status}`;
const moves = this.#game.moves().slice(0, 16);
document.getElementById('legalMoves').innerHTML = `📋 Moves: ${moves.join(', ') || 'none'}`;
}
Below you will find the complete working game. It uses all the modern features we discussed: ES2022+ private fields, native drag & drop, CSS Grid, ES modules, and a simple AI. You play as White. Drag or click a white piece, then click a green‑highlighted square to move. The AI (Black) will respond with a random legal move.