Hello, aujourd’hui on va développer ensemble un jeu d’échecs web complet avec toutes les règles officielles, un robot, un chronomètre, et une interface moderne avec historique, niveau etc.

On part de zéro, on comprend chaque couche, et à la fin vous avez un projet dont vous êtes fier. Durant mes études, il m’a été emmené à développer un jeu de dame que vous pouvez retrouver ici, c’est sur cette base que l’idée de développer un jeu d’échecs est venu.

Comme d’habitude, vous pouvez télécharger le code source complet du projet disponible sur GitHub.

Ce qu’on va construire concrètement

Un jeu d’échecs jouable en 1 vs 1 local (tu peux jouer avec ton ami) ou contre la machine, une IA, avec toutes les règles (roque, en passant, promotion, pat, nulle par répétition), un chronomètre complet (Bullet, Blitz, Rapide, Classique, Fischer), 5 niveaux de difficulté pour affronter la machine, un historique des coups en notation algébrique, plusieurs thèmes visuels et tout cela avec une architecture propre et bien organisée.

Chess-Home

Voici la stack technique qu’on va utiliser :

Outil Rôle
React 18 Interface utilisateur
TypeScript 5 Typage statique
Vite Build tool ultra-rapide
Tailwind CSS Styles utilitaires
Zustand Gestion d’état
chess.js Moteur des règles d’échecs
react-chessboard Composant plateau visuel

Prérequis

Pour suivre ce tutoriel, vous devez savoir utiliser React (composants, hooks, props), vous devez Comprendre les bases de TypeScript (interfaces, types, génériques) et vous devez utiliser npm en ligne de commande.

Vous verrez, pas besoin d’être expert. Si vous avez déjà fait quelques projets React, vous êtes prêt.

Étape 1 : Mise en place du projet

On commence par créer le projet avec Vite, qui est aujourd’hui la meilleure option pour un projet React moderne. Créer le dossier dans lequel vous créerai votre proje et ouvrez votre invite de commande. Voici les commandes à tapper:

npm create vite@latest echecs-pro -- --template react-ts
cd echecs-pro
npm install

On installe ensuite toutes les dépendances dont on aura besoin :

npm install chess.js react-chessboard zustand tailwind-merge clsx
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Configurer Tailwind

Ouvrez le fichier tailwind.config.js, on va dire à Tailwind où trouver nos fichiers :

export default {
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: { extend: {} },
  plugins: [],
}

Dans src/styles/globals.css, on importe Tailwind et on ajoute quelques styles de base :

@tailwind base;
@tailwind components;
@tailwind utilities;

* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; width: 100%; }

Configurer le chemin @

Pour éviter les imports relatifs à rallonge (`../../stores/…`), on configure un alias `@` qui pointe vers `src/` :

Dans vite.config.ts :

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') },
  },
  server: { port: 3000 },
})

Dans tsconfig.json :

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  }
}

Étape 2 : L’architecture du projet

Avant d’écrire du code, prenons le temps de réfléchir à l’organisation. C’est ce qui fait la différence entre un projet maintenable et euhhh, un spaghetti.

src/
├── core/           ← logique métier pure, sans React
│   ├── engine/     ← wrapper autour de chess.js
│   ├── timer/      ← moteur de chronomètre
│   └── stockfish/  ← gestionnaire de l'IA
├── stores/         ← état global avec Zustand
├── components/     ← composants React réutilisables
│   ├── organisms/  ← composants complexes (le plateau)
│   └── molecules/  ← composants composés (timer, dialog)
├── views/          ← les "pages" de l'app
│   ├── Menu/
│   └── Game/
├── utils/          ← fonctions utilitaires
└── styles/         ← CSS global et thèmes

Le principe clé : la logique du jeu (règles, timer, IA) ne dépend pas de React. Elle est dans `core/`. Les stores Zustand font le lien entre la logique et l’interface. Les composants n’affichent que des données et appellent des actions.

Étape 3 : Le moteur d’échecs

Chess.js est une librairie qui implémente toutes les règles des échecs. On va créer un wrapper autour d’elle pour avoir une API propre et typée.

Les types de base

Commençons par définir nos types dans src/core/engine/types.ts:

// Les deux couleurs
export type Color = 'w' | 'b'

