Créer un jeu Snake en JavaScript pas à pas (sans framework)

Lire un cours, c’est bien. Finir un projet qui tourne vraiment, c’est ce qui fait passer de « je comprends quand on m’explique » à « je sais le faire ». Snake est le premier jeu idéal : il tient dans un fichier, il n’a besoin d’aucune bibliothèque, et il oblige à comprendre trois mécanismes qu’on retrouve ensuite partout, la boucle de jeu, l’état stocké dans un tableau et la détection de collisions.

À la fin de cet article, vous aurez un jeu jouable au clavier, avec un score, une fin de partie et un bouton pour rejouer. Environ 90 lignes de JavaScript, zéro installation, zéro outil de build : un fichier .html ouvert dans le navigateur suffit.

Une précision de méthode : chaque comportement décrit ici a été exécuté dans Chrome 151 pendant la rédaction. Quand j’écris que la boucle tourne à 120,2 ms de moyenne ou que le serpent passe de 3 à 4 cases, ce sont des valeurs relevées dans le navigateur, pas des estimations. Vous pouvez refaire chaque essai de votre côté, les manipulations sont décrites au fil du texte.

Ce qu’il faut savoir avant de commencer

Il suffit de savoir écrire une page HTML et de connaître les bases de JavaScript : variables, fonctions, tableaux, conditions. Si le squelette d’une page vous échappe encore, faites d’abord un détour par les bases pour bien débuter en HTML, le reste sera beaucoup plus confortable.

Créez un fichier snake.html et collez ce squelette. Le <canvas> est une zone de dessin pilotable en JavaScript ; c’est notre plateau de jeu.

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="utf-8" />
    <title>Snake</title>
  </head>
  <body>
    <p id="score">Score : 0</p>
    <canvas id="jeu" width="400" height="400"></canvas>
    <button id="rejouer" hidden>Rejouer</button>

    <script>
      // tout notre code viendra ici
    </script>
  </body>
</html>

Les attributs width et height du canvas ne sont pas du décor : ils définissent la résolution réelle de la zone de dessin, indépendamment de la taille à laquelle le CSS l’affiche. Écrivez-les toujours dans le HTML, jamais uniquement en CSS, sinon le canvas garde sa taille par défaut de 300 par 150 pixels et votre jeu sort déformé.

Le <head> de cette page se limite au minimum vital, l’encodage et le titre, parce qu’un jeu local n’a besoin de rien de plus. Sur un vrai site, il en contient bien davantage, et c’est l’objet des balises à connaître pour le head.

Étape 1 : raisonner en cases, pas en pixels

La bonne idée qui simplifie tout le reste : le jeu ne pense jamais en pixels. Il pense en cases, sur une grille. Les pixels n’apparaissent qu’au moment du dessin, par une multiplication.

const canvas = document.getElementById("jeu");
const ctx = canvas.getContext("2d");

const CASE = 20; // taille d'une case, en pixels
const COLONNES = canvas.width / CASE; // 20
const LIGNES = canvas.height / CASE; // 20
yx0123456701234la case surlignée = { x: 3, y: 1 }au dessin : 3 × 20 = 60 px, 1 × 20 = 20 px
Le jeu raisonne en cases (grille réduite ici pour la lisibilité, 20 sur 20 dans le jeu) ; la multiplication par la taille d’une case ne sert qu’au dessin.

Avec un canvas de 400 par 400 pixels et des cases de 20, on obtient une grille de 20 colonnes sur 20 lignes, soit 400 cases au total. Une position ne sera jamais un pixel mais un couple de nombres entiers, par exemple { x: 3, y: 1 }. Fini les comparaisons de coordonnées flottantes : deux cases sont identiques quand leurs deux entiers sont égaux, point.

Étape 2 : le serpent est un simple tableau

Le serpent n’est pas un objet compliqué, c’est une liste de cases, la tête en premier. Trois segments alignés au démarrage, direction vers la droite :

let serpent = [
  { x: 10, y: 10 }, // la tête
  { x: 9, y: 10 },
  { x: 8, y: 10 },
];
let direction = { x: 1, y: 0 }; // 1 case vers la droite à chaque tour

