Créer un jeu du morpion en JavaScript (avec une IA imbattable)

Avec le morpion, on peut écrire en une soixantaine de lignes un adversaire qui ne perd jamais, et le vérifier soi-même plutôt que de le croire sur parole. Neuf cases, c'est assez peu pour que l'ordinateur essaie toutes les suites possibles d'une partie avant chaque coup. Cette méthode s'appelle minimax, et c'est le cœur de ce projet.

Nous allons construire le jeu complet : une page avec neuf boutons, les règles, l'IA, deux façons de lancer une partie (vous commencez, ou l'ordinateur commence), puis la preuve. Elle tient en un chiffre : sur les 642 parties différentes qu'un humain peut jouer contre cette IA, en essayant tous les coups permis, l'ordinateur n'en perd aucune. Si vous n'avez encore jamais fabriqué un jeu dans une page, le projet Snake en JavaScript est une bonne mise en jambes ; ici, il n'y a ni boucle de jeu ni animation, seulement des clics et un peu de récursivité.

Tout le code de cette page a été exécuté le 7 octobre 2026 dans Chromium 153.0.8010.12 (le navigateur de Playwright 1.63.0), piloté depuis Node 20.19.4 sous Windows 11. Ce banc de test (un script qui rejoue le jeu et vérifie chacun des chiffres de cette page) contrôle aussi, à chaque exécution, que les blocs de code ci-dessous sont recopiés à l'identique des fichiers qu'il a joués.

Quatre fichiers, et une séparation qui change tout

Le projet tient en quatre fichiers à placer dans un même dossier :

Pourquoi deux fichiers JavaScript ? Parce que la logique qui ne touche pas la page se teste sans navigateur. C'est ainsi que l'invincibilité de l'IA a été vérifiée : logique.js, chargé tel quel dans Node, a joué toutes les parties possibles sans ouvrir de navigateur. Séparées de la page, ces fonctions se testent directement dans Node, sans avoir à préparer de page ni de boutons pour elles.

Aucun outil à installer : ouvrez index.html par un double-clic, le jeu fonctionne directement depuis le disque.

Neuf boutons et une ligne de statut

La page commence par le HTML. Les cases sont de vrais <button> : on peut donc jouer à la souris, au doigt ou au clavier (Tab pour se déplacer, Entrée ou Espace pour jouer) sans écrire une ligne de code pour cela. Le paragraphe #statut porte l'attribut aria-live="polite" : un lecteur d'écran annonce chaque nouveau message, « à la prochaine occasion appropriée » selon la documentation MDN, donc sans couper la phrase en cours.

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Morpion</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <main>
    <h1>Morpion</h1>
    <p id="statut" aria-live="polite"></p>
    <div class="plateau" role="group" aria-label="Plateau de jeu">
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
      <button class="case" type="button"></button>
    </div>
    <p class="actions">
      <button id="vous-commencez" type="button">Nouvelle partie : vous commencez</button>
      <button id="ordi-commence" type="button">Nouvelle partie : l'ordinateur commence</button>
    </p>
  </main>
  <script src="logique.js"></script>
  <script src="morpion.js"></script>
</body>
</html>

Les deux balises <script> sont placées en fin de page, une fois les boutons créés. morpion.js utilise des fonctions déclarées dans logique.js : ce qui est déclaré au premier niveau d'un script classique reste visible des autres scripts de la page. Le banc a d'ailleurs inversé les deux lignes : le jeu marche encore, parce que morpion.js n'appelle ces fonctions qu'au premier clic, quand les deux fichiers sont chargés. L'ordre logique, celui des dépendances, reste le plus sûr à lire.

Le style vient ensuite. La partie qui compte est la grille : trois colonnes égales, et des cases carrées grâce à aspect-ratio: 1, quelle que soit la largeur de l'écran.

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  font-family: system-ui, sans-serif;
  color: #1d222a;
  background: #f4f5f8;
}

main {
  padding: 1rem;
  text-align: center;
}

.plateau {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
  width: min(90vw, 330px);
  margin: 0 auto 1rem;
}

.case {
  aspect-ratio: 1;
  font-size: 2.5rem;
  font-weight: bold;
  color: #1d222a;
  background: #fff;
  border: 2px solid #1d222a;
  border-radius: 8px;
  cursor: pointer;
}

.case[aria-disabled="true"] {
  cursor: default;
}

.case:focus-visible {
  outline: 3px solid #4f46e5;
  outline-offset: 2px;
}