// Les 6 types de pièces (p=pion, n=cavalier, b=fou, r=tour, q=dame, k=roi)
export type PieceSymbol = 'p' | 'n' | 'b' | 'r' | 'q' | 'k'

// Les 64 cases de l'échiquier
export type Square =
  | 'a1' | 'b1' | 'c1' /* ... */ | 'h8'

// Un coup joué avec toutes ses métadonnées
export interface Move {
  from: Square
  to: Square
  color: Color
  piece: PieceSymbol
  captured?: PieceSymbol
  promotion?: PieceSymbol
  flags: string
  san: string   // notation courte "e4", "Nf3", "O-O"
  lan: string   // notation longue "e2e4"
  before: string
  after: string
}

// État de la partie
export type GameStatus =
  | 'playing' | 'checkmate' | 'stalemate' | 'draw'
  | 'threefold_repetition' | 'insufficient_material'

export type GameMode = 'local' | 'ai'

La classe ChessEngine

On crée maintenant src/core/engine/ChessEngine.ts. L’idée est simple : toutes les interactions avec chess.js passent par cette classe, qui garantit que nos types TypeScript sont respectés.

import { Chess } from 'chess.js'
import type { Color, GameState, Move, MoveOptions, Square } from './types'

export class ChessEngine {
  private chess: Chess

  constructor(fen?: string) {
    // Si on passe une FEN, on charge cette position. Sinon, position de départ.
    this.chess = fen ? new Chess(fen) : new Chess()
  }

  // Tente de jouer un coup — retourne null si illégal
  move(moveOptions: MoveOptions): Move | null {
    try {
      const result = this.chess.move({
        from: moveOptions.from,
        to: moveOptions.to,
        promotion: moveOptions.promotion,
      })
      if (!result) return null
      return this.convertMove(result)
    } catch {
      return null
    }
  }

  // Annule le dernier coup
  undo(): Move | null {
    const result = this.chess.undo()
    return result ? this.convertMove(result) : null
  }

  // Tous les coups légaux depuis une case (ou toute la position)
  getLegalMoves(square?: Square): Move[] {
    return this.chess
      .moves({ square, verbose: true })
      .map(m => this.convertMove(m))
  }

  // Le statut de la partie
  getGameStatus() {
    if (this.chess.isCheckmate()) return 'checkmate'
    if (this.chess.isStalemate()) return 'stalemate'
    if (this.chess.isThreefoldRepetition()) return 'threefold_repetition'
    if (this.chess.isInsufficientMaterial()) return 'insufficient_material'
    if (this.chess.isDraw()) return 'draw'
    return 'playing'
  }

  getFen() { return this.chess.fen() }
  getTurn(): Color { return this.chess.turn() as Color }
  isInCheck() { return this.chess.inCheck() }
  isGameOver() { return this.chess.isGameOver() }
  reset() { this.chess.reset() }
  clone() { return new ChessEngine(this.getFen()) }
}

Pourquoi ce wrapper ? Chess.js retourne ses propres types internes. En les convertissant vers nos types, on garde le contrôle et on peut changer de librairie en bas niveau sans toucher au reste de l’application.

Étape 4 : La gestion d’état avec Zustand

Zustand est une librairie de gestion d’état légère et simple. Contrairement à Redux, pas de boilerplate, pas de reducers complexes. Juste un objet avec des données et des fonctions.

Pourquoi Zustand plutôt que useState ?

Parce que l’état du jeu d’échecs doit être partagé entre plusieurs composants qui ne sont pas dans la même hiérarchie : le plateau, le panneau de contrôles, l’historique, les timers. Passer tout ça en props deviendrait vite cauchemardesque. Clin d’oeil à ceux qui veulent se lancer avec useState. Le principe reste le même.

Le store principal

Dans src/stores/useGameStore.ts :

import { create } from 'zustand'
import { ChessEngine } from '@/core/engine/ChessEngine'
import type { Move, MoveOptions, Square, GameMode, Color, GameStatus } from '@/core/engine/types'

interface GameStore {
  engine: ChessEngine
  currentPosition: string  // FEN de la position actuelle
  moveHistory: Move[]
  currentMoveIndex: number
  turn: Color
  inCheck: boolean
  gameStatus: GameStatus
  selectedSquare: Square | null
  legalMovesForSelected: Square[]
  lastMove: Move | null

