Aller au contenu
SCRIPTS CLIENTS 25-26

Snake

Projet Snake 🐍 — Crée ton jeu en JavaScript

Tu vas programmer le jeu du serpent : un serpent qui se déplace sur une grille, mange des fruits, grandit… et meurt s'il touche un mur ou sa propre queue.

C'est un vrai petit jeu vidéo, et tu vas le construire entièrement toi-même. En chemin, tu vas pratiquer :

  • le dessin dans un <canvas>
  • une boucle de jeu avec setInterval
  • les événements clavier
  • les tableaux d'objets (la structure de données du serpent)
  • la logique de collision
  • la manipulation du DOM pour l'interface (score, écrans)

Temps estimé : 4 à 8 heures, selon jusqu'où tu pousses les bonus.

Comment travailler

L'énoncé est découpé en étapes. Chaque étape te donne un objectif, des explications sur les concepts nécessaires, et parfois un coup de pouce. On ne te donne pas le code : à toi de le trouver. Mais si tu suis les étapes dans l'ordre, chacune est un petit pas faisable.

Règle d'or : ne passe pas à l'étape suivante tant que l'actuelle ne fonctionne pas. Teste après chaque modification, et utilise console.log dès que tu ne comprends pas ce qui se passe.

Le point de départ

Crée un projet Vite (vanilla JS) et remplace le contenu de main.js par ceci :

import "./style.css";

const app = document.getElementById("app");

app.innerHTML = `
  <h1>Le jeu du serpent</h1>
  <canvas id="snake" width="400" height="400"></canvas>
`;

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

const tailleCase = 20;                        // 1 case = 20 px
const nbColonnes = canvas.width / tailleCase; // 20 colonnes
const nbLignes = canvas.height / tailleCase;  // 20 lignes

const directions = {
  ArrowLeft:  { dx: -1, dy: 0 },
  ArrowUp:    { dx: 0, dy: -1 },
  ArrowRight: { dx: 1, dy: 0 },
  ArrowDown:  { dx: 0, dy: 1 },
};

// Pour l'instant, pas encore de serpent :
// juste une case verte qui se déplace.
let position = { x: 5, y: 5 };
let direction = { dx: 1, dy: 0 };

setInterval(() => {
  // 1. on déplace : la position avance d'une case
  position.x += direction.dx;
  position.y += direction.dy;

  // 2. on efface tout le canvas
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 3. on dessine la case à sa nouvelle position
  ctx.fillStyle = "green";
  ctx.fillRect(
    position.x * tailleCase,
    position.y * tailleCase,
    tailleCase,
    tailleCase
  );
}, 200);

document.addEventListener("keydown", (event) => {
  if (directions[event.code]) {
    direction = directions[event.code];
  }
});

Lance le projet : une case verte glisse vers la droite, et tu peux changer sa direction avec les flèches. Elle sort du canvas et disparaît ? C'est normal, on s'en occupera à l'étape des collisions.

⚠️ Remarque l'ordre dans la boucle : on déplace d'abord, on dessine ensuite. C'est la règle d'or de toute boucle de jeu : d'abord mettre à jour l'état du jeu (la simulation), puis l'afficher (le rendu). L'écran montre ainsi toujours l'état réel du jeu — et plus tard, tu vérifieras les collisions juste après le déplacement, avant de dessiner.

Concept clé n° 1 : la grille

Le canvas fait 400 × 400 pixels, mais on ne raisonne jamais en pixels. On découpe le canvas en une grille de 20 × 20 cases de 20 px chacune. Toute la logique du jeu (positions, déplacements, collisions) se fait en coordonnées de cases : { x: 5, y: 3 } veut dire 6ᵉ colonne, 4ᵉ ligne (on compte à partir de 0).

La conversion ne se fait qu'au moment de dessiner : la case { x: 5, y: 3 } se dessine au pixel (5 × 20, 3 × 20), donc (100, 60).

Pourquoi ce choix ? Parce que comparer des cases est trivial : le serpent est sur le fruit si tete.x === fruit.x && tete.y === fruit.y. Essaie de faire ça en pixels et tu vas pleurer.

Vérifie que tu as compris (réponds avant de continuer) :

  1. Pourquoi clearRect à chaque tour de boucle ? Commente la ligne et observe.
  2. À quel pixel se dessine la case { x: 0, y: 19 } ? Où est-elle à l'écran ?
  3. Que représente exactement { dx: 0, dy: -1 } ? Pourquoi -1 pour monter ?

Étape 1 — Interdire le demi-tour

Objectif : quand on va vers la droite, appuyer sur ← ne doit rien faire.

Pour l'instant, toutes les flèches sont acceptées. Avec une simple case, peu importe. Mais quand le serpent aura un corps, faire demi-tour signifiera rentrer dans sa propre nuque : mort instantanée et injuste. Tous les vrais Snake interdisent le demi-tour.

💡 Coup de pouce. Deux directions sont opposées quand leurs vecteurs s'annulent. Compare la direction demandée à la direction actuelle : si la somme des dx vaut 0 et la somme des dy vaut 0, c'est un demi-tour → on ignore la touche. Vérifie sur papier que ça marche pour ↑/↓ et que ça n'interdit pas ↑ quand on va à droite.