.actions button {
  margin: 0.25rem;
  padding: 0.5rem 0.75rem;
  font: inherit;
}

Mesuré dans Chromium : le plateau fait 330 px de large à 1440 px comme à 390 px de fenêtre, soit des cases de 106 × 106 px (330 moins les deux espacements de 6 px, divisé par trois). La valeur 90vw ne prend le relais que sur les écrans plus étroits : à 320 px, le plateau descend à 288 px et les cases à 92 × 92 px, sans défilement horizontal. Si grid-template-columns vous est étranger, le guide CSS Grid pas à pas détaille cette propriété.

Le plateau est un tableau de neuf chaînes

Côté JavaScript, le plateau n'est pas une grille à deux dimensions. C'est un simple tableau de neuf cases, numérotées de 0 à 8 ligne par ligne, où chaque case vaut "" (vide), "X" ou "O". Un plateau vide s'écrit donc ["", "", "", "", "", "", "", "", ""].

Plateau numéroté de 0 à 8 ligne par ligne, puis trois exemples d'alignement gagnant : la ligne 0, 1, 2, la colonne 1, 4, 7 et la diagonale 2, 4, 6. Il existe 3 lignes, 3 colonnes et 2 diagonales, soit 8 alignements.
Dans le code, les cases vont de 0 à 8. Les messages de la page, eux, numérotent les cases de 1 à 9 (indice + 1), ce qui est plus naturel pour un joueur.

Avec cette numérotation, une victoire, c'est trois indices précis remplis par le même symbole. Il n'y en a que huit possibles, autant les écrire en toutes lettres plutôt que de les calculer :

// Les huit alignements gagnants, repérés par le numéro des cases (0 à 8).
const LIGNES = [
  [0, 1, 2], [3, 4, 5], [6, 7, 8], // les trois lignes
  [0, 3, 6], [1, 4, 7], [2, 5, 8], // les trois colonnes
  [0, 4, 8], [2, 4, 6],            // les deux diagonales
];

const HUMAIN = "X";
const ORDI = "O";

Le joueur humain a toujours les X et l'ordinateur les O, quel que soit celui qui commence. Les deux constantes évitent de semer des "X" et des "O" dans tout le code.

Reconnaître la fin d'une partie

Deux petites fonctions suffisent pour savoir où l'on en est. gagnant parcourt les huit alignements et renvoie le symbole qui en occupe un en entier ; casesLibres liste les cases encore jouables. Une partie est finie quand il y a un gagnant, ou quand il n'y a plus de case libre (match nul).

// Renvoie "X" ou "O" si un joueur a aligné trois symboles, sinon null.
function gagnant(plateau) {
  for (const [a, b, c] of LIGNES) {
    if (plateau[a] !== "" && plateau[a] === plateau[b] && plateau[a] === plateau[c]) {
      return plateau[a];
    }
  }
  return null;
}

// Renvoie la liste des numéros de cases encore vides.
function casesLibres(plateau) {
  const libres = [];
  for (let i = 0; i < 9; i++) {
    if (plateau[i] === "") libres.push(i);
  }
  return libres;
}

Le test plateau[a] !== "" n'est pas une précaution de style. Sans lui, trois cases vides seraient vues comme trois cases « identiques », donc comme un alignement, et la boucle s'arrêterait dessus. Le banc l'a vérifié sur un plateau où X a gagné sur la ligne du bas alors que la ligne du haut est vide : avec le test, gagnant renvoie "X" ; sans lui, elle renvoie "" dès la première ligne et la victoire de X passe inaperçue.

Minimax : jouer toutes les parties avant de jouer un coup

Voici l'idée de l'IA. Pour choisir son coup, l'ordinateur ne cherche pas une « bonne case » à l'instinct : il imagine chaque coup possible, puis chaque réponse possible de l'humain, puis chacune de ses propres réponses, et ainsi de suite jusqu'à la fin de toutes les parties. Chaque fin de partie reçoit une note : positive si l'ordinateur gagne, négative s'il perd, 0 pour un nul. Il ne reste qu'à faire remonter ces notes.

La règle pour les faire remonter donne son nom à la méthode. Quand c'est à l'ordinateur de jouer, il prendra le coup de note maximale. Quand c'est à l'humain, on suppose qu'il jouera le coup le plus gênant pour l'ordinateur, celui de note minimale. Si l'humain joue moins bien que ça, tant mieux pour l'ordinateur : il ne peut pas faire pire que la note prévue.