  makeMove: (moveOptions: MoveOptions) => boolean
  undoMove: () => boolean
  selectSquare: (square: Square | null) => void
  resetGame: () => void
  newGame: (mode: GameMode) => void
}

export const useGameStore = create<GameStore>((set, get) => ({
  engine: new ChessEngine(),
  currentPosition: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
  moveHistory: [],
  currentMoveIndex: -1,
  turn: 'w',
  inCheck: false,
  gameStatus: 'playing',
  selectedSquare: null,
  legalMovesForSelected: [],
  lastMove: null,

  makeMove: (moveOptions) => {
    const { engine } = get()
    const move = engine.move(moveOptions)
    if (!move) return false

    const state = engine.getState()
    set({
      currentPosition: state.fen,
      moveHistory: [...get().moveHistory, move],
      currentMoveIndex: get().currentMoveIndex + 1,
      turn: state.turn,
      inCheck: state.inCheck,
      gameStatus: state.status,
      selectedSquare: null,
      legalMovesForSelected: [],
      lastMove: move,
    })
    return true
  },

  selectSquare: (square) => {
    const { engine, turn } = get()
    if (!square) {
      set({ selectedSquare: null, legalMovesForSelected: [] })
      return
    }

    const piece = engine.getPiece(square)
    // On ne peut sélectionner que ses propres pièces
    if (!piece || piece.color !== turn) {
      set({ selectedSquare: null, legalMovesForSelected: [] })
      return
    }

    const legalMoves = engine.getLegalMoves(square)
    set({
      selectedSquare: square,
      legalMovesForSelected: legalMoves.map(m => m.to),
    })
  },

  // ... autres actions
}))

Ce qu’il faut retenir : create() prend une fonction qui reçoit `set` (pour modifier l’état) et `get` (pour lire l’état actuel). On retourne l’objet initial, avec les valeurs de départ et les actions. Propre, simple, efficace.

Étape 5 : Le chronomètre

Le système de timer est l’une des parties les plus intéressantes du projet. On doit gérer deux chronomètres indépendants, les passer d’un joueur à l’autre après chaque coup, et gérer l’incrément Fischer.

Le TimerEngine

Dans src/core/timer/TimerEngine.ts, le moteur utilise setInterval pour décompter toutes les 100ms :

export class TimerEngine {
  private intervals: Map<'w' | 'b', NodeJS.Timeout> = new Map()
  private timeRemaining: Record<'w' | 'b', number>
  private lastTickTime: number | null = null

  constructor(private config: TimeControl, private callbacks: TimerCallbacks) {
    this.timeRemaining = {
      w: config.initialTime * 1000,
      b: config.initialTime * 1000,
    }
  }

  start(color: 'w' | 'b') {
    this.stop(color)  // évite les doublons
    this.lastTickTime = Date.now()

    const interval = setInterval(() => {
      const now = Date.now()
      const elapsed = this.lastTickTime ? now - this.lastTickTime : 0
      this.lastTickTime = now

      this.timeRemaining[color] -= elapsed

      if (this.timeRemaining[color] <= 0) {
        this.timeRemaining[color] = 0
        this.stop(color)
        this.callbacks.onTimeout?.(color)
      }

      this.callbacks.onTick?.(color, this.timeRemaining[color])
    }, 100)

    this.intervals.set(color, interval)
  }

  // Passe le timer d'un joueur à l'autre (avec incrément Fischer)
  switch(from: 'w' | 'b', to: 'w' | 'b') {
    this.stop(from)
    if (this.config.increment > 0) {
      this.timeRemaining[from] += this.config.increment * 1000
    }
    this.start(to)
  }
}

Pourquoi mesurer l’`elapsed`plutôt que de décompter 100ms fixe ? Parce que `setInterval` n’est pas garanti à exactement 100ms. En mesurant le temps réellement écoulé entre deux ticks, on évite la dérive.

Les presets de temps

On définit tous les formats classiques :

export const TIME_CONTROLS = {
  bullet:    { name: 'Bullet',    initialTime: 60,   increment: 0 },
  blitz:     { name: 'Blitz',     initialTime: 180,  increment: 2 },
  rapid:     { name: 'Rapide',    initialTime: 600,  increment: 0 },
  classical: { name: 'Classique', initialTime: 1800, increment: 0 },
  fischer:   { name: 'Fischer',   initialTime: 300,  increment: 3 },
  none:      { name: 'Sans timer',initialTime: 0,    increment: 0 },
}

