34 min read

CheckM8 : Créez votre propre entraîneur d’échecs en ligne (version 1)

Image d'entête représentant un robot en train de faire échec et mat à son adversaire

1. Introduction

Les échecs sont un jeu exigeant où la pratique et l’analyse sont essentielles pour progresser. Que ce soit pour tester des ouvertures, rejouer une partie après une défaite ou simplement s’entraîner à réfléchir à des positions spécifiques, il est crucial de pouvoir jouer régulièrement. Cependant, on n’a pas toujours un adversaire disponible, même si des plateformes comme chess.com ou lichess permettent aujourd’hui de trouver une partie en quelques secondes.

Dans cet article, nous allons voir comment créer une application web pour jouer aux échecs contre une intelligence artificielle, en intégrant le moteur Stockfish directement dans le navigateur grâce à Vue 3 et WebAssembly. Nous allons détailler la conception fonctionnelle et technique avant de passer à la réalisation. Si vous souhaitez directement coder, vous pouvez sauter ces sections et aller directement à la partie Réalisation.

2. Conception

2.1. Définition fonctionnelle

La première partie de notre conception va être fonctionnelle et va consister à déterminer ce que notre client web peut faire.

2.1.1. Exigences fonctionnelles

Plongeons tout de suite dans le vif du sujet. Dans la première version du projet, un utilisateur doit pouvoir :

  1. Accéder à une page web contenant un échiquier ;
  2. Commencer une nouvelle partie en choisissant son côté (Blancs ou Noirs) ;
  3. Savoir à qui est le tour de jouer ;
  4. Déplacer les pièces quand c'est son tour ;
  5. Obtenir une réponse du bot après chaque coup ;
  6. Être alerté quand la partie est terminée.

Ces exigences représentent le minimum pour que l'utilisateur puisse jouer contre notre bot. Dans cette version, nous nous cantonnerons à ces fonctionnalités et nous les enrichirons dans les versions suivantes.

2.1.2. User stories et critères d'acceptation

Transformons ces exigences en user stories et en critères d'acceptation. Cela nous donne :

US-1 : En tant qu'utilisateur, je veux pouvoir accéder à une page web contenant un échiquier afin de m'entraîner.
  • AC-1-1 : L'application met à disposition de l'utilisateur une page web sur laquelle est affichée un échiquier.
  • AC-1-2 : Les coordonnées des cases sont affichées : a, b, ..., h pour les colonnes et 1, 2, ..., 8 pour les rangées.
US-2 : En tant qu'utilisateur, je veux pouvoir déplacer mes pièces quand c'est mon tour afin de jouer mon coup.
  • AC-2-1 : Lorsque c'est le tour de l'utilisateur, les pièces de sa couleur sont déplacables. Les pièces de l'autre couleur ne peuvent pas être déplacées.
  • AC-2-2 : Lorsque l'utilisateur saisit une pièce, les coups légaux sont affichés sur l'échiquier.
  • AC-2-3 : L'utilisateur valide son coup en relâchant la pièce. Il n'est alors plus possible de revenir en arrière.
  • AC-2-4 : Lorsque l'utilisateur a validé son coup, celui-ci est mis en évidence en affichant la case de départ et celle d'arrivée d'une couleur différente des autres cases.