// Note une position : positive si l'ordinateur gagne, négative s'il perd, 0 pour un nul.
function minimax(plateau, joueur, profondeur) {
  const vainqueur = gagnant(plateau);
  if (vainqueur === ORDI) return 10 - profondeur;
  if (vainqueur === HUMAIN) return profondeur - 10;
  const libres = casesLibres(plateau);
  if (libres.length === 0) return 0;

  const scores = [];
  for (const i of libres) {
    const suite = plateau.slice();
    suite[i] = joueur;
    const adversaire = joueur === ORDI ? HUMAIN : ORDI;
    scores.push(minimax(suite, adversaire, profondeur + 1));
  }
  // L'ordinateur prend le meilleur score, l'humain est supposé jouer le pire pour lui.
  return joueur === ORDI ? Math.max(...scores) : Math.min(...scores);
}

Trois points à lire de près :

Combien de positions l'ordinateur évalue-t-il ? Le banc a compté les appels à minimax, sachant qu'une même position peut être évaluée plusieurs fois, atteinte par des ordres de coups différents : quand l'ordinateur commence sur un plateau vide, minimax est appelée 549 945 fois avant le premier coup. S'il répond à un X posé dans un coin, 59 704 fois ; au centre, 55 504 fois. C'est beaucoup pour un humain, peu pour un navigateur : le clic sur « l'ordinateur commence » a pris une cinquantaine de millisecondes dans Chromium sur la machine de test (valeur indicative, qui varie d'un ordinateur à l'autre). Les coups suivants sont bien plus rapides, puisqu'il reste moins de cases.

Il reste à choisir le coup. meilleurCoup pose un O sur chaque case libre d'une copie, demande sa note à minimax (c'est alors à l'humain de jouer) et garde la meilleure :

// Essaie chaque case libre pour l'ordinateur et garde la mieux notée.
function meilleurCoup(plateau) {
  let meilleurScore = -Infinity;
  let coup = -1;
  for (const i of casesLibres(plateau)) {
    const suite = plateau.slice();
    suite[i] = ORDI;
    const score = minimax(suite, HUMAIN, 1);
    if (score > meilleurScore) {
      meilleurScore = score;
      coup = i;
    }
  }
  return coup;
}

La comparaison stricte score > meilleurScore a un effet discret : à égalité de note, c'est la première case essayée qui reste, celle de plus petit indice. L'IA est donc déterministe. Quand elle commence, elle joue toujours la case 1 (indice 0) : sur un plateau vide, toutes les cases mènent à un nul avec un jeu parfait des deux côtés, et le coin en haut à gauche est simplement la première.

Pourquoi « 10 moins la profondeur » et pas 10

La profondeur compte les coups joués depuis la position de départ de la réflexion. En retranchant cette profondeur, une victoire rapide vaut plus qu'une victoire lente (et une défaite lointaine coûte moins qu'une défaite immédiate). Le schéma ci-dessous montre une position réelle, tirée d'une partie de test : X a joué les cases 1, 3 et 7, O les cases 2 et 5, et c'est à l'ordinateur de jouer.

Position X O X, vide O vide, X vide vide, à l'ordinateur de jouer. Case 4 : victoire deux coups plus tard, score 7. Case 6 : X gagne juste après en case 4, score moins 8. Case 8 : victoire immédiate sur les cases 2, 5, 8, score 9, coup choisi. Case 9 : X gagne juste après, score moins 8.
Les quatre notes calculées par le banc pour cette position. La case 8 gagne tout de suite (9), la case 4 gagne aussi, mais deux coups plus tard (7).

Remplacez 10 - profondeur par 10 et profondeur - 10 par -10, et les notes deviennent 10, -10, 10 et -10 : la case 4 et la case 8 sont à égalité, et la règle du plus petit indice fait choisir la case 4. L'ordinateur gagne quand même, mais il laisse passer la victoire immédiate pour la retarder de deux coups. Le banc a rejoué toutes les parties possibles avec cette variante :

Toutes les parties possiblesAvec la profondeurSans la profondeur
Vous commencez : parties différentes569681
Victoires de l'ordinateur au 6ᵉ coup188132
Victoires de l'ordinateur au 8ᵉ coup198366
Matchs nuls183183
L'ordinateur commence : parties différentes73101
Victoires de l'ordinateur au 5ᵉ coup4033
Victoires de l'ordinateur au 7ᵉ coup3152
Victoires de l'ordinateur au 9ᵉ coup014
Matchs nuls22

Dans les deux colonnes, l'humain ne gagne jamais : la profondeur ne rend pas l'IA plus forte, elle la rend plus directe. Sans elle, l'ordinateur fait traîner des parties qu'il a déjà gagnées, ce qui ressemble beaucoup à de la maladresse pour celui qui joue en face.

Brancher la logique sur la page

Reste morpion.js, qui fait le lien avec l'écran. Il garde deux variables d'état : le tableau plateau et le booléen finie. La fonction afficher recopie ce tableau dans les neuf boutons, et c'est la seule à le faire : l'écran est toujours le reflet du tableau, jamais l'inverse.

const cases = document.querySelectorAll(".case");
const statut = document.querySelector("#statut");

let plateau;
let finie;

// Recopie le tableau plateau dans les neuf boutons.
function afficher() {
  cases.forEach((bouton, i) => {
    bouton.textContent = plateau[i];
    bouton.setAttribute("aria-label", `Case ${i + 1} : ${plateau[i] || "vide"}`);
    bouton.setAttribute("aria-disabled", String(finie || plateau[i] !== ""));
  });
}

Chaque bouton reçoit un aria-label du type « Case 5 : O » : sans lui, une case vide n'aurait aucun nom pour un lecteur d'écran. Et une case prise ou une partie finie reçoit aria-disabled="true", un choix qui mérite qu'on s'y arrête.

L'attribut HTML disabled serait plus simple, mais un bouton désactivé ne peut plus recevoir le focus. Le banc a essayé cette variante, en ne changeant que cette ligne : on joue la case 1 au clavier, et le focus quitte la case pour revenir au corps du document (document.activeElement vaut alors body) : plus aucun contour de focus à l'écran, le joueur au clavier ne voit plus où il en est. Avec aria-disabled, le focus reste sur la case jouée. Contrepartie, rappelée par MDN : cet attribut ne fait qu'annoncer l'état, il n'empêche rien. Le blocage doit être fait en JavaScript, et c'est le rôle de la première ligne du gestionnaire de clic, plus bas.

Viennent ensuite la fin de partie, le coup de l'ordinateur et la remise à zéro :

// Annonce le résultat si la partie est terminée et renvoie true dans ce cas.
function verifierFin() {
  const vainqueur = gagnant(plateau);
  if (vainqueur === HUMAIN) {
    statut.textContent = "Vous avez gagné !";
  } else if (vainqueur === ORDI) {
    statut.textContent = "L'ordinateur a gagné.";
  } else if (casesLibres(plateau).length === 0) {
    statut.textContent = "Match nul.";
  } else {
    return false;
  }
  finie = true;
  return true;
}

function jouerOrdi() {
  const coup = meilleurCoup(plateau);
  plateau[coup] = ORDI;
  if (!verifierFin()) {
    statut.textContent = `L'ordinateur a joué la case ${coup + 1}. À vous.`;
  }
}

function nouvellePartie(ordiCommence) {
  plateau = ["", "", "", "", "", "", "", "", ""];
  finie = false;
  statut.textContent = "À vous de commencer : vous avez les X.";
  if (ordiCommence) jouerOrdi();
  afficher();
}

verifierFin écrit le message de résultat et passe finie à true. jouerOrdi annonce la case jouée par l'ordinateur, ce qui sert aussi au lecteur d'écran grâce à aria-live. nouvellePartie repart d'un tableau neuf et, si l'ordinateur commence, le fait jouer tout de suite.

Enfin, les écouteurs de clic :

cases.forEach((bouton, i) => {
  bouton.addEventListener("click", () => {
    // Partie terminée ou case déjà prise : le clic ne fait rien.
    if (finie || plateau[i] !== "") return;
    plateau[i] = HUMAIN;
    if (!verifierFin()) jouerOrdi();
    afficher();
  });
});

document.querySelector("#vous-commencez").addEventListener("click", () => nouvellePartie(false));
document.querySelector("#ordi-commence").addEventListener("click", () => nouvellePartie(true));

nouvellePartie(false);

La garde if (finie || plateau[i] !== "") return; est la vraie protection du jeu. Le banc a cliqué sur une case déjà prise, puis sur une case libre après une victoire de l'ordinateur : dans les deux cas, ni le plateau ni le message n'ont bougé. Notez aussi que l'ordinateur répond dans le même gestionnaire, sans délai artificiel. Ajouter un setTimeout pour « faire réfléchir » l'IA paraît anodin, mais pendant ce délai, la garde ci-dessus laisserait passer un second clic sur une autre case libre, donc un deuxième X avant la réponse de l'ordinateur : il faudrait une garde de plus.