Étape 6 : Le composant plateau

Le composant ChessBoard utilise react-chessboard pour le rendu visuel, mais gère lui-même toute la logique d’interaction.

export function ChessBoard({ boardWidth = 600, orientation = 'white' }) {
  const { currentPosition, selectedSquare, legalMovesForSelected,
          makeMove, selectSquare, turn, gameStatus, engine } = useGameStore()

  const [promotionMove, setPromotionMove] = useState(null)

  // Calcule les styles des cases : sélection en jaune, coups légaux en points
  const customSquareStyles = useMemo(() => {
    const styles = {}

    legalMovesForSelected.forEach(square => {
      styles[square] = {
        background: 'radial-gradient(circle, rgba(0,0,0,.1) 25%, transparent 25%)',
        borderRadius: '50%',
      }
    })

    if (selectedSquare) {
      styles[selectedSquare] = { backgroundColor: 'rgba(255, 255, 0, 0.4)' }
    }

    return styles
  }, [selectedSquare, legalMovesForSelected])

  const handleSquareClick = (square) => {
    // Si une case est sélectionnée et qu'on clique sur une destination légale → on joue
    if (selectedSquare && legalMovesForSelected.includes(square)) {
      if (isPromotionMove(selectedSquare, square)) {
        setPromotionMove({ from: selectedSquare, to: square })
        return
      }
      makeMove({ from: selectedSquare, to: square })
      selectSquare(null)
      return
    }
    // Sinon on sélectionne la case
    selectSquare(square)
  }

  return (
    <>
      <Chessboard
        position={currentPosition}
        onSquareClick={handleSquareClick}
        onPieceDrop={(from, to) => makeMove({ from, to })}
        boardOrientation={orientation}
        customSquareStyles={customSquareStyles}
        boardWidth={boardWidth}
        customDarkSquareStyle={{ backgroundColor: 'var(--board-dark)' }}
        customLightSquareStyle={{ backgroundColor: 'var(--board-light)' }}
      />

      {promotionMove && (
        <PromotionDialog
          color={turn}
          onSelect={(piece) => {
            makeMove({ ...promotionMove, promotion: piece })
            setPromotionMove(null)
          }}
        />
      )}
    </>
  )
}

Le point délicat : la promotion. Quand un pion arrive à la dernière rangée, on doit interrompre le flux normal, afficher une fenêtre de choix, puis jouer le coup avec la pièce choisie. C’est pour ça qu’on stocke le coup en attente dans `promotionMove`.

Étape 7 : L’IA

Pour ce projet, l’IA ou le bot est volontairement simplifiée : elle joue des coups légaux aléatoires, avec une préférence pour les captures et les coups donnant échec aux niveaux élevés. C’est une excellente base pour intégrer plus tard Stockfish WebAssembly.

export class StockfishManager {
  async getBestMove(fen: string, difficulty: DifficultyConfig): Promise<string> {
    // Simule le temps de réflexion
    await new Promise(resolve => setTimeout(resolve, difficulty.movetime))

    const chess = new Chess(fen)
    const legalMoves = chess.moves({ verbose: true })

    let selectedMove

    if (difficulty.skillLevel > 10) {
      // Niveaux élevés : préférer captures et coups donnant échec
      const goodMoves = legalMoves.filter(m =>
        m.captured || m.san.includes('+') || m.san.includes('#')
      )
      selectedMove = goodMoves.length > 0
        ? goodMoves[Math.floor(Math.random() * goodMoves.length)]
        : legalMoves[Math.floor(Math.random() * legalMoves.length)]
    } else {
      // Niveaux faibles : aléatoire pur
      selectedMove = legalMoves[Math.floor(Math.random() * legalMoves.length)]
    }

    // Format UCI : "e2e4" ou "e7e8q" pour une promotion
    return selectedMove.from + selectedMove.to + (selectedMove.promotion || '')
  }
}

Le format UCI (Universal Chess Interface) est le standard de communication avec les moteurs d’échecs. Un coup est exprimé comme ` »case_départ » + « case_arrivée »`, avec la lettre de promotion collée à la fin pour les pions (ex: ` »e7e8q »` = promotion en dame).

Étape 8 : Le système de thèmes

