♞ Build a Modern Chess Game ES2022+

Step‑by‑step tutorial · No jQuery · Native Drag & Drop · Modules · AI opponent

1. HTML Structure & Responsive Meta

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>

2. CSS Grid Board & Square Styling

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%;
}

3. Import Map & Chess.js Module

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>
💡 The import map is a modern way to control module resolution. It keeps the import statements clean: import { Chess } from 'chess.js'.

4. Building the Board Dynamically

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);
        }
    }
}

5. Native Drag & Drop + Click Events

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);
    });
}

6. Highlighting Legal Moves

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');
    });
}

7. Making a Move & AI Response

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();
}

8. Rendering Pieces with Images

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);
            }
        }
    }
}

9. Game Status & Legal Moves List

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'}`;
}

10. Putting It All Together – The Final Game

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.

🎯 The game is isolated in an iframe so that the tutorial styles do not interfere. You can reset the game anytime with the "New game" button.

🎮 Play Chess – You are White vs Random AI

💡 Tip: You can drag pieces or click a piece and then click a green circle. The AI moves randomly but legally.