Étape 2 — Une case devient un serpent

Objectif : remplacer la case unique par un serpent de plusieurs cases qui avance.

Concept clé n° 2 : le serpent est un tableau

Le serpent, c'est un tableau d'objets {x, y}, un par case du corps :

let snake = [
  { x: 7, y: 4 },  // snake[0] = la tête
  { x: 6, y: 4 },
  { x: 5, y: 4 },  // le dernier élément = la queue
];

Et voilà l'idée géniale qui rend Snake si simple à programmer : pour faire avancer le serpent, on ne déplace aucune case. On en ajoute une et on en retire une :

  1. on calcule la position de la nouvelle tête (l'ancienne tête + la direction) ;
  2. on l'ajoute au début du tableau avec unshift() ;
  3. on retire le dernier élément — la queue — avec pop().

Le corps n'a pas bougé, mais visuellement le serpent a avancé d'une case. Prends 2 minutes pour le vérifier sur le schéma ci-dessous : c'est LE déclic de cet exercice.

💡 Coup de pouce. La nouvelle tête doit être un nouvel objet { x: ..., y: ... }, construit à partir de snake[0] — ne modifie surtout pas l'objet de l'ancienne tête. Pour le dessin : une boucle for sur le tableau, et chaque segment se dessine exactement comme la case verte du départ.

Validation : un serpent de 3 cases avance et tourne avec les flèches (sans demi-tour possible). Il sort toujours du canvas, c'est toujours normal.

Étape 3 — Le fruit et le score

Objectif : un fruit rouge apparaît à une position aléatoire. Quand la tête l'atteint, le serpent grandit d'une case, le score augmente, un nouveau fruit apparaît.

a) Générer un fruit

Le fruit est un simple objet {x, y}. Pour une coordonnée aléatoire valide :

const x = Math.floor(Math.random() * nbColonnes); // un entier entre 0 et 19

Math.random() renvoie un décimal entre 0 (inclus) et 1 (exclu). Multiplié par 20, ça donne un décimal entre 0 et 19,999… et Math.floor() arrondit vers le bas : un entier entre 0 et 19, jamais 20. Exactement ce qu'il nous faut.

⚠️ Un fruit ne doit jamais apparaître sur le serpent. Il te faut donc une fonction qui vérifie si une case (x, y) est occupée par un segment du serpent — tire une nouvelle position tant que c'est le cas (regarde la boucle do...while). Cette fonction te resservira plus tard, ce n'est pas du travail perdu.

🚀 Au passage, les performances. Pour parcourir le serpent, écris une boucle for classique qui fait return true dès qu'un segment correspond. C'est plus rapide que les méthodes de tableau comme forEach ou filter, qui appellent une fonction pour chaque élément et parcourent toujours le tableau en entier. Cette vérification tournera très souvent : autant prendre le bon réflexe.

b) Manger le fruit

Après avoir calculé la nouvelle tête, compare-la à la position du fruit.

💡 Coup de pouce. Grandir ne demande aucun code en plus, seulement du code en moins : si le serpent vient de manger, on ajoute la nouvelle tête mais on ne retire pas la queue. Le tableau gagne un élément, le serpent a grandi. Relis le bas du schéma 4 si ce n'est pas limpide.

c) Afficher le score

Le canvas dessine le jeu, le HTML affiche l'interface. N'écris pas le score dans le canvas : ajoute un élément dans le HTML (par exemple <div>Score : <span id="score"></span></div>) et mets-le à jour avec textContent à chaque fruit mangé.

Validation : tu peux manger plusieurs fruits d'affilée, le serpent grandit, le score s'affiche, et aucun fruit n'apparaît jamais sur le serpent (mange beaucoup pour tester).

Étape 4 — Game over

Objectif : la partie se termine quand la tête touche un mur ou le corps, et un écran de fin affiche le score avec un bouton « Rejouer ».

a) Détecter la mort

Deux cas, à tester après chaque déplacement, sur la tête uniquement :

  • les murs : la tête est sortie de la grille (réfléchis aux 4 bords — quelles valeurs de x et y sont encore valables ?) ;
  • la queue : la tête a les mêmes coordonnées qu'un autre segment du corps.

💡 Coup de pouce. Pour la queue : parcours le tableau à partir de l'indice 1. Pourquoi pas l'indice 0 ?

b) Arrêter la boucle

Détail crucial que personne ne connaît la première fois : setInterval renvoie un identifiant. Garde-le dans une variable, car c'est lui qu'on passe à clearInterval(id) pour arrêter la boucle. Sans ça, ton jeu continue de tourner en arrière-plan pour l'éternité. 👻

c) L'écran de fin

À la mort : clearInterval, puis remplace le contenu de app (avec innerHTML, comme au démarrage) par un écran de fin : message, score, bouton « Rejouer ».

💡 Coup de pouce. Pour rejouer, regroupe toute l'initialisation d'une partie dans une fonction newGame() : remise à zéro du serpent, du score, de la direction, du HTML, et démarrage de l'interval. Appelle-la au chargement et au clic sur « Rejouer ».

⚠️ Piège classique : tu rejoues et le serpent a gardé sa taille ou sa direction d'avant ? C'est qu'une variable a échappé à ta remise à zéro.

⚠️ En remplaçant app.innerHTML, tu détruis l'ancien canvas et ses éléments. Les éléments recréés sont neufs : il faut re-sélectionner le canvas et ré-attacher les écouteurs de clic après chaque changement d'écran. (L'écouteur keydown sur document, lui, survit — pourquoi ?)

Validation : mourir contre un mur et contre sa queue affiche l'écran de fin ; « Rejouer » relance une partie propre, plusieurs fois de suite.

Étape 5 — L'écran d'accueil

Objectif : le jeu démarre sur un écran d'accueil avec un bouton « Nouvelle partie », au lieu de démarrer directement.

Tu as déjà tout ce qu'il faut : c'est le même principe que l'écran de fin. Pour garder un code lisible, écris une fonction par écran qui renvoie son HTML (un template string), par exemple home(), game(), end(score).

À ce stade, ton jeu est complet : accueil → partie → game over → rejouer. Bravo. 🎉 Soigne un peu le CSS, puis attaque les bonus.

Bonus (dans l'ordre de difficulté)

Bonus 1 — La pause

La touche Escape (ou un bouton) met le jeu en pause et le reprend.

💡 Pas besoin d'arrêter l'interval : un booléen isPaused, et au début de la boucle de jeu, si on est en pause… on ne fait rien (return). Affiche par-dessus le jeu un panneau HTML semi-transparent (position: absolute + un fond rgba) avec un bouton « Reprendre ».

Bonus 2 — Les fruits rares

Tous les fruits ne se valent pas :

Type Probabilité Points Couleur
normal 70 % +1 rouge
or 25 % +3 or
diamant 5 % +7 cyan

Ajoute une propriété type au fruit, qui détermine sa couleur et ses points.

💡 Un seul Math.random() suffit : si le tirage est sous 0.70 → normal, sous 0.95 → or, sinon → diamant.

Bonus 3 — La vitesse augmente

Tous les 5 fruits, le jeu accélère. Attention : on ne peut pas modifier le délai d'un interval existant… mais on peut l'arrêter et en relancer un autre.

Bonus 4 (costaud) — Le bug des doigts rapides

Ton anti-demi-tour a une faille. Tu vas à droite, et entre deux ticks tu appuies très vite sur ↑ puis ←. Chaque touche, prise isolément, est valide… mais le serpent n'a avancé qu'une fois. Résultat : il part à gauche, plein demi-tour, mort. 💀

La solution pro : au lieu d'écraser direction à chaque touche, mets les touches dans une file d'attente, et la boucle de jeu en consomme une seule par tick. Attention à valider chaque nouvelle direction par rapport à la dernière de la file, pas la direction courante !

Pour aller plus loin : requestAnimationFrame

Notre version utilise setInterval : simple et parfait pour apprendre, mais le serpent saute de case en case. Les vrais jeux utilisent requestAnimationFrame, qui se synchronise avec l'écran (60+ images/seconde) et permet des animations fluides : un serpent qui glisse entre les cases. C'est un beau défi pour plus tard — cherche « game loop requestAnimationFrame » si tu veux transformer ton Snake en jeu pro.

Ton plan de bataille

Étape Tu sauras que ça marche quand…
Point de départ la case verte glisse et obéit aux flèches
Étape 1 le demi-tour est ignoré
Étape 2 un serpent de 3 cases avance et tourne
Étape 3 il mange, grandit, le score monte
Étape 4 mur ou queue → écran de fin, « Rejouer » fonctionne
Étape 5 accueil → jeu → fin → rejouer
Bonus pause, fruits rares, vitesse, file de touches

Derniers conseils

  • Une fonction = une responsabilité : déplacer, dessiner, générer un fruit, détecter les collisions… Si une fonction fait deux choses, coupe-la en deux.
  • console.log est ton ami. Le serpent fait n'importe quoi ? Affiche le tableau snake à chaque tick et regarde ce qui s'y passe vraiment.
  • Commence lent. 200 ms par tick, c'est bien pour développer. Tu accéléreras quand tout sera stable.
  • Bloqué plus de 30 minutes sur le même problème ? Cherche sur Google ou sur MDN, demande à une IA de t'expliquer ce qui coince (pas de coder à ta place — c'est toi qui apprends), pose ta question sur Teams, ou note-la pour le prochain cours.

Documentation utile

  • Tutoriel canvas (MDN) — dessiner des formes, fillRect, clearRect
  • setInterval et clearInterval (MDN) — la boucle de jeu
  • KeyboardEvent.code (MDN) — les codes des touches du clavier
  • Les tableaux et leurs méthodes (MDN) — unshift, pop, et toutes les autres
  • Math.random() (MDN) — l'aléatoire
  • Les template strings (MDN) — pour écrire le HTML de tes écrans
  • textContent (MDN) — mettre à jour le score

Bon développement, et amuse-toi : c'est un jeu, après tout. 🍎

Énoncé rédigé en collaboration avec Claude Fable 5.