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) :
- Pourquoi
clearRectà chaque tour de boucle ? Commente la ligne et observe. - À quel pixel se dessine la case
{ x: 0, y: 19 }? Où est-elle à l'écran ? - Que représente exactement
{ dx: 0, dy: -1 }? Pourquoi-1pour 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
dxvaut 0 et la somme desdyvaut 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 :
- on calcule la position de la nouvelle tête (l'ancienne tête + la direction) ;
- on l'ajoute au début du tableau avec
unshift(); - 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 desnake[0]— ne modifie surtout pas l'objet de l'ancienne tête. Pour le dessin : une boucleforsur 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
forclassique qui faitreturn truedès qu'un segment correspond. C'est plus rapide que les méthodes de tableau commeforEachoufilter, 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
xetysont 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 fondrgba) 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.logest ton ami. Le serpent fait n'importe quoi ? Affiche le tableausnakeà 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 setIntervaletclearInterval(MDN) — la boucle de jeuKeyboardEvent.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.