La direction est elle aussi un couple de nombres, et c’est volontaire : avancer se résumera à une addition. Vers la droite { x: 1, y: 0 }, vers le haut { x: 0, y: -1 }, puisque l’axe vertical descend quand y augmente. Aucun if à quatre branches n’est nécessaire, ce qui évite une bonne partie des erreurs classiques.

Une convention pour la suite : les let de cette étape et des suivantes servent à présenter chaque variable à sa place. Dans le fichier final, elles sont toutes déclarées une seule fois, sur une ligne commune, et c’est la fonction nouvellePartie() de l’étape 8 qui leur donne leur valeur de départ. Si vous assemblez le jeu au fur et à mesure, gardez cela en tête pour ne pas déclarer deux fois la même variable.

Étape 3 : avancer, c’est ajouter une tête et retirer la queue

Voici l’astuce centrale du jeu, celle qui étonne toujours quand on la découvre : on ne déplace aucun segment. On ajoute une nouvelle tête devant, et on supprime le dernier segment derrière. Le serpent semble ramper alors qu’aucune case n’a bougé.

const tete = {
  x: serpent[0].x + direction.x,
  y: serpent[0].y + direction.y,
};

serpent.unshift(tete); // la nouvelle tête entre en début de tableau
serpent.pop(); // la queue quitte le tableau
Avant le tourqueuetêteil avancevers la droiteAprès le tourlongueurinchangée :3 casespop() retire la queueunshift() ajoute la tête
Un tour de jeu : une case entre devant, une case sort derrière, et la longueur du tableau ne change pas.

Mesure prise dans la console : en partant de [{10,10}, {9,10}, {8,10}], un tour donne [{11,10}, {10,10}, {9,10}]. La longueur reste à 3, la tête a avancé d’une case et la queue a disparu. Retenez bien ce couple unshift et pop : toute la croissance du serpent va se jouer sur une seule ligne, celle du pop.

Étape 4 : la boucle de jeu

Un jeu, c’est un état qu’on met à jour à intervalles réguliers, puis qu’on redessine. Ici, un tour de jeu toutes les 120 millisecondes :

let boucle = setInterval(tour, 120);

function tour() {
  // 1. calculer la nouvelle tête
  // 2. vérifier les collisions
  // 3. avancer (unshift + pop)
  // 4. redessiner
}

J’ai voulu vérifier ce que valait vraiment cette cadence. Sur 40 tours consécutifs mesurés avec performance.now(), l’écart moyen entre deux tours est de 120,2 ms, avec un minimum à 106,8 ms et un maximum à 126,1 ms. La dérive cumulée après 40 tours : 8,1 ms, soit moins d’un dixième de tour sur près de 5 secondes. Autrement dit, setInterval n’est pas parfaitement régulier, mais pour un jeu au tour par tour comme Snake, l’imprécision est invisible. C’est le bon outil ici, et on garde requestAnimationFrame pour les jeux qui animent à 60 images par seconde.

Le dessin, lui, tient en quelques lignes : on efface tout, on peint la pomme, puis chaque segment. Les couleurs s’écrivent en hexadécimal, exactement comme dans une feuille de style, et si cette écriture vous est encore obscure, elle est détaillée dans les notations de couleur en CSS.

function dessiner() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.fillStyle = "#c0392b";
  ctx.fillRect(pomme.x * CASE, pomme.y * CASE, CASE, CASE);

  ctx.fillStyle = "#27ae60";
  for (const c of serpent) {
    // le -1 laisse un liseré entre les segments
    ctx.fillRect(c.x * CASE, c.y * CASE, CASE - 1, CASE - 1);
  }
}

C’est là que la multiplication par CASE intervient, et nulle part ailleurs : le reste du programme ignore complètement l’existence des pixels.

Étape 5 : le clavier, et le piège du demi-tour

Quatre touches, quatre directions. Une table de correspondance évite la cascade de if. Ne vous arrêtez pas sur la dernière ligne, qui écrit dans une variable prochaineDirection encore inconnue : c’est justement le sujet du piège expliqué juste après.

document.addEventListener("keydown", (e) => {
  const touches = {
    ArrowUp: { x: 0, y: -1 },
    ArrowDown: { x: 0, y: 1 },
    ArrowLeft: { x: -1, y: 0 },
    ArrowRight: { x: 1, y: 0 },
  };
  const d = touches[e.key];
  if (!d) return;
  e.preventDefault(); // les flèches ne doivent pas faire défiler la page
  if (d.x === -direction.x && d.y === -direction.y) return; // demi-tour interdit
  prochaineDirection = d;
});

Deux détails méritent qu’on s’y arrête. Le premier, e.preventDefault() : sans lui, les flèches font défiler la page pendant qu’on joue. Vérifié dans le navigateur, avec l’appel en place le défilement reste à 0 pixel pendant toute la partie.

Le second est plus subtil, et c’est celui sur lequel je suis tombé le premier. Interdire le demi-tour est indispensable, sinon la tête entre immédiatement dans le deuxième segment et la partie s’arrête. Mais la vraie question est : où écrire la nouvelle direction ? La version naïve écrit directement dans direction. Je l’ai codée pour la mesurer, avec le serpent allant vers la droite et deux touches pressées dans le même tour, Haut puis Gauche. Résultat relevé : Haut est accepté, puis Gauche est comparé à une direction qui vaut déjà Haut, donc accepté lui aussi. La direction finale est Gauche, la tête part en {9,10} qui est occupée par le corps, et la partie est terminée sans que le joueur ait rien fait d’illégal.

Le correctif tient en une variable. La touche n’écrit pas dans direction mais dans une direction en attente, et c’est le tour de jeu qui l’applique :

let direction = { x: 1, y: 0 };
let prochaineDirection = direction;

function tour() {
  direction = prochaineDirection; // on applique une seule fois par tour
  // ...
}

Même scénario rejoué avec cette version : Gauche est comparé à la direction réellement en cours, la droite, donc refusé. La direction retenue est Haut, la tête part en {10,9}, aucune collision. Une variable, un bug de moins, et un contrôle qui devient enfin prévisible.

Étape 6 : la pomme et la croissance

Une pomme apparaît sur une case libre. La contrainte « case libre » est plus importante qu’elle n’en a l’air : une pomme qui apparaît sous le serpent serait injouable.

function nouvellePomme() {
  let p;
  do {
    p = {
      x: Math.floor(Math.random() * COLONNES),
      y: Math.floor(Math.random() * LIGNES),
    };
  } while (serpent.some((c) => c.x === p.x && c.y === p.y));
  return p;
}

La boucle do while retire au hasard tant que la case tombe sur le serpent, et la tentation est grande de la remplacer par un if, qui ne retire qu’une seule fois. J’ai chiffré les trois versions sur un serpent occupant 200 des 400 cases, soit la moitié du plateau, avec 100 000 tirages chacune. Sans aucun rattrapage, 49 755 pommes tombent sur le corps. Avec un if et un seul rattrapage, il en reste 25 112, soit une pomme sur quatre posée sous le serpent. Avec la boucle do while : zéro, et l’ensemble s’exécute en 76 millisecondes. Le tirage avec rejet ne coûte rien à cette échelle, et lui seul donne une garantie.

Reste la croissance, et c’est la ligne la plus élégante du jeu. On avait unshift puis pop à chaque tour. Manger, c’est simplement ne pas faire le pop. La variable score qui apparaît ici part de zéro à chaque partie, dans la fonction de l’étape 8 :

serpent.unshift(tete);

if (tete.x === pomme.x && tete.y === pomme.y) {
  score += 1;
  document.getElementById("score").textContent = "Score : " + score;
  pomme = nouvellePomme();
} else {
  serpent.pop(); // pas de pomme : la queue avance normalement
}

Mesure : serpent à 3 segments, pomme placée trois cases devant, trois tours de jeu. La tête arrive sur la pomme, le tableau passe à 4 segments, le score affiche « Score : 1 ». Le serpent n’a pas grandi par magie, il a juste gardé sa queue un tour de plus.

Étape 7 : perdre, et le faux positif qui rend fou

Deux façons de perdre : sortir du plateau, ou se mordre. Le mur se teste sur les bornes de la grille :

if (tete.x < 0 || tete.x >= COLONNES || tete.y < 0 || tete.y >= LIGNES) {
  return finDePartie();
}

Vérifié : avec la tête en colonne 19, la dernière, et la direction vers la droite, le tour suivant déclenche bien la fin de partie et aucun segment n’est dessiné hors du plateau. C’est le return qui produit cet effet : il coupe le tour avant l’ajout de la tête, donc la case fatale n’est jamais peinte et le serpent semble s’arrêter juste avant le mur.

La morsure paraît tout aussi simple, et c’est là que j’ai récolté mon deuxième bug. Écrite naturellement, la condition compare la future tête à tous les segments du serpent. J’ai testé un serpent enroulé sur lui-même, tête en {5,5}, queue en {6,5}, avançant vers la droite : la fin de partie se déclenche. Pourtant la case visée est celle que la queue quitte pendant ce tour précis. Le serpent meurt en entrant dans une case qui sera libre au moment où il y arrive.

Le correctif consiste à exclure la dernière case du test, puisqu’elle se libère au même instant :

// on ignore la dernière case : la queue la libère pendant ce tour
if (serpent.slice(0, -1).some((c) => c.x === tete.x && c.y === tete.y)) {
  return finDePartie();
}

Le même scénario rejoué avec le correctif : plus de fin de partie, la tête arrive en {6,5}, la longueur reste à 4. Et le correctif reste juste même quand le serpent est sur le point de manger, car une pomme n’apparaît jamais sur son corps : si la tête entre dans la case de la queue, c’est que cette case ne porte pas la pomme, donc le pop s’exécute et la queue la libère bien.

Étape 8 : tout remettre à zéro pour rejouer

Une fonction rassemble tout ce qui doit repartir de zéro, et le bouton se contente de l’appeler. C’est aussi elle qui lance la partie au chargement de la page :

function nouvellePartie() {
  serpent = [
    { x: 10, y: 10 },
    { x: 9, y: 10 },
    { x: 8, y: 10 },
  ];
  direction = { x: 1, y: 0 };
  prochaineDirection = direction;
  score = 0;
  pomme = nouvellePomme();
  document.getElementById("score").textContent = "Score : 0";
  document.getElementById("rejouer").hidden = true;
  clearInterval(boucle); // sinon deux boucles tournent en parallèle
  boucle = setInterval(tour, 120);
}

Le clearInterval avant le setInterval n’est pas décoratif. Sans lui, chaque clic sur « Rejouer » ajoute une boucle : le serpent avance deux fois, puis trois fois par tour, et le jeu devient injouable au bout de quelques parties. Contre-épreuve mesurée sur 600 millisecondes : avec le clearInterval, la fonction est appelée 5 fois, comme prévu ; avec deux boucles restées actives, 10 fois ; avec trois, 15 fois.

Le fichier complet