On utilise des variables CSS pour les couleurs, ce qui permet de changer de thème sans toucher au JavaScript. On définit 5 thèmes dans `src/styles/themes.css` :

/* Thème par défaut */
:root {
  --board-light: #f0d9b5;
  --board-dark: #b58863;
  --accent-primary: #769656;
  --bg-primary: #312e2b;
  --text-primary: #ffffff;
}

/* Thème sombre */
[data-theme='dark'] {
  --board-light: #779952;
  --board-dark: #4e6b3d;
  --accent-primary: #88c0d0;
  --bg-primary: #2e3440;
  --text-primary: #eceff4;
}

Pour changer de thème : `document.documentElement.setAttribute(‘data-theme’, ‘dark’)`

C’est tout. Tailwind utilise ces variables directement dans les classes custom, comme `bg-[var(–bg-primary)]` ou via l’extension de config.

Étape 9 : Assembler la vue principale

La vue GameView orchestre tout : elle abonne les composants aux stores, gère les effets de bord (lancement de l’IA, gestion du timer), et affiche la mise en page en trois colonnes.

export function GameView({ onBackToMenu }) {
  const { moveHistory, turn, gameStatus, mode, makeMove, engine } = useGameStore()
  const { isInitialized, isThinking, requestMove, initialize } = useStockfishStore()
  const { preset, start: startTimer, switchTimer, stop: stopTimer } = useTimerStore()

  // Synchronise le timer avec les coups joués
  useEffect(() => {
    if (moveHistory.length === 1) {
      startTimer('b')  // les blancs ont joué le 1er coup, on démarre le timer des noirs
    } else if (moveHistory.length > previousCount) {
      switchTimer(previousTurn, turn)
    }
  }, [moveHistory.length])

  // Lance l'IA quand c'est son tour
  useEffect(() => {
    if (mode === 'ai' && turn === 'b' && isInitialized && !isThinking) {
      setTimeout(() => {
        requestMove(engine.getFen()).then(uciMove => {
          const from = uciMove.substring(0, 2)
          const to = uciMove.substring(2, 4)
          const promotion = uciMove[4]
          makeMove({ from, to, promotion })
        })
      }, 300)
    }
  }, [turn, isInitialized, isThinking])

  return (
    <div className="flex h-full">
      {/* Panneau gauche : contrôles + historique */}
      {/* Centre : plateau */}
      {/* Panneau droit : timers + infos */}
    </div>
  )
}

Étape 10 : Build et déploiement

Une fois le projet prêt :

# Vérifier qu'il n'y a pas d'erreurs TypeScript
npx tsc --noEmit

# Construire pour la production
npm run build
chess-plateforme

Le dossier `dist/` contient les fichiers statiques à déployer. Vous pouvez les héberger sur n’importe quel serveur (Nginx, Apache) ou sur des plateformes comme Vercel, Netlify ou GitHub Pages.

Pour aller plus loin

Ce projet est une base solide. Voici quelques pistes d’amélioration que vous pouvez explorer :

Intégrer le vrai Stockfish: La librairie `stockfish.js` permet d’utiliser le moteur Stockfish compilé en WebAssembly directement dans le navigateur. Remplacez la classe `StockfishManager` par une vraie communication UCI via `postMessage`.

Sons: La librairie `Howler.js` est déjà dans les dépendances. Ajoutez des sons de déplacement de pièces, d’échec, et de promotion.

Persistance: Utilisez `Dexie` (déjà installé) pour sauvegarder l’historique des parties dans IndexedDB.

Export PGN: La méthode `engine.getPgn()` est déjà là. Ajoutez un bouton « Exporter » qui génère un fichier `.pgn` téléchargeable.

Sélecteur de thème: Ajoutez des boutons dans les paramètres pour changer de thème en un clic via `document.documentElement.setAttribute(‘data-theme’, …)`.

Conclusion

Vous avez maintenant un jeu d’échecs complet et professionnel. Ce projet démontre plusieurs concepts importants du développement web moderne : l’architecture en couches, la gestion d’état avec Zustand, les hooks React pour les effets de bord, le typage strict avec TypeScript, et le système de thèmes avec CSS variables.

Le code source complet est disponible sur GitHub. Clonez-le, explorez-le, modifiez-le. On se retrouve dans les commentaires pour toutes questions.

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *