♞ Building a Modern Chess Game

Step-by-step guide with ES6 Modules, private class fields & chess.js

⬇ Jump to Live Demo
1

Project Setup & Import Map

Instead of a bundler, we use a browser-native import map to load the chess.js library directly from a CDN as an ES6 module. This keeps the setup zero-config and blazing fast.

💡 Tip: Import maps are supported in all modern browsers (Chrome, Edge, Firefox, Safari). No build step needed!
<script type="importmap">
            {
            "imports": {
            "chess.js": "https://esm.sh/chess.js@0.12.1"
            }
            }
        </script>

This tells the browser: "when you see import { Chess } from 'chess.js', fetch it from esm.sh."

2

HTML Structure — Board & Info Panel

The DOM is intentionally minimal: a grid container for the 64 squares, plus an info panel showing turn status, legal moves, errors, and a reset button.

<div class="game-container">
            <div class="board" id="chessboard"></div>
            <div class="info-panel">
            <div class="status" id="gameStatus"></div>
            <div class="moves-list" id="legalMoves"></div>
            <div class="error" id="errorMsg"></div>
            <button id="resetBtn">♻️ New game</button>
            </div>
        </div>
3

CSS Grid — The 8×8 Board

The board uses CSS Grid with grid-template-columns: repeat(8, 1fr). Each square gets an aspect-ratio: 1/1 to stay perfectly square. Alternating light/dark colours create the classic checkerboard.

.board {
            display: grid;
            grid-template-columns: repeat(8, 1fr);
            width: min(70vw, 70vh, 560px);
            height: min(70vw, 70vh, 560px);
        }
        .square.light { background: #f0d9b5; }
        .square.dark  { background: #b58863; }
⚠️ Note: Using min(70vw, 70vh, 560px) ensures the board never overflows the viewport on any screen size.
4

The ModernChess Class (Private Fields)

We encapsulate all game logic in a class with true private fields (the # prefix). This keeps the internal state (#game, #selectedSquare) truly hidden from outside code.

class ModernChess {
            // Private fields — inaccessible from outside
            #game;
            #boardElement;
            #selectedSquare = null;
            #legalMovesForSelected = [];

            constructor(boardElement) {
            this.#game = new Chess();
            this.#boardElement = boardElement;
            // ...
            }
        }
💡 Private fields (#) are a real ES2022 feature. They provide runtime encapsulation — no workarounds needed.
5

Building the Board Dynamically

The 64 squares are generated in JavaScript using a double loop. Each square gets a data attribute (data-square="e4") that maps it to standard algebraic notation. Squares are also made draggable for drag-and-drop support.

#initBoardUI() {
            for (let row = 0; row < 8; row++) {
            for (let col = 0; col < 8; col++) {
            const file = String.fromCharCode(97 + col); // 'a'–'h'
            const rank = 8 - row;                     // 8–1
            const squareName = `${file}${rank}`;     // e.g. "e4"
            const square = document.createElement('div');
            square.dataset.square = squareName;
            square.draggable = true;
            // ... append to board
            }
            }
        }
6

Click & Drag-and-Drop Events

Two interaction modes are supported. Click-to-move: first click selects a piece, second click attempts the move. Drag-and-drop: uses the HTML5 Drag API with a hidden drag image for a clean feel.

// Click handling — select then move
        #handleSquareClick(squareName) {
            if (this.#selectedSquare) {
            this.#tryMove(this.#selectedSquare, squareName);
            this.#clearSelection();
            return;
            }
            // Select a white piece on white's turn
            const piece = this.#game.get(squareName);
            if (piece && piece.color === 'w') this.#setSelected(squareName);
        }

        // Drag-and-drop — set drag data on dragstart
        boardElement.addEventListener('dragstart', (e) => {
            e.dataTransfer.setData('text/plain', from);
            e.dataTransfer.setDragImage(new Image(), 0, 0); // invisible ghost
        });
7

Move Validation with chess.js

The chess.js library handles all chess rules — legal moves, check, checkmate, stalemate, and draws. We simply call game.move() and check the return value to know if a move is valid.

#tryMove(from, to) {
            // chess.js validates the move against all chess rules
            const move = this.#game.move({ from, to, promotion: 'q' });
            if (!move) {
            this.#showMessage("Invalid move ❌");
            return false;
            }
            this.#renderBoard();
            this.#updateUI();
            // Check for game-over conditions
            if (this.#game.game_over()) { /* handle checkmate/stalemate */ }
            return true;
        }
💡 promotion: 'q' auto-promotes pawns to queens. In a full game you'd let the player choose, but this keeps the demo simple.
8

Rendering Pieces on the Board

After every move, the board is re-rendered. Each square is queried by its data-square attribute, and piece images are loaded from Wikipedia's public-domain chess piece set via chessboardjs.com.

#renderBoard() {
            const board = this.#game.board(); // 8×8 array from chess.js
            for (let i = 0; i < 8; i++) {
            for (let j = 0; j < 8; j++) {
            const piece = board[i][j];
            if (piece) {
            const colorLetter = piece.color === 'w' ? 'w' : 'b';
            const imgUrl = PIECE_IMG_URL
            .replace('{piece}', `${colorLetter}${piece.type.toUpperCase()}`);
            pieceImg.style.backgroundImage = `url('${imgUrl}')`;
            }
            }
            }
        }
9

AI Opponent — Random Move Engine

The AI is deliberately simple: it picks a random legal move for black. The setTimeout adds a tiny delay so the UI updates before the AI responds, making it feel more natural. This is where you'd plug in a real chess engine like Stockfish!

async #aiMove() {
            if (this.#game.game_over() || this.#game.turn() !== 'b') return;
            const moves = this.#game.moves({ verbose: true });
            const random = moves[Math.floor(Math.random() * moves.length)];
            this.#game.move({ from: random.from, to: random.to, promotion: 'q' });
            this.#renderBoard();
            this.#updateUI();
        }

        // Trigger AI after player moves (in #tryMove):
        if (this.#game.turn() === 'b') setTimeout(() => this.#aiMove(), 100);
10

UI Updates & Legal Move Display

The info panel updates after every move: turn indicator, legal moves list, check warnings, and error messages that auto-dismiss. Selected squares glow green and legal target squares show a subtle dot indicator.

#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 = status;

            // Show first 16 legal moves in SAN notation
            const moves = this.#game.moves({ verbose: true })
            .map(m => m.san).slice(0, 16);
            document.getElementById('legalMoves').innerHTML =
            `📋 Moves: ${moves.join(', ') || 'none'}`;
        }
🎮 LIVE DEMO

Play Against the AI

You play White. Click a piece, then click a target square — or drag & drop. The AI responds automatically as Black.