US-3 : En tant qu'utilisateur, je veux pouvoir obtenir une réponse du bot après avoir joué un coup.
  • AC-3-1 : La validation du coup par l'utilisateur déclenche la réflexion du bot.
  • AC-3-2 : Le bot répond après un délai raisonnable (< 3sec).
  • AC-3-3 : Le coup joué par le bot est également mis en évidence (de la même façon que pour l'utilisateur).
  • AC-3-4 : L'utilisateur ne peut déplacer aucune pièce pendant que c'est au tour du bot de jouer : ni les siennes ni celles de son adversaire.
US-4 : En tant qu'utilisateur, je veux pouvoir savoir à tout moment à qui est le tour de jouer.
  • AC-4-1 : Un message texte est affiché au-dessus de l'échiquier pour indiquer à qui est le tour de jouer. Celui-ci est : Tour courant : (Blancs|Noirs) en fonction de la couleur du joueur à qui c'est le tour.
US-5 : En tant qu'utilisateur, je veux pouvoir être informé lorsque la partie est terminée afin de savoir si j'ai gagné ou perdu.
  • AC-5-1 : Un message texte s'affiche entre le message qui indique à qui est le tour de jouer et l'échiquier pour indiquer que la partie est terminée.
  • AC-5-2 : Si l'utilisateur a gagné la partie, le message est : Bravo ! Vous avez gagné la partie !
  • AC-5-3 : Si le bot a gagné la partie, le message est : Zut ! Vous avez perdu pour cette fois !
  • AC-5-4 : S'il y a pat, le message est : Match nul ! C'est un pat !
  • AC-5-5 : S'il y a un nul, le message est : Match nul !
  • AC-5-6 : Lorsque la partie est terminée, il n'est plus possible de déplacer les pièces sur l'échiquier (quelle que soit la couleur).
US-6 : En tant qu'utilisateur, je veux pouvoir commencer une nouvelle partie et choisir mon côté (Blancs ou Noirs) afin de (re)jouer contre l'IA et m'entraîner avec un côté ou l'autre.
  • AC-6-1 : Sous l'échiquier, deux boutons Nouvelle partie (Blancs) et Nouvelle partie (Noirs) sont présents et permettent de commencer une nouvelle partie en jouant respectivement les Blancs ou les Noirs.
  • AC-6-2 : Lorsque l'utilisateur arrive sur la page, il a les Blancs par défaut.
  • AC-6-3 : Si une partie était déjà en cours, les pièces sont repositionnés dans l'état de départ du jeu.
  • AC-6-4 : Si l'utilisateur choisit de recommencer une nouvelle partie en jouant les Noirs alors l'échiquier est retourné de façon à ce que les pièces Noirs soient en bas de l'échiquier.
  • AC-6-5 : Si l'utilisateur choisit de jouer les Noirs alors le clic sur ce bouton déclenche aussi le premier coup du bot (puisque ce sont les Blancs qui commencent).

2.1.3. Maquettage

Maintenant que nous avons défini et spécifié le périmètre fonctionnel, nous pouvons passer à la partie maquettage.

Ici, rien de compliqué. Nous optons pour une interface vraiment simple pour cette première version. Il n'y a qu'un seul écran présenté ci-dessous :

Figure 1 - Maquette wireframe de la page web

Sur cette maquette, nous retrouvons notre échiquier avec les deux boutons pour commencer une nouvelle partie juste en dessous. Au-dessus du plateau, nous avons l'indicateur textuel du tour actuel ainsi que le message de fin de partie qui apparaît quand celle-ci est finie.

2.2. Conception technique

Nous allons maintenant nous attaquer à l'architecture technique de notre application. Elle détaille les choix technologiques et décrit les intéractions entre les différentes briques du système et avec l'extérieur.

2.2.1. Pile technologique

Voici la liste des technos que nous allons utiliser pour réaliser ce projet :

Technologie Catégorie Rôle Pourquoi ?
Vue 3 +
TypeScript

v3.5.13
Frontend Interface utilisateur 🔹 Réactif et performant
🔹 Typage statique pour robustesse
vue3-chessboard
v1.3.3
Échiquier
Moteur de règles
Affichage des pièces 🔹 Intégré à Vue 3
🔹 Basé sur lichess chessground
      et chess.js
🔹 Drag & drop intuitif
Stockfish
v16.0.0
IA Analyse des positions 🔹 Moteur puissant et open-source
🔹 Fonctionne en Web Worker
nginx
v1.27.4
Serveur Web Gestion des requêtes 🔹 Performant et léger
🔹 Optimisé pour fichiers statiques
Docker
v28.0.1
Conteneurisation Exécution isolée 🔹 Portabilité maximale
🔹 Déploiement rapide
Docker Compose
v2.33.1
Orchestration Gestion multi-services 🔹 Configuration simplifiée
🔹 Déploiement en une commande
Stockfish et les Web Workers

Dans notre application CheckM8, Stockfish tourne dans un Web Worker afin de ne pas bloquer l’interface utilisateur lors des calculs.
Un moteur d’échecs, comme Stockfish, effectue des calculs intensifs pour analyser des positions et trouver le meilleur coup : il est essentiel de l'exécuter en arrière-plan pour garantir une expérience fluide.

Un Web Worker est un script JavaScript qui s'exécute indépendamment du thread principal du navigateur. Il permet d’exécuter des tâches lourdes (comme des calculs intensifs) sans affecter la réactivité de l’interface.

Caractéristiques principales d’un Web Worker :

  • Il tourne dans un thread séparé du navigateur.
  • Il ne peut pas manipuler directement le DOM (pas d'accès aux éléments HTML).
  • Il communique avec le thread principal via la fonction postMessage().
  • Il est souvent utilisé pour le calcul intensif, les IA, ou le traitement de données.

Grâce à cette approche, CheckM8 peut exécuter Stockfish sans ralentir les interactions du joueur, garantissant ainsi une expérience de jeu fluide et réactive.

2.2.2. Modélisation du système

Diagramme de contexte système

Pour avoir une vision globale du système et de comment il va intéragir avec l'extérieur, réalisons le diagramme de contexte système pour notre application. Ce diagramme est le premier niveau du modèle C4 (https://c4model.com/).

Figure 2 - Diagramme de contexte système

Dans le cas à l'étude, ce diagramme est extrêment simple puisque notre application n'intéragit pas avec des services externes. Si nous avions délégué à une API externe le fait de répondre aux coups de l'utilisateur par exemple, cette API aurait été représentée ici.
Ici, nous avons donc juste notre utilisateur qui intéragit avec CheckM8, notre application, et c'est tout !


Diagramme des conteneurs

Allons un cran plus loin dans notre conception avec le deuxième niveau du modèle C4 : le diagramme des conteneurs ! Celui-ci détaille les différentes briques (ou conteneurs) qui composent le système à l'étude.

Figure 3 - Diagramme de conteneurs

On y voit la SPA Vue 3 qui est mise à disposition des utilisateurs par un serveur web (ici nginx) conteneurisé.


Diagramme de séquence d'un tour de jeu

Pour avoir une meilleure idée de l'enchaînement des actions lorsque l'utilisateur joue un coup, nous pouvons dessiner le diagramme de séquence qui montre le déroulement d'un tour de jeu. Il décrit ce qu'il se passe à partir de l'action de l'utilisateur jusqu'à la réponse de Stockfish.

Figure 4 - Diagramme de séquence d'un tour de jeu

Les étapes sont :

1. Le joueur joue un coup

L’utilisateur déplace une pièce sur l’échiquier via vue3-chessboard (ex: e2 → e4).

2. Émission de l’événement move

vue3-chessboard met à jour l'état de la partie et émet l’événement move lorsqu’un coup valide est joué.
Cet événement est écouté par Vue.js, où un handler attaché intercepte l’événement.

3. Récupération de la position FEN

Vue.js utilise l’API de vue3-chessboard pour récupérer la FEN (Forsyth-Edwards Notation) de la position après le coup.
La FEN est une représentation textuelle de l’échiquier. C'est cette valeur que nous allons utiliser pour communiquer avec Stockfish.

4. Envoi de la position FEN à Stockfish

Vue.js transmet la FEN au Web Worker exécutant Stockfish.
Depuis le Vue.js la commande "position fen <...>" est envoyée à Stockfish puis le calcul est lancé avec "go movetime 1000" pour demander le meilleur coup en 1 seconde (ou un autre temps si on veut contrôler la force de Stockfish).

5. Stockfish calcule la réponse

Stockfish analyse la position et détermine le meilleur coup en fonction du temps alloué.

6. Stockfish répond via onmessage

Stockfish émet un message bestmove (sous la forme "bestmove e7e5") via l'évènement onmessage du Web Worker.

7. Mise à jour de l’échiquier

Vue.js analyse le message et met à jour vue3-chessboard avec le coup retourné par Stockfish (ex: e7 → e5).
L’échiquier affiche automatiquement le coup joué par l’IA.

Ces étapes se répètent en boucle jusqu'à la fin de la partie.

3. Réalisation

C'est enfin le moment de passer au développement de notre application !

3.1. Créer le projet Vue 3

Comme décrit dans la partie précédente, nous allons utiliser le framework Vue 3 pour développer notre client web. La première étape est donc de créer le projet Vue.

Pour cela, depuis le répertoire de notre choix, créons un répertoire checkm8 et placons nous à l'intérieur de ce répertoire avec la commande :

mkdir checkm8 && cd checkm8

Exécutons la commande suivante pour initier un nouveau projet Vue utilisant Typescript :

npm create vite@latest checkm8-frontend -- --template vue-ts

Ensuite, déplacons nous dans le répertoire checkm8-frontend puis installons les dépendances :

npm install

Enfin, pour tester que la création du projet est réussie, lancons le projet avec la commande :

npm run dev

L'application Vue devrait se lancer sur le port 5173 (par défaut) et être accessible à l'adresse http://localhost:5173.

Avant de démarrer les développements, nous allons tout d'abord :

  1. Supprimer le composant d'exemple HelloWorld.vue → Ouvrez donc le répertoire src/components et supprimez le fichier HelloWorld.vue (vous pouvez le remplacer par un fichier .gitkeep pour que le répertoire components reste versionné)
  2. Supprimer le fichier vue.svg du répertoire src/assets (là encore vous pouvez le remplacer par un fichier .gitkeep pour que le répertoire assets reste versionné)
  3. Changer le titre de la page → Ouvrez le fichier index.html à la racine du projet et changer le contenu de la balise <title> pour CheckM8

N.B. Nous allons garder le CSS généré en exemple pour cette première version donc faites attention à ne pas l'effacer (fichier style.css à la racine du projet).

3.2. Afficher un plateau sur notre page web

La première étape va être d'afficher notre plateau d'échecs sur la page principale de l'application.

3.2.1. Installer vue3-chessboard

Pour cela, nous commencons par installer le paquet NPM vue3-chessboard en lancant la commande :

npm install vue3-chessboard

3.2.2. Intégrer le composant vue3-chessboard sur notre page web

N.B. Pour cette première version, nous allons centraliser toute la logique et l'affichage dans App.vue afin de garder une structure simple et faciliter le développement initial. Dans les prochaines évolutions, nous refactoriserons ce fichier en composants modulaires pour améliorer la maintenabilité et la lisibilité du code.

Tout d'abord, vidons les trois parties du fichier App.vue : tout ce qu'il y a entre les balises <script setup lang="ts"></script>, <template></template> et <style scoped></scoped>. Nous avons maintenant un composant prêt pour que nous y ajoutions notre code.

Nous allons commencer par mettre les imports suivants dans la partie <script setup lang="ts"></script> :

import { reactive } from 'vue';
import { TheChessboard } from 'vue3-chessboard';
import 'vue3-chessboard/style.css';
import type { BoardApi, BoardConfig } from 'vue3-chessboard';

Juste en dessous, nous allons déclarer deux variables qui vont nous permettre d'intéragir avec le composant Vue vue3-chessboard :

let boardAPI: BoardApi;

const boardConfig: BoardConfig = reactive({
  coordinates: true,
  orientation: 'white',
  position: 'start',
});
  • La variable boardAPI permet d'appeler l'API vue3-chessboard pour, par exemple, récupérer la notation FEN de l'état actuel de la partie, faire bouger une pièce automatiquement, remettre l'échiquier dans sa position de départ en fin de jeu, etc.

  • La variable boardConfig décrit la configuration du composant. Ici, nous spécifions que :

    1. Nous voulons afficher les coordonnées des cases
    2. Le plateau doit être positionné de façon à ce que les Blancs soient en face de l'utilisateur (couleur par défaut)
    3. Les pièces doivent être dans la position de départ du jeu
    4. Les pièces ne doivent pas être déplaçables (cet attribut est modifié de manière réactive quand Stockfish a fini de s'initialiser)

Nous allons également implémenter une fonction onBoardCreated pour initialiser la variable boardAPI lorsque le composant a été instancié :

function onBoardCreated(api: BoardApi) {
  boardAPI = api;
}

Ensuite, il ne nous manque plus qu'à appeler le composant Vue dans notre template :

<template>
  <h2>CheckM8</h2>

  <!-- Plateau -->
  <TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated">
  </TheChessboard>
</template>

La prop reactive-config nous permet de dire à vue3-chessboard que nous voulons que notre configuration boardConfig soit reactive. C'est-à-dire que nous pourrons modifier les valeurs de cet objet à la volée et elles seront propagées au composant.

Et voilà ! Si vous lancez le frontend (npm run dev), vous devriez voir notre échiquier en plein milieu de la page !

Voici le code de notre fichier App.vue pour le moment :

<script setup lang="ts">
import { reactive } from 'vue';
import { TheChessboard } from 'vue3-chessboard';
import 'vue3-chessboard/style.css';
import type { BoardApi, BoardConfig } from 'vue3-chessboard';

let boardAPI: BoardApi;

const boardConfig: BoardConfig = reactive({
  coordinates: true,
  orientation: 'white',
  position: 'start',
});

function onBoardCreated(api: BoardApi) {
  boardAPI = api;
}
</script>

<template>
  <h2>CheckM8</h2>

  <!-- Plateau -->
  <TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated">
  </TheChessboard>
</template>

<style scoped>
</style>

Avec ces quelques lignes de code, nous avons déjà mis en oeuvre les user stories :

US-1 : En tant qu'utilisateur, je veux pouvoir accéder à une page web contenant un échiquier afin de m'entraîner.
US-2 : En tant qu'utilisateur, je veux pouvoir déplacer mes pièces quand c'est mon tour afin de jouer mon coup.

Nous répondons à leurs critères d'acceptation (AC-1-1, AC-1-2, AC-2-1, AC-2-2, AC-2-3 et AC-2-4). La plupart d'entre elles sont gérées automatiquement par le composant vue3-chessboard.

Néanmoins, pour l'instant, l'utilisateur peut jouer les deux côtés à lui tout seul. Il nous faudra donc bloquer le déplacement des pièces de l'autre couleur lorsque c'est au bot de jouer. Il s'agit du critère d'acceptation AC-3-4 que nous prendrons en compte lorsque nous implémenterons la réponse de Stockfish (user story US-3 : En tant qu'utilisateur, je veux pouvoir obtenir une réponse du bot après avoir joué un coup).

3.3. Obtenir une réponse de Stockfish après un coup

3.3.1. Installation de Stockfish

La première étape pour mettre en place Stockfish dans notre application est de l'installer. Placez-vous dans le répertoire du frontend si ce n'est pas déjà le cas et lancez la commande :

npm install stockfish

3.3.2. Intégration via un Web Worker

Pour utiliser un Web Worker, il faut passer l'URL du script à appeler en paramètre du constructeur. Pour ce faire, nous allons récupérer deux fichiers depuis le répertoire de Stockfish node_modules/stockfish/src :

  • stockfish-nnue-16.js
  • stockfish-nnue-16.wasm

Attention : les noms peut varier selon la version de Stockfish utilisée.

Copions ensuite ces fichiers dans un sous-répertoire stockfish du répertoire public de notre frontend Vue.

Une fois que c'est fait, dans notre fichier App.vue, nous allons ajouter le code suivant sous la déclaration de nos variables boardAPI et boardConfig :

let worker: Worker;

onMounted(() => {
  // Création d'un nouvel objet Stockfish
  worker = new Worker(
    new URL(
      '/stockfish/stockfish-nnue-16.js',
      import.meta.url
    ),
    { type: 'module' }
  );
  
  // Abonnement à l'évènement 'onmessage' du moteur Stockfish
  worker.onmessage = onStockfishMessage;

  // Initialisation du moteur
  worker.postMessage('uci');
  worker.postMessage('isready');
  worker.postMessage('setoption name Skill Level value 0');
  worker.postMessage('ucinewgame');
});

Détail du code :
Nous commencons par déclarer une variable worker de type Worker : notre Web Worker.

Ensuite, dans le hook onMounted de Vue, nous définissons un callback dans lequel :

  • Nous instancions un Worker en passant en paramètre l'URL vers le script .js de Stockfish (situé dans notre répertoire public) → Attention : le fichier .wasm doit être présent avec le .js dans le répertoire public sinon une erreur s'affichera dans la console du navigateur
  • Nous nous abonnons à l'évènement onmessage du Worker qui exécute Stockfish → nous implémenterons le contenu de la fonction de gestion de cet évènement onStockfishMessage juste après
  • Nous initialisons Stockfish :
    1. Envoi de la commande uci (Universal Chess Interface) pour indiquer à Stockfish qu'on veut l'utiliser en mode UCI → Stockfish répondra uciok pour indiquer qu'il a accepté le mode UCI
    2. Envoi de la commande isready pour demander à Stockfish de confirmer qu'il est prêt à recevoir des commandes → Stockfish répondra readyok
    3. Envoi de la commande setoption name Skill Level value 0 pour ajuster la force de Stockfish au minimum. L'échelle de force du moteur va de 0 (faible) à 20 (niveau maître)
    4. Enfin, envoi de la commande ucinewgame pour réinitialiser l'état du moteur et indiquer que l'on souhaite commencer une nouvelle partie

Important ! N'oubliez pas d'ajouter l'import de onMounted :

import { reactive, onMounted } from 'vue';

Si vous avez suivi les consignes précédentes, vous devriez avoir une erreur dans votre code : Cannot find name 'onStockfishMessage'. Nous n'avons pas encore déclaré cette fonction : il nous faut la créer. C'est elle qui sera appelé lorsque Stockfish enverra un message via le Web Worker et qui contiendra les traitements pour gérer la réponse.

Sous la fonction onBoardCreated définie plus haut dans cet article, ajoutez cette fonction et laissons la vide pour le moment :

function onStockfishMessage(event: any) {
}

Ceci conclut l'intégration de Stockfish !

3.3.3. Gestion des coups utilisateur et réponse de Stockfish

Maintenant que Stockfish a été intégré et initialisé à l'arrivée sur la page, nous allons pouvoir gérer le fait de répondre au coup joué par l'utilisateur.

Pour cela, nous implémentons la fonction onPlayerMove :

function onPlayerMove() {
  const turnColor = boardAPI.getTurnColor();

  // Appel à Stockfish pour calculer le prochain coup
  // si c'est son tour de jouer
  if (turnColor !== boardConfig.orientation) {
    boardConfig.viewOnly = true;
    askStockfishMove();
  } else {
    boardConfig.viewOnly = false;
  }
}

Cette fonction sera appelée après un coup (par l'utilisateur ou par Stockfish).

Détail de la fonction onPlayerMove :

  • Tout d'abord, nous récupérons la couleur dont c'est le tour (white ou black)
  • Nous vérifions ensuite si c'est au tour de Stockfish de jouer → boardConfig.orientation indique les pièces qui sont en bas de l'échiquier = la couleur de l'utilisateur
  • Si c'est le cas, nous effectuons deux actions :
    1. En premier lieu, nous désactivons les intéractions avec le composant pour empêcher que l'utilisateur déplace les pièces adverses
    2. Nous appelons la fonction askStockfishMove qui va demander à Stockfish de calculer le prochain coup et que nous allons implémenter tout de suite
  • Si ce n'est pas au tour de Stockfish de jouer, cela signifie que le moteur vient de faire son coup et que c'est au tour de l'utilisateur, nous réactivons donc les intéractions avec le composant vue3-chessboard

Implémentation de la fonction askStockfishMove :

function askStockfishMove() {
  // Passage de l'état de la partie à Stockfish pour un nouveau calcul
  worker.postMessage(`position fen ${boardAPI.getFen()}`);
  
  // Lancement du calcul pour 1 seconde
  worker.postMessage('go movetime 1000');
}

Détails de la fonction askStockfishMove :

  • Dans cette fonction, nous envoyons un premier message au Web Worker pour indiquer la position actuelle de l'échiquier
  • Puis nous envoyons un second message pour demander à Stockfish de lancer le calcul pendant 1000 millisecondes = 1 seconde (Vous pouvez utiliser une autre valeur pour laisser plus ou moins de temps à Stockfish pour réfléchir)

L'étape suivante est de gérer la réception des messages de Stockfish dans la fonction onStockfishMessage. Nous allons tout d'abord ajouter les imports suivants en haut de notre partie Typescript :

import type {
  BoardApi,
  BoardConfig,
  Move,
  PieceColor,
  Promotion
} from 'vue3-chessboard';
import type { Key } from 'chessground/types';

Les ajouts sont les types Move et Promotion depuis vue3-chessboard et Key depuis chessground/types qui est une dépendance de vue3-chessboard.

Après quoi nous pouvons écrire notre fonction :

function onStockfishMessage(event: any) {
  if (!boardAPI) {
    throw new Error('vue3-chessboard is not initialized.');
  }

  const msg: string = event.data;
  // On log les messages de Stockfish dans la console 
  // (pour l'instant utile pour du débogage)
  console.log(msg);
  
  // Si Stockfish est prêt, on active les intéractions
  // avec le composant 'vue3-chessboard'
  if (msg === 'readyok') {
    boardConfig.viewOnly = false;
  }

  if (msg.includes('bestmove')) {
    const tokens = msg.split(' ');

    // Vérification présence valeur de bestmove
    if (tokens.length < 2) {
      console.error('Best move not found.');
      return;
    }

    // Récupération de la valeur du meilleur coup à jouer
    const bestMove = tokens[1];

    // Vérification du format du best move
    if (!bestMove || bestMove.length < 4) {
      console.error('Incorrect best move format.');
      return;
    }

    // Application du coup calculé
    const moveObj: Move = { 
      from: bestMove.substring(0, 2) as Key,
      to: bestMove.substring(2, 4) as Key 
    };

    // Si la valeur de bestMove fait 5 caractères,
    // c'est que la pièce est promue
    if (bestMove.length === 5) {
      moveObj.promotion = bestMove[4] as Promotion;
    }

    // Déplacement de la pièce sur l'échiquier
    boardAPI.move(moveObj);
  }
}

Détail de la fonction onStockfishMessage :

  • La première chose que nous faisons dans cette fonction est de vérifier que l'API du composant vue3-chessboard est disponible (c'est-à-dire que l'évènement board-created a bien été appelé et donc que notre composant est bien instancié) → nous avons en effet besoin de cette API pour appliquer le coup retourné par Stockfish sur l'échiquier
  • Nous récupérons le message envoyé par Stockfish avec const msg: string = event.data;
  • Nous loggons ce message (ne doit pas être conservé en prod mais très utile pour faire du débogage pendant les développements
  • Nous effectuons un premier test pour vérifier si le message envoyé par Stockfish contient la string readyok → si c'est le cas, alors les intéractions avec le composant vue3-chessboard sont activées (permet d'empêcher l'utilisateur de déplacer des pièces tant que le moteur n'est pas complètement initialisé)
  • Puis nous testons si le message envoyé par Stockfish contient la string bestmove.
    En effet, la réponse finale de Stockfish a notre commande go movetime <DURATION> avec le meilleur coup à jouer est de la forme : bestmove <move> ponder <predicted_user_move> (exemple : bestmove e7e5 ponder g1f3). Il s'agit du dernier message envoyé par Stockfish
  • Si le message contient bestmove alors Stockfish a répondu avec son coup : nous découpons la chaîne renvoyée sur le caractère <espace>
  • La valeur du coup à jouer est contenu dans le second token, nous testons donc que nous avons bien au moins deux tokens dans notre réponse → il est très improbable que le cas où le moteur réponde un message bestmove sans valeur existe mais dans l'optique d'avoir un code robuste, nous effectuons tout de même la vérification
  • Nous récupérons ensuite la valeur du coup à jouer (qui est donc dans le second token)
  • Nous effectuons une autre vérification pour nous assurer que le format est bon : il doit y avoir au moins 4 caractères (exemple : e2e4)
  • Une fois la réponse de Stockfish validée, nous appliquons le coup : nous extrayons la position de départ (from) et d'arrivée (to) de la valeur récupérée et nous créons une instance d'un objet Move
  • S'il y a un cinquième caractère dans la valeur du coup, c'est qu'il s'agit d'une promotion. Exemple : a7a8q qui indique que le pion en a7 atteint le côté adverse a8 et qu'il est promu en une reine (q pour Queen) → nous ajoutons donc un attribut promotion a notre objet Move avec la nouvelle valeur de la pièce
  • Enfin, nous appliquons le coup grâce à l'API vue3-chessboard en passant l'objet de type Move créé précédemment

Il ne nous reste plus qu'à modifier notre template pour définir notre fonction onPlayerMove comme handler de l'évènement move de vue3-chessboard :

<TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated"
    @move="onPlayerMove">
</TheChessboard

Ca y est ! Nous pouvons jouer un coup et Stockfish répondra !

Voici le code complet dans l'état actuel :

<template>
  <h2>CheckM8</h2>
  <p>Tour courant : {{ currentTurn }}</p>
  <p v-if="gameOverMessage">{{ gameOverMessage }}</p>

  <!-- Plateau -->
  <TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated"
    @move="onPlayerMove"
    @checkmate="onCheckmate"
    @stalemate="onStalemate"
    @draw="onDraw">
  </TheChessboard>
</template>

<script setup lang="ts">
import { reactive, onMounted } from 'vue';
import { TheChessboard } from 'vue3-chessboard';
import 'vue3-chessboard/style.css';
import type {
  BoardApi,
  BoardConfig,
  Move,
  PieceColor,
  Promotion
} from 'vue3-chessboard';
import type { Key } from 'chessground/types';

let boardAPI: BoardApi;

const boardConfig: BoardConfig = reactive({
  coordinates: true,
  orientation: 'white',
  position: 'start',
  viewOnly: true,
});

let worker: Worker;

onMounted(() => {
  // Création d'un nouvel objet Stockfish
  worker = new Worker(
    new URL(
      '/stockfish/stockfish-nnue-16.js',
      import.meta.url
    ),
    { type: 'module' }
  );
  
  // Abonnement à l'évènement 'onmessage' du moteur Stockfish
  worker.onmessage = onStockfishMessage;

  // Initialisation du moteur
  worker.postMessage('uci');
  worker.postMessage('isready');
  worker.postMessage('setoption name Skill Level value 0');
  worker.postMessage('ucinewgame');
});

function onBoardCreated(api: BoardApi) {
  boardAPI = api;
}

function onPlayerMove() {
  // Mise à jour de qui joue
  const turnColor = boardAPI.getTurnColor();

  // Appel à Stockfish pour calculer le prochain coup 
  // si c'est son tour de jouer
  if (turnColor !== boardConfig.orientation) {
    boardConfig.viewOnly = true;
    askStockfishMove();
  } else {
    boardConfig.viewOnly = false;
  }
}

function askStockfishMove() {
  // Passage de l'état de la partie à Stockfish pour un nouveau calcul
  worker.postMessage(`position fen ${boardAPI.getFen()}`);
  
  // Lancement du calcul pour 1 seconde
  worker.postMessage('go movetime 1000');
}

function onStockfishMessage(event: any) {
  if (!boardAPI) {
    throw new Error('vue3-chessboard is not initialized.');
  }

  const msg: string = event.data;
  console.log(msg);

  // Si Stockfish est prêt, on active les intéractions
  // avec le composant 'vue3-chessboard'
  if (msg === 'readyok') {
    boardConfig.viewOnly = false;
  }

  if (msg.includes('bestmove')) {
    const tokens = msg.split(' ');

    // Vérification présence valeur de bestmove
    if (tokens.length < 2) {
      console.error('Best move not found.');
      return;
    }

    // Récupération de la valeur du meilleur coup à jouer
    const bestMove = tokens[1];

    // Vérification du format du best move
    if (!bestMove || bestMove.length < 4) {
      console.error('Incorrect best move format.');
      return;
    }

    // Application du coup calculé
    const moveObj: Move = { 
      from: bestMove.substring(0, 2) as Key,
      to: bestMove.substring(2, 4) as Key 
    };

    // Si la valeur de bestMove fait 5 caractères,
    // c'est que la pièce est promue
    if (bestMove.length === 5) {
      moveObj.promotion = bestMove[4] as Promotion;
    }

    // Déplacement de la pièce sur l'échiquier
    boardAPI.move(moveObj);
  }
}
</script>

<style scoped>
</style>

La user story associée à ces modifications est l'US-3 :

US-3 : En tant qu'utilisateur, je veux pouvoir obtenir une réponse du bot après avoir joué un coup.

Après nos derniers ajouts :

  • Un coup joué par l'utilisateur déclenche bien un appel à Stockfish (AC-3-1)
  • Le moteur a un temps limité pour répondre (dans notre cas défini à 1 seconde) (AC-3-2)
  • Le coup effectué par Stockfish est mis en évidence (géré automatiquement par vue3-chessboard) (AC-3-3)
  • L'utilisateur ne peut déplacer aucune pièce pendant que c'est au tour du bot de jouer (AC-3-4)

Nous validons donc bien les critères d'acceptation associés : nous avons fini l'implémentation de cette user story.

3.4. Afficher le tour courant

L'objectif maintenant est de rajouter un texte au-dessus de l'échiquier pour indiquer à qui est le tour courant (Blancs ou Noirs). Cette information est utile pour le joueur pour savoir si c'est à lui de jouer ou si le bot est en train de réfléchir.

Nous allons créer un attribut ref qui va contenir le texte à afficher (Blancs ou Noirs) en fonction du tour. Dans notre partie script, ajoutons ref à la liste des imports de vue (import { ref, reactive, onMounted } from 'vue';) et définissions la variable suivante :

const currentTurnLabel = ref<string>('');

Nous rajoutons également une fonction utilitaire pour mettre à jour le libellé du tour courant :

function updateTurnColor(turnColor: PieceColor) {
  currentTurnLabel.value = turnColor === 'white' ? 'Blancs' : 'Noirs';
}

Ajoutons maintenant les mises à jour aux endroits adéquats :

  • Quand le composant vue3-chessboard est créé, nous initialisons la valeur. La fonction onBoardCreated devient :
function onBoardCreated(api: BoardApi) {
  boardAPI = api;
  updateTurnColor(boardAPI.getTurnColor());
}

A chaque fois qu'un joueur (utilisateur ou Stockfish) joue un coup, il faut aussi mettre à jour le tour courant. La fonction onPlayerMove devient :

function onPlayerMove() {
  // Mise à jour de qui joue
  const turnColor = boardAPI.getTurnColor();
  updateTurnColor(turnColor);

  // Appel à Stockfish pour calculer le prochain coup 
  // si c'est son tour de jouer
  if (turnColor !== boardConfig.orientation) {
    boardConfig.viewOnly = true;
    askStockfishMove();
  } else {
    boardConfig.viewOnly = false;
  }
}

Enfin, il faut ajouter le message dans le template :

<template>
  <h2>CheckM8</h2>
  <p>Tour courant : {{ currentTurnLabel }}</p>

  <!-- Plateau -->
  <TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated"
    @move="onPlayerMove">
  </TheChessboard>
</template>

Important : le tour courant aurait pu être géré par un attribut computed. C'est mieux car l'attribut se met alors à jour dès que les attributs réactifs qu'il utilise sont modifiés et il n'y a plus besoin de mise à jour manuelle comme nous le faisons ici.
Alors pourquoi ai-je choisi de mettre à jour manuellement le tour courant et d'utiliser un ref plutôt qu'un computed ?
Et bien tout simplement parce que le tour courant dépend de boardAPI qui n'est pas un attribut réactif. Il faudrait le rendre réactif pour que cela marche en le déclarant comme ceci :

const boardAPI: Ref<BoardApi | null> = ref(null);

En effet, une variable réactive doit avoir une valeur d'assignée dès sa déclaration. Dans la suite, il faut l'utiliser comme ceci : boardAPI.value en vérifiant à chaque fois que la variable n'est pas nulle (exemple : boardAPI.value?.move(moveObj).

Au final, les modifications sont plus lourdes qu'une simple mise à jour manuelle (un seul attribut qui nous intéresse pour l'instant, plus d'endroits impactés et code moins lisible).
Il m'a donc paru plus simple de créer un attribut ref pour le moment. Quand le code grossira et deviendra plus complexe, il faudra potentiellement revoir l'utilisation de computed.

Nous avons maintenant le tour courant qui s'affiche au-dessus de l'échiquier ce qui complète l'US-4 :

US-4 : En tant qu'utilisateur, je veux pouvoir savoir à tout moment à qui est le tour de jouer.

3.5. Gérer la fin de partie

A ce stade nous pouvons jouer une partie contre Stockfish du début à la fin et vue3-chessboard - grâce à chess.js - gère tout seul l'état du jeu (incluant la fin de partie). Néanmoins il nous manque tout de même un retour visuel pour indiquer à l'utilisateur qui a gagné la partie.

Créons un attribut ref pour stocker le message de fin de partie :

const gameOverMessage = ref<string>('');

Ensuite, déclarons trois nouvelles fonctions (une pour chacun des cas suivants : Echec et mat, Pat et Nul) :

function onCheckmate(isMated: PieceColor) {
  // Cas "Echec et mat"
  // Si le joueur maté est l'utilisateur
  if (isMated === boardConfig.orientation) {
    gameOverMessage.value = 'Zut ! Vous avez perdu pour cette fois !';
  } else {
    gameOverMessage.value = 'Bravo ! Vous avez gagné la partie !';
  }
}

function onStalemate() {
  // Cas "Pat"
  gameOverMessage.value = `Match nul ! C'est un pat !`;
}

function onDraw() {
  // Cas "Nul"
  gameOverMessage.value = `Match nul !`;
}

Détail de la fonction onCheckmate :
Nous regardons qui est le joueur maté :

  • S'il s'agit de l'utilisateur, c'est qu'il a perdu ! Nous affectons le message Zut ! Vous avez perdu pour cette fois ! à l'attribut gameOverMessage
  • S'il s'agit de Stockfish, c'est que l'utilisateur a gagné ! Nous affectons donc cette fois le message : Bravo ! Vous avez gagné la partie ! à l'attribut gameOverMessage

Détail de la fonction onStalemate :
S'il y a pat, nous affectons le message : Match nul ! C'est un pat ! à l'attribut gameOverMessage.

Détail de la fonction onDraw :
S'il y a nul (hors pat), nous affectons tout simplement le message : Match nul ! à l'attribut gameOverMessage.

Il ne reste plus qu'à modifier le template pour attacher ces handlers aux évènements émis par le composant vue3-chessboard :

<template>
  <h2>CheckM8</h2>
  <p>Tour courant : {{ currentTurnLabel }}</p>
  <p v-if="gameOverMessage">{{ gameOverMessage }}</p>

  <!-- Plateau -->
  <TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated"
    @move="onPlayerMove"
    @checkmate="onCheckmate"
    @stalemate="onStalemate"
    @draw="onDraw">
  </TheChessboard>
</template>

Avec ces nouvelles modifications :

  • Nous avons bien un message qui s'affiche lorsque la partie est terminée (de manière conditionnelle grâce à un v-if) (AC-5-1)
  • Nous avons bien un message pour chaque cas de fin partie possible (AC-5-2 à AC-5-5)
  • L'utilisateur ne peut déplacer aucune pièce lorsque la partie est finie (gérée automatiquement par vue3-chessboard) (AC-5-6)

Cela valide donc l'US-5 :

US-5 : En tant qu'utilisateur, je veux pouvoir être informé lorsque la partie est terminée afin de savoir si j'ai gagné ou perdu.

3.6. Commencer/recommencer une nouvelle partie

Dernière fonctionnalité de cette première version : nous allons maintenant nous focaliser sur l'US-6 qui consiste à pouvoir commencer/recommencer une nouvelle partie en choisissant l'un ou l'autre des camps.

3.6.1. Création d'une fonction pour réinitialiser la partie

Tout d'abord, nous allons créer deux nouvelles fonctions restartGame et resetGame qui vont contenir la logique pour réinitialiser le jeu :

function restartGame(orientation: PieceColor) {
  // Application de l'orientation et reset de la partie
  boardConfig.orientation = orientation;
  resetGame();
  if (orientation === 'black') {
    // Comme les Blancs commencent, 
    // on demande tout de suite à Stockfish de jouer
    askStockfishMove();
  }
}

function resetGame() {
  // Réinitialisation de l'échiquier
  boardAPI.resetBoard();

  // Réinitialisation des messages
  gameOverMessage.value = '';
  updateTurnColor(boardAPI.getTurnColor());

  // Désactivation des déplacements 
  // en attendant que Stockfish soit prêt
  boardConfig.viewOnly = true;

  // Réinitialisation du moteur Stockfish
  worker.postMessage('ucinewgame');
  worker.postMessage('isready');
}

Détail de la fonction restartGame :
Nous commencons, sur la première ligne, par définir l'orientation de l'échiquier en fonction du choix du joueur :

  • white : l’échiquier est affiché normalement (Blancs en bas)
  • black : l’échiquier est retourné (Noirs en bas)

Ensuite nous faisons appel à une autre fonction resetGame dont le rôle est de réinitialiser l'état de la partie : remise de l'échiquier dans l'état de départ et réinitialisation des variables d'état.

Enfin, nous avons une condition en fonction de l'orientation du plateau : si l'utilisateur joue les Noirs, il faut demander à Stockfish de jouer le premier coup car, dans une partie classique, ce sont toujours les Blancs qui commencent → nous appelons donc la fonction askStockfishMove que nous avons définie plus haut dans cet article.

Détail de la fonction resetGame :
La première instruction demande au composant vue3-chessboard de réinitialiser l'échiquier et l'état du jeu.

Ensuite nous effaçons le message de fin de partie et nous mettons à jour le tour courant (celui-ci vaudra Blancs puisque les Blancs commencent dans une partie d'échecs classique).

Nous désactivons les intéractions avec le composant vue3-chessboard de façon à empêcher l'utilisateur de déplacer les pièces pendant la réinitialisation de Stockfish.

Enfin, en dernier, nous indiquons à Stockfish que nous démarrons une nouvelle partie (ce qui entraîne une réinitialisation interne) et nous lui demandons de nous indiquer quand il est prêt.

3.6.2. Ajout des boutons sur l'interface

Il ne nous manque plus qu'à rajouter deux boutons sous notre échiquier dans notre template pour faire appel à notre fonction restartGame :

<template>
  <h2>CheckM8</h2>

  <!-- Plateau -->
  <TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated">
  </TheChessboard>
    
  <!-- Boutons pour réinitialiser la partie -->
  <button @click="restartGame('white')">Nouvelle partie (Blancs)</button>
  <button @click="restartGame('black')">Nouvelle partie (Noirs)</button>
</template>

En relancant le serveur de développement avec la commande npm run dev, quand l'utilisateur arrive sur la page il a les Blancs par défaut et peut jouer directement (AC-6-2).
Nous avons également les deux boutons Nouvelle partie (Blancs) et Nouvelle partie (Noirs) pour recommencer une nouvelle partie (AC-6-1).
Le clic sur l'un de ces boutons remet les pièces dans leur position initiale (AC-6-3) et si l'utilisateur choisit de jouer les Noirs l'échiquier est en plus retourné (AC-6-4).

Nous avons donc complètement implémenté la user story :

US-6 : En tant qu'utilisateur, je veux pouvoir commencer une nouvelle partie et choisir mon côté (Blancs ou Noirs) afin de jouer contre une IA et pratiquer l'un côté comme l'autre.

Code Final

Voici le code final du fichier App.vue de notre projet CheckM8, l'application pour s'entraîner aux échecs :

<template>
  <h2>CheckM8</h2>
  <p>Tour courant : {{ currentTurnLabel }}</p>
  <p v-if="gameOverMessage">{{ gameOverMessage }}</p>

  <!-- Plateau -->
  <TheChessboard
    :board-config="boardConfig"
    reactive-config
    @board-created="onBoardCreated"
    @move="onPlayerMove"
    @checkmate="onCheckmate"
    @stalemate="onStalemate"
    @draw="onDraw">
  </TheChessboard>

  <!-- Bouton pour réinitialiser la partie -->
  <button @click="restartGame('white')">Nouvelle partie (Blancs)</button>
  <button @click="restartGame('black')">Nouvelle partie (Noirs)</button>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { TheChessboard } from 'vue3-chessboard';
import 'vue3-chessboard/style.css';
import type {
  BoardApi,
  BoardConfig,
  Move,
  PieceColor,
  Promotion
} from 'vue3-chessboard';
import type { Key } from 'chessground/types';

let boardAPI: BoardApi;

const boardConfig: BoardConfig = reactive({
  coordinates: true,
  orientation: 'white',
  position: 'start',
  viewOnly: true,
});

const gameOverMessage = ref<string>('');
const currentTurnLabel = ref<string>('');

let worker: Worker;

onMounted(() => {
  // Création d'un nouvel objet Stockfish
  worker = new Worker(
    new URL(
      '/stockfish/stockfish-nnue-16.js',
      import.meta.url
    ),
    { type: 'module' }
  );
  
  // Abonnement à l'évènement 'onmessage' du moteur Stockfish
  worker.onmessage = onStockfishMessage;

  // Initialisation du moteur
  worker.postMessage('uci');
  worker.postMessage('isready');
  worker.postMessage('setoption name Skill Level value 0');
  worker.postMessage('ucinewgame');
});

function onBoardCreated(api: BoardApi) {
  boardAPI = api;
  updateTurnColor(boardAPI.getTurnColor());
}

function onPlayerMove() {
  // Mise à jour de qui joue
  const turnColor = boardAPI.getTurnColor();
  updateTurnColor(turnColor);

  // Appel à Stockfish pour calculer le prochain coup 
  // si c'est son tour de jouer
  if (turnColor !== boardConfig.orientation) {
    boardConfig.viewOnly = true;
    askStockfishMove();
  } else {
    boardConfig.viewOnly = false;
  }
}

function askStockfishMove() {
  // Passage de l'état de la partie à Stockfish pour un nouveau calcul
  worker.postMessage(`position fen ${boardAPI.getFen()}`);
  
  // Lancement du calcul pour 1 seconde
  worker.postMessage('go movetime 1000');
}

function onStockfishMessage(event: any) {
  if (!boardAPI) {
    throw new Error('vue3-chessboard is not initialized.');
  }

  const msg: string = event.data;
  console.log(msg);

  // Si Stockfish est prêt, on active les intéractions
  // avec le composant 'vue3-chessboard'
  if (msg === 'readyok') {
    boardConfig.viewOnly = false;
  }

  if (msg.includes('bestmove')) {
    const tokens = msg.split(' ');

    // Vérification présence valeur de bestmove
    if (tokens.length < 2) {
      console.error('Best move not found.');
      return;
    }

    // Récupération de la valeur du meilleur coup à jouer
    const bestMove = tokens[1];

    // Vérification du format du best move
    if (!bestMove || bestMove.length < 4) {
      console.error('Incorrect best move format.');
      return;
    }

    // Application du coup calculé
    const moveObj: Move = { 
      from: bestMove.substring(0, 2) as Key,
      to: bestMove.substring(2, 4) as Key 
    };

    // Si la valeur de bestMove fait 5 caractères,
    // c'est que la pièce est promue
    if (bestMove.length === 5) {
      moveObj.promotion = bestMove[4] as Promotion;
    }

    // Déplacement de la pièce sur l'échiquier
    boardAPI.move(moveObj);
  }
}

function onCheckmate(isMated: PieceColor) {
  // Si le joueur maté est l'utilisateur
  if (isMated === boardConfig.orientation) {
    gameOverMessage.value = 'Zut ! Vous avez perdu pour cette fois !';
  } else {
    gameOverMessage.value = 'Bravo ! Vous avez gagné la partie !';
  }
}

function onStalemate() {
  gameOverMessage.value = `Match nul ! C'est un pat !`;
}

function onDraw() {
  gameOverMessage.value = `Match nul !`;
}

function restartGame(orientation: PieceColor) {
  // Application de l'orientation et reset de la partie
  boardConfig.orientation = orientation;
  resetGame();
  if (orientation === 'black') {
    // Comme les Blancs commencent, 
    // on demande tout de suite à Stockfish de jouer
    askStockfishMove();
  }
}

function resetGame() {
  // Réinitialisation de l'échiquier
  boardAPI.resetBoard();

  // Réinitialisation des messages
  gameOverMessage.value = '';
  updateTurnColor(boardAPI.getTurnColor());

  // Désactivation des déplacements 
  // en attendant que Stockfish soit prêt
  boardConfig.viewOnly = true;

  // Réinitialisation du moteur Stockfish
  worker.postMessage('ucinewgame');
  worker.postMessage('isready');
}

function updateTurnColor(turnColor: PieceColor) {
  currentTurnLabel.value = turnColor === 'white' ? 'Blancs' : 'Noirs';
}
</script>

<style scoped>
</style>

Vous pouvez une nouvelle fois accéder à l'application et vérifier vous-mêmes les fonctionnalités en lancant le serveur de développement avec la commande :

npm run dev

4. Déploiement

Maintenant que le développement de notre projet est terminée, attaquons la partie déploiement !

Important : Nous allons faire un simple déploiement en local : il faudrait rajouter la gestion du HTTPS avec un certificat SSL pour un déploiement en production.

Allons-y ! Voici le diagramme de déploiement pour notre application CheckM8 :

Figure 5 - Diagramme de déploiement

4.1. Copie automatique des scripts Stockfish dans public

Pour éviter de devoir copier les scripts de Stockfish à la main nous allons rajouter une commande postinstall dans la partie scripts du fichier package.json de notre frontend :

{
  "scripts": {
    "postinstall": "mkdir -p public/stockfish && cp node_modules/stockfish/src/stockfish-nnue-16.* public/stockfish/"
  }
}

Cette commande va permettre d'automatiquement copier tous les scripts du répertoire d'installation de Stockfish commencant par stockfish-nnue-16. dans le répertoire public/stockfish/ du projet Vue.

4.2. Configuration de nginx

Notre frontend étant servi par un nginx, nous allons créer un fichier nginx.conf à la racine de notre frontend Vue et écrire dedans la configuration suivante :

server {
    listen 80;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri /index.html;
    }

    location /stockfish/ {
        root /usr/share/nginx/html;
        types { application/wasm wasm; }
        add_header Cross-Origin-Opener-Policy same-origin;
        add_header Cross-Origin-Embedder-Policy require-corp;
        add_header Cross-Origin-Resource-Policy cross-origin;
    }

    location ~* \.(wasm|js|css|html|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|otf|eot|json|txt)$ {
        expires 6M;
        access_log off;
        add_header Cache-Control "public";
    }
}

Détail du fichier nginx.conf :

  • Au début de la configuration, nous définissions les paramètres généraux du serveur :
    • nginx écoute sur le port 80
    • Le dossier racine est défini comme /usr/share/nginx/html : c'est là que sont stockés les fichiers de l'application Vue après le build
    • index.html est utilisée comme page d'accueil
  • Nous définissions ensuite une règle pour les requêtes dont le préfixe est / :
    • Si un utilisateur accède directement à une page autre que l’accueil, Nginx redirige la requête vers index.html, permettant à Vue de prendre en charge la navigation côté client
  • Puis nous ajoutons des règles pour les requêtes vers le répertoire /stockfish/ :
    • On s’assure que Nginx cherche bien les fichiers de Stockfish dans /usr/share/nginx/html/stockfish/ en remettant la règle root /usr/share/nginx/html;
    • Le type MIME application/wasm est déclaré, garantissant la reconnaissance correcte des fichiers .wasm par le navigateur
    • Des en-têtes HTTP sont ajoutées pour éviter l’erreur SharedArrayBuffer is not defined :
      • Cross-Origin-Opener-Policy: same-origin → assure que l’application s’exécute dans un environnement sécurisé
      • Cross-Origin-Embedder-Policy: require-corp → autorise le chargement de WebAssembly
      • Cross-Origin-Resource-Policy: cross-origin → permet aux fichiers .wasm d’être accessibles même en provenance d’une autre origine
  • Enfin, nous créons des règles qui s'appliquent à tous les fichiers statiques :
    • Ils sont mis en cache pendant 6 mois pour éviter aux utilisateurs de re-télécharger ces fichiers à chaque visite
    • Les logs d’accès sont désactivés pour économiser des ressources
    • La politique Cache-Control est définie à public permettant aux navigateurs de garder ces fichiers en cache

Notre configuration du serveur web est maintenant terminée !

4.3. Ecriture du Dockerfile du frontend Vue

Comme nous l'avons spécifié dans lors de la conception de l'architecture du projet, nous allons utiliser Docker pour conteneuriser notre application.

Voici le Dockerfile pour notre frontend Vue :

# Étape 1 : Build Vue.js
FROM node:22.14 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build

# Étape 2 : Serveur Nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Détail du Dockerfile :
Le Dockerfile est divisé en deux étapes distinctes :

  • Etape n°1 : build du frontend Vue en ressources statiques (transpilation du Typescript en Javascript, minimification, bundling des assets, copie dans le répertoire dist)
  • Etape n°2 : création du conteneur nginx pour servir les ressources statiques générées à l'étape précédente

Etape n°1 : Build du frontend

  • Nous commencons par définir l'image de base (ici node:22.14). Il s'agit de la version LTS (Long-Term Support) de Node au moment où j'écris cet article
  • Nous définissons /app (à l'intérieur du conteneur) comme le répertoire de travail
  • Nous copions d'abord uniquement les fichiers package.json et package-lock.json dans le conteneur
  • Nous lancons ensuite la commande : npm install pour installer les dépendances et copier les scripts Stockfish dans le répertoire /public/stockfish/
  • Dans l'étape suivante, nous copions l'ensemble des fichiers sources dans le conteneur
  • Puis nous lancons la commande npm run build pour générer les ressources statiques

Etape n°2 : Création du conteneur nginx

  • Nous démarrons cette étape en définissant l'image de base (ici nginx:1.27-alpine)
  • Nous copions ensuite les fichiers générés à l'étape n°1 dans le répertoire /usr/share/nginx/html
  • Enfin nous copions le fichier de configuration nginx.conf dans le répertoire /etc/nginx/conf.d/ et nous le renommons default.conf

Il ne reste plus qu'à exposer le port 80 et à lancer le serveur web au premier plan avec l'option daemon off; (un conteneur = un process).

4.4. Ecriture du fichier docker-compose.yml

La dernière étape de notre déploiement est de créer un fichier docker-compose.yml pour orchestrer notre unique conteneur.
Cela a peu d'intérêt pour le moment mais lorsque nous aurons plus de services à gérer, il sera essentiel d'utiliser un orchestrateur (docker-compose, Swarm, k8s, k3s, etc...).

Nous allons donc d'ores-et-déjà mettre en place docker-compose. Pour cela, créons un fichier docker-compose.yml à côté de notre projet Vue (dans le répertoie checkm8). Celui-ci contient la configuration YAML suivante :

services:
  frontend:
    image: checkm8-frontend
    build: ./checkm8-frontend
    ports:
      - "8080:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    restart: always

Détails du docker-compose.yml :

  • Nous commencons par définir un service frontend qui correspond à notre serveur nginx fournissant notre application Vue
  • Nous donnons ensuite un nom à notre image : ici checkm8-frontend
  • Puis nous indiquons le répertoire dans lequel se situe le Dockerfile de l'image : ici ./checkm8-frontend
  • Nous redirigons ensuite le port 8080 de la machine hôte vers le port 80 du conteneur (où nginx écoute) → nous pourrons accéder à l'application via http://localhost:8080
  • Nous montons le fichier nginx.conf personnalisé dans le conteneur → avoir un volume ici nous permet de nous assurer que toute modification du fichier nginx.conf soit prise en compte sans devoir reconstruire l'image
  • Enfin, nous ajoutons l'option restart: always pour s'assurer que nginx redémarre automatiquement en cas de crash ou de redémarrage du serveur

4.5. Tester notre déploiement

Pour tester notre déploiement, il faut d'abord construire l'image Docker en lancant la commande suivante depuis le répertoire où se trouve le fichier docker-compose.yml :

docker compose build

Puis lancer les services (ici un seul service frontend) :

docker compose up -d

Et voilà ! Nous en avons terminé avec la partie déploiement !

5. Prochaines étapes / Possibilités d'amélioration

Cette première version de notre application est finie pour l'instant mais il y a énormément de possibilités pour l'améliorer :

  1. Inclure une bibliothèque CSS (Bootstrap, Tailwind CSS, etc...) pour une application plus esthétique
  2. Charger une partie à partir d'une FEN (et pouvoir la continuer avec le bot)
  3. Afficher l'historique de la partie (liste coups joués depuis le début)
  4. Revenir en arrière dans l'historique de la partie pour reprendre à un point donné
  5. Ajouter une fonctionnalité d'"indice" : un bouton qui permet de montrer à l'utilisateur le meilleur coup à jouer sur le plateau
  6. Analyser le coup qui vient d'être joué par le joueur et faire un retour à l'utilisateur (bon coup, mauvais coup, excellent, etc...) avec une explication détaillée
  7. Limiter plus Stockfish (ELO minimum de 1320 pour la version actuellement utilisée) ou trouver une autre IA pour les débutants
  8. Développer une API REST pour déporter le calcul de Stockfish en backend et permettre aux utilisateurs de s'inscrire
  9. Proposer des puzzles à résoudre (https://database.lichess.org/#puzzles)

Nous implémenterons quelques unes de ces idées dans la prochaine version !

6. Conclusion

Nous avons vu comment concevoir et développer une application web permettant de jouer aux échecs contre une intelligence artificielle, directement dans le navigateur. Grâce à Vue 3 et WebAssembly, nous avons pu intégrer Stockfish, un moteur d’analyse puissant.

Ce projet constitue une excellente base pour s’entraîner aux échecs, expérimenter différentes stratégies et tester des positions spécifiques sans avoir besoin d’un adversaire humain. De nombreuses améliorations sont possibles, comme l’ajout de niveaux de difficulté ajustables, la possibilité de charger des puzzles d’échecs ou encore le suivi des parties jouées.

Liens utiles

  • chess.com → L'une des plateformes d’échecs en ligne les plus populaires, offrant des parties en temps réel et des outils d’analyse avancés
  • lichess → Une plateforme d’échecs gratuite et open source proposant des parties en ligne, des puzzles et un moteur d’analyse puissant
  • Vue 3 → Framework JavaScript progressif permettant de développer des interfaces utilisateur réactives et performantes
  • vue3-chessboard → Composant Vue.js permettant d’intégrer facilement un échiquier interactif dans une application web
  • Stockfish → Moteur d’échecs open source utilisé pour l’analyse et le jeu automatisé
  • nginx → Serveur web et reverse proxy, utilisé pour héberger et distribuer des applications web
  • Docker → Plateforme de conteneurisation permettant de déployer et exécuter des applications de manière portable et isolée
  • docker-compose → Outil facilitant l’orchestration des conteneurs Docker en définissant leur configuration dans un fichier unique