Voici l’ensemble, à coller dans snake.html et à ouvrir dans le navigateur. Aucun serveur n’est nécessaire, un double clic sur le fichier suffit, puisque tout se joue côté client comme l’explique le fonctionnement d’un site web.

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="utf-8" />
    <title>Snake</title>
    <style>
      body {
        font-family: system-ui, sans-serif;
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 16px;
        padding: 24px;
      }
      canvas {
        border: 2px solid #333;
        background: #fafafa;
      }
      #score {
        font-size: 20px;
        font-weight: bold;
      }
    </style>
  </head>
  <body>
    <p id="score">Score : 0</p>
    <canvas id="jeu" width="400" height="400"></canvas>
    <button id="rejouer" hidden>Rejouer</button>

    <script>
      const canvas = document.getElementById("jeu");
      const ctx = canvas.getContext("2d");

      const CASE = 20;
      const COLONNES = canvas.width / CASE;
      const LIGNES = canvas.height / CASE;

      let serpent, direction, prochaineDirection, pomme, score, boucle;

      function nouvellePartie() {
        serpent = [
          { x: 10, y: 10 },
          { x: 9, y: 10 },
          { x: 8, y: 10 },
        ];
        direction = { x: 1, y: 0 };
        prochaineDirection = direction;
        score = 0;
        pomme = nouvellePomme();
        document.getElementById("score").textContent = "Score : 0";
        document.getElementById("rejouer").hidden = true;
        clearInterval(boucle);
        boucle = setInterval(tour, 120);
      }

      function nouvellePomme() {
        let p;
        do {
          p = {
            x: Math.floor(Math.random() * COLONNES),
            y: Math.floor(Math.random() * LIGNES),
          };
        } while (serpent.some((c) => c.x === p.x && c.y === p.y));
        return p;
      }

      function tour() {
        direction = prochaineDirection;
        const tete = {
          x: serpent[0].x + direction.x,
          y: serpent[0].y + direction.y,
        };

        if (tete.x < 0 || tete.x >= COLONNES || tete.y < 0 || tete.y >= LIGNES) {
          return finDePartie();
        }
        // on ignore la dernière case : la queue la libère pendant ce tour
        if (serpent.slice(0, -1).some((c) => c.x === tete.x && c.y === tete.y)) {
          return finDePartie();
        }

        serpent.unshift(tete);

        if (tete.x === pomme.x && tete.y === pomme.y) {
          score += 1;
          document.getElementById("score").textContent = "Score : " + score;
          pomme = nouvellePomme();
        } else {
          serpent.pop();
        }

        dessiner();
      }

      function dessiner() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        ctx.fillStyle = "#c0392b";
        ctx.fillRect(pomme.x * CASE, pomme.y * CASE, CASE, CASE);

        ctx.fillStyle = "#27ae60";
        for (const c of serpent) {
          ctx.fillRect(c.x * CASE, c.y * CASE, CASE - 1, CASE - 1);
        }
      }

      function finDePartie() {
        clearInterval(boucle);
        document.getElementById("rejouer").hidden = false;
        ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.fillStyle = "#fff";
        ctx.font = "28px sans-serif";
        ctx.textAlign = "center";
        ctx.fillText("Perdu ! Score : " + score, canvas.width / 2, canvas.height / 2);
      }

      document.addEventListener("keydown", (e) => {
        const touches = {
          ArrowUp: { x: 0, y: -1 },
          ArrowDown: { x: 0, y: 1 },
          ArrowLeft: { x: -1, y: 0 },
          ArrowRight: { x: 1, y: 0 },
        };
        const d = touches[e.key];
        if (!d) return;
        e.preventDefault();
        if (d.x === -direction.x && d.y === -direction.y) return;
        prochaineDirection = d;
      });

      document.getElementById("rejouer").addEventListener("click", nouvellePartie);

      nouvellePartie();
    </script>
  </body>
</html>

Test final sur cette version, boucle volontairement ralentie à 700 ms le temps de suivre le déplacement à l’œil : partie lancée, flèche du bas pressée au clavier, le serpent tourne et descend de deux cases, le corps suit la tête case par case et la page ne défile pas. À la cadence de 120 ms du fichier ci-dessus, ces deux mêmes cases passent en un peu plus de deux dixièmes de seconde. Le mur, la morsure, la pomme et le bouton « Rejouer » se comportent comme décrit plus haut.

Un détail qui surprend au premier lancement : la partie démarre immédiatement, serpent vers la droite. Si vous ne touchez pas au clavier, il atteint le mur en une dizaine de tours, soit un peu plus d’une seconde, et affiche « Perdu ! » avant que vous ayez joué. Ce n’est pas un bug, c’est le comportement voulu ; si l’effet vous gêne, faites partir le serpent plus à gauche ou attendez une première touche avant de lancer la boucle.

Ce que la mesure m’a appris

Trois enseignements de cette session valent bien plus que le jeu lui-même. D’abord, les deux bugs ont été trouvés en exécutant du code, pas en le relisant : ni le demi-tour en deux touches ni la case de la queue ne sautent aux yeux à la lecture, et les deux passeraient une relecture attentive.

Ensuite, les deux venaient du même angle mort : le décalage entre l’instant où une décision est prise et celui où elle s’applique. La touche est pressée entre deux tours, la queue se libère pendant le tour. C’est exactement ce qui rend les boucles de jeu contre-intuitives, et c’est une intuition qui resservira bien au-delà de Snake.

Enfin, mesurer coûte peu : le banc qui compare les trois façons de tirer la pomme tient en une vingtaine de lignes et s’exécute en quelques dizaines de millisecondes, et il remplace définitivement un « ça devrait marcher » par une certitude.

Pour aller plus loin

Le jeu est complet, mais il reste beaucoup à en tirer. Quatre extensions par difficulté croissante, à faire dans l’ordre :

La dernière est de loin la plus formatrice : elle oblige à séparer « ce qui change la direction » de « ce qui lit le clavier », c’est-à-dire à découpler l’entrée du reste du jeu. Une fois ce réflexe pris, ajouter une manette ou une intelligence artificielle qui joue toute seule ne demande plus que quelques lignes.