Partie de contrôle jouée par le banc : vous posez X en case 1, l'ordinateur prend le centre (case 5) ; X en case 3, l'ordinateur bloque en case 2 ; X en case 7, l'ordinateur aligne 2, 5, 8 et le statut affiche « L'ordinateur a gagné. ». Une partie au clavier (cases 5, 3, 4, 8 puis 9) s'est terminée par « Match nul. », le focus toujours sur la dernière case jouée.

Vérifier vous-même : 642 parties, zéro défaite

« Imbattable » est une promesse qui se vérifie. Le morpion est assez petit pour qu'on puisse faire jouer à l'humain absolument tous les coups permis, à chaque tour, contre l'IA qui répond comme dans le jeu. Ouvrez la page, puis la console du navigateur (F12, onglet Console), et collez ceci. Si Chrome refuse le collage et affiche un avertissement, c'est une protection prévue pour qui ouvre rarement la console : tapez allow pasting comme il le demande, puis collez à nouveau.

// À coller dans la console du navigateur, sur la page du jeu.
function explorer(plateau, tour, bilan) {
  const vainqueur = gagnant(plateau);
  if (vainqueur || casesLibres(plateau).length === 0) {
    bilan[vainqueur ?? "nul"]++;
    return;
  }
  if (tour === HUMAIN) {
    // Au tour de l'humain : on essaie toutes les cases libres.
    for (const i of casesLibres(plateau)) {
      const suite = plateau.slice();
      suite[i] = HUMAIN;
      explorer(suite, ORDI, bilan);
    }
  } else {
    // Au tour de l'ordinateur : il joue son meilleur coup, comme dans le jeu.
    const suite = plateau.slice();
    suite[meilleurCoup(plateau)] = ORDI;
    explorer(suite, HUMAIN, bilan);
  }
}

for (const premier of [HUMAIN, ORDI]) {
  const bilan = { X: 0, O: 0, nul: 0 };
  explorer(["", "", "", "", "", "", "", "", ""], premier, bilan);
  console.log(`${premier} commence : victoires de X ${bilan.X}, victoires de O ${bilan.O}, nuls ${bilan.nul}`);
}

Ce code fonctionne dans la console parce que gagnant, casesLibres, meilleurCoup, HUMAIN et ORDI sont déclarées au premier niveau de logique.js. Dans Chromium, il affiche :

569 parties quand vous commencez, 73 quand l'ordinateur commence : 642 au total, et pas une seule victoire de X. Ce sont les deux seules configurations que propose la page, et c'est pour cela que la preuve est complète : il n'existe pas d'autre partie à jouer contre cette IA. Le banc de mesure a fait le même parcours dans Node, en contrôlant en plus que chaque coup de l'IA tombe sur une case libre, et le calcul de minimax sur le plateau vide donne 0 quel que soit le premier joueur : bien joué des deux côtés, le morpion se termine toujours par un nul.

Cette vérification exhaustive est un luxe que permettent les neuf cases : depuis le plateau vide, les 549 945 appels comptés plus haut couvrent toutes les suites possibles. C'est ce qui fait du morpion un bon premier contact avec la récursivité : on peut tout vérifier.

Pour prolonger le projet

Quelques extensions, de la plus simple à la plus formatrice : surligner les trois cases gagnantes (faites renvoyer l'alignement par gagnant plutôt que le symbole) ; compter les victoires et les nuls sur plusieurs parties ; puis proposer un niveau « facile » où l'ordinateur joue parfois une case libre au hasard. Attention alors à la portée de la preuve : elle vaut pour la stratégie exacte publiée ici, qui donne toujours la même réponse à une position donnée, et c'est pour cela que l'exploration ne suit qu'un seul coup de l'ordinateur à chaque tour. Avec une part de hasard, cette exploration ne couvrirait plus qu'une fraction des parties possibles : pour les compter toutes, il faudrait explorer aussi chacun des coups que l'ordinateur peut choisir, ce que le code de cette page ne fait pas.

Si vous cherchez un autre projet du même format, sans adversaire mais avec un score, le quiz interactif en JavaScript reprend la même séparation entre données, affichage et clics.

Sources vérifiées le 7 octobre 2026