Leçon 5/13 13 min

Projet 5 : un petit jeu de réflexe avec l'IA

TD corrigé : un jeu de temps de réaction construit avec l'IA. Les vrais prompts, et la relecture qui empêche de tricher (annuler le timer), gère les cas limites du score et rend le jeu jouable au clavier.

Le projet : clique dès que c'est vert

Cinquième projet, on attaque la logique et l'état : un jeu de temps de réaction. La zone est rouge (« Attends… »), puis devient verte après un délai aléatoire. Tu cliques le plus vite possible, on mesure tes millisecondes, et on garde ton record.

Un jeu, c'est une machine à états : au repos, en attente, c'est parti, résultat. L'IA code le scénario idéal, mais oublie que le joueur va essayer de tricher, et que la première partie n'a pas de record à battre. On code, puis on relit.

Ce que tu vas savoir faire
  • Modéliser une machine à états explicite (idle / wait / go / result) pour piloter le comportement d'un jeu sans que les états se mélangent.
  • Garder l'identifiant d'un timer et l'annuler avec clearTimeout pour éviter une race condition : le vert ne surgit plus après un faux départ.
  • Gérer le cas « première partie » proprement : best = null et le test best == null || ms < best évitent tout NaN ou comparaison absurde.
Tu as réussi quand…
  • cliquer pendant le rouge affiche « Trop tôt ! » et aucun vert ne surgit ensuite ;
  • la toute première partie affiche le score proprement, sans « NaN » ni tiret erroné ;
  • le jeu est entièrement jouable au clavier (Tab pour atteindre la zone, Espace pour cliquer).

Prompt 1 : poser le cadre

On décrit la règle du jeu à l'IA aussi clairement qu'on l'expliquerait à un ami : une zone qui devient verte après un délai aléatoire, un clic qui mesure le temps de réaction, et le meilleur score gardé en mémoire.

Crée une mini-app web autonome, un seul fichier HTML (zéro dépendance). Un jeu de réflexe : une zone qui devient verte après un délai aléatoire ; le joueur clique et on affiche son temps de réaction en millisecondes. Garde le meilleur score dans localStorage. Bilingue FR/EN. Code simple et lisible.

L'IA livre une version jouable. Le cœur :

function start() {
  zone.style.background = 'red';
  setTimeout(function () {                 // ⚠️ l'id n'est pas gardé
    zone.style.background = 'green';
    startTime = Date.now();
  }, 1000 + Math.random() * 2000);
}
zone.onclick = function () {
  var ms = Date.now() - startTime;         // ⚠️ et si on clique pendant le rouge ?
  result.textContent = ms + ' ms';
  if (ms < best) best = ms;                // ⚠️ best vaut quoi à la 1re partie ?
};

Ça marche si on joue « bien ». Mais clique pendant le rouge : tu obtiens un temps négatif délirant, et le « vert » surgit quand même une seconde plus tard. Et au tout premier essai, best n'existe pas.

Prédis avant de lire la suite

Relis ce code que l'IA a produit pour lancer le passage au vert, sans descendre plus bas :

function start() {
  zone.style.background = 'red';
  setTimeout(function () {
    zone.style.background = 'green';
    startTime = Date.now();
  }, 1000 + Math.random() * 2000);
}

À ton avis, que se passe-t-il si le joueur clique AVANT le vert, puis attend ? Le timer est-il toujours là ?

Vérifier ma prédiction

Sans clearTimeout, le setTimeout lancé dans start() continue de tourner en arrière-plan : après le faux départ, la zone passe quand même au vert quelques secondes plus tard. Le chrono démarre alors sans que le joueur l'ait voulu, et la mesure est faussée. C'est une race condition : deux événements se courent après. Pour l'éviter, il faut garder l'identifiant du timer (var timer = setTimeout(…)) et l'annuler au clic anticipé (clearTimeout(timer)).

Ma relecture humaine : 3 trucs que l'IA a laissés passer

La version de l'IA est jouable… tant que le joueur joue le jeu gentiment. Sauf qu'un joueur, par nature, ça teste les limites : ça clique avant le vert pour voir, ça arrive pour la toute première fois sans record à battre. Trois points à reprendre, et le premier est une vraie petite leçon d'informatique.

1. Le joueur va cliquer trop tôt (et il faut annuler le timer)

Si on clique pendant l'attente, deux choses ratent : on calcule un temps absurde, et surtout le setTimeout programmé continue et fera passer la zone au vert plus tard, en plein milieu. Il faut garder l'id du timer et l'annuler (clearTimeout) dès le clic anticipé. C'est une petite race condition : deux événements qui se courent après.

2. Les cas limites du score

À la première partie, il n'y a pas de record : comparer ms < best avec un best indéfini donne n'importe quoi. On initialise best = null et on teste best == null || ms < best. Et comme au projet 3, on relit le record depuis localStorage avec prudence (un parseInt qui peut échouer).

3. Un jeu se joue aussi au clavier

La zone de jeu est un <button> : on peut lancer et cliquer avec Espace ou Entrée, et le focus est visible. La zone est en aria-live pour annoncer les changements, et les animations respectent prefers-reduced-motion.

Prompt 2 : durcir après relecture

Trois corrections à demander, dans l'ordre où on les a trouvées :

Corrige : (1) garde l'id du setTimeout et annule-le avec clearTimeout si le joueur clique pendant le rouge (affiche "trop tôt"). (2) Initialise best à null et teste best == null || ms < best ; relis le record depuis localStorage prudemment. (3) Fais de la zone un <button> (clavier), mets-la en aria-live et respecte prefers-reduced-motion.
var timer = null, state = 'idle', best = null;

function startRound() {
  state = 'wait';
  timer = setTimeout(function () {        // on garde l'id
    timer = null; state = 'go';
    startTime = Date.now();
  }, 1000 + Math.random() * 2500);
}

function onClick() {
  if (state === 'wait') {                 // clic trop tôt
    if (timer) { clearTimeout(timer); timer = null; }   // on annule le vert programmé
    state = 'early';
  } else if (state === 'go') {
    var ms = Date.now() - startTime;
    if (best == null || ms < best) { best = ms; /* … save … */ }   // 1re partie gérée
  }
}

Nouveau réflexe pour ta panoplie : tout timer programmé doit pouvoir être annulé. Un setTimeout ou un setInterval qu'on oublie de nettoyer, c'est la source classique de bugs « fantômes » qui se déclenchent au mauvais moment.

Héberger et tester

  • Fichier statique, en ligne d'un dépôt.
  • Joue normalement, puis clique pendant le rouge : tu dois voir « Trop tôt ! » et aucun vert ne doit surgir après.
  • Première visite (ou localStorage vidé) : le record doit s'afficher proprement, pas « NaN ».
  • Joue au clavier (Tab pour atteindre la zone, Espace pour cliquer). Bascule FR / EN en pleine partie : ça doit repartir proprement.

Le rendu final

Ouvrir le projet en plein écran

Le code complet (et téléchargeable)

Le fichier entier, exactement celui qui tourne au-dessus.

Télécharger le code (.html · 218 lignes)

Voir le code complet
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jeu de réflexe</title>
<meta name="description" content="Clique dès que c'est vert : teste ton temps de réaction. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  JEU DE RÉFLEXE — un seul fichier : HTML + CSS + JS, zéro dépendance.
    1. <style>  : l'apparence (la grande zone qui change de couleur, les scores).
    2. <body>   : la structure (la zone de jeu + les deux scores).
    3. <script> : la logique du jeu, organisée comme une "machine à états".
  Le jeu passe par 4 états : idle (repos) -> wait (rouge) -> go (vert) -> result.
  Point clé : si on clique pendant le rouge, il FAUT annuler le minuteur prévu,
  sinon le "vert" surgirait plus tard, par-dessus. C'est une petite race condition.
  ============================================================================
-->

<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
  * { box-sizing:border-box; }
  html,body { margin:0; padding:0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:540px; }
  header { text-align:center; margin-bottom:18px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
  .lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  /* La zone de jeu est un <button> géant. Sa couleur dépend de l'état (classe). */
  .stage {
    width:100%; min-height:260px; border:0; border-radius:18px; cursor:pointer; color:#fff;
    display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; text-align:center;
    font-family:inherit; box-shadow:0 16px 38px rgba(15,25,35,0.16); transition:background 0.15s ease; padding:24px;
  }
  .stage:focus-visible { outline:4px solid rgba(38,125,66,0.6); outline-offset:3px; }
  .stage .big { font-size:1.5rem; font-weight:800; }
  .stage .small { font-size:0.95rem; opacity:0.92; }
  .stage.idle  { background:linear-gradient(135deg,#102a43,#334e68); }   /* repos : bleu */
  .stage.wait  { background:linear-gradient(135deg,#7d2d2d,#a8341f); }   /* attends : rouge */
  .stage.go    { background:linear-gradient(135deg,#0b6b3a,#1fbf6b); }   /* clique ! : vert */
  .stage.early { background:linear-gradient(135deg,#5c1a4b,#a23b86); }   /* trop tôt : violet */
  .stage.result{ background:linear-gradient(135deg,#0f1923,#267d42); }   /* résultat */
  .stage .time { font-size:2.6rem; font-weight:800; letter-spacing:-0.02em; }

  .scores { display:flex; justify-content:center; gap:28px; margin-top:18px; }
  .score { text-align:center; }
  .score-label { font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted); }
  .score-val { font-size:1.3rem; font-weight:800; color:var(--accent); font-variant-numeric:tabular-nums; }
  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
  .credit a { color:var(--accent); }
  @media (prefers-reduced-motion: reduce){ .stage{transition:none;} }
</style>
</head>
<body>

<main class="app">
  <header>
    <h1 data-i18n="title">Jeu de réflexe</h1>
    <p class="sub" data-i18n="sub">Clique dès que la zone devient verte. Pas avant !</p>
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <!-- La zone de jeu est un vrai <button> : jouable à la souris ET au clavier (Entrée/Espace). -->
  <button class="stage idle" id="stage" type="button" aria-live="polite">
    <span class="big" id="big">Commencer</span>
    <span class="small" id="small">Clique pour lancer</span>
  </button>

  <div class="scores">
    <div class="score"><div class="score-label" data-i18n="last">Dernier</div><div class="score-val" id="last">—</div></div>
    <div class="score"><div class="score-label" data-i18n="best">Record</div><div class="score-val" id="bestval">—</div></div>
  </div>

  <p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>

<script>
(function () {
  'use strict';

  /* ---------------------------------------------------------------------------
     1. LES TEXTES (FR / EN) — un libellé par état du jeu.
     --------------------------------------------------------------------------- */
  var UI = {
    fr: { title:"Jeu de réflexe", sub:"Clique dès que la zone devient verte. Pas avant !", last:"Dernier", best:"Record",
      credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
      start:"Commencer", startSub:"Clique pour lancer",
      waitBig:"Attends…", waitSub:"Reste prêt, ne clique pas encore",
      goBig:"MAINTENANT !", goSub:"Clique !",
      earlyBig:"Trop tôt !", earlySub:"Tu as cliqué avant le vert. Clique pour réessayer.",
      resultSub:"Clique pour rejouer", ms:"ms" },
    en: { title:"Reaction game", sub:"Click as soon as the box turns green. Not before!", last:"Last", best:"Best",
      credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
      start:"Start", startSub:"Click to begin",
      waitBig:"Wait…", waitSub:"Stay ready, don't click yet",
      goBig:"NOW!", goSub:"Click!",
      earlyBig:"Too soon!", earlySub:"You clicked before green. Click to try again.",
      resultSub:"Click to play again", ms:"ms" }
  };

  /* ---------------------------------------------------------------------------
     2. LES ÉLÉMENTS + L'ÉTAT DU JEU.
     --------------------------------------------------------------------------- */
  var stage = document.getElementById('stage');
  var bigEl = document.getElementById('big');
  var smallEl = document.getElementById('small');
  var lastEl = document.getElementById('last');
  var bestEl = document.getElementById('bestval');

  var lang = 'fr';
  try { lang = localStorage.getItem('reaction-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';

  var state = 'idle';   // l'état courant : idle | wait | go | early | result
  var timer = null;     // l'identifiant du setTimeout en cours (pour pouvoir l'annuler)
  var startTime = 0;    // l'instant où la zone est devenue verte
  var best = null;      // le meilleur temps (null tant qu'on n'a pas encore joué)
  // On relit le record en mémoire. parseInt peut échouer -> on vérifie avec isNaN.
  try { var b = parseInt(localStorage.getItem('reaction-best'), 10); if (!isNaN(b)) best = b; } catch (e) {}

  /* ---------------------------------------------------------------------------
     3. L'AFFICHAGE — changer la couleur et les textes de la zone, afficher le record.
     --------------------------------------------------------------------------- */
  function setStage(cls, big, small) {
    stage.className = 'stage ' + cls;   // la classe pilote la couleur (voir le CSS)
    bigEl.textContent = big;
    smallEl.textContent = small;
  }
  function showScores() {
    bestEl.textContent = (best == null) ? '—' : best + ' ' + UI[lang].ms;
  }
  function toIdle() { state = 'idle'; setStage('idle', UI[lang].start, UI[lang].startSub); }

  /* ---------------------------------------------------------------------------
     4. LA LOGIQUE DU JEU — une manche, et la réaction à chaque clic.
     --------------------------------------------------------------------------- */

  // Lance une manche : on passe au rouge, puis au vert après un délai aléatoire.
  function startRound() {
    state = 'wait';
    setStage('wait', UI[lang].waitBig, UI[lang].waitSub);
    var delay = 1000 + Math.random() * 2500;   // entre 1 et 3,5 s, pour qu'on ne puisse pas anticiper
    // On GARDE l'id du minuteur : c'est lui qu'on annulera si le joueur clique trop tôt.
    timer = setTimeout(function () {
      timer = null;
      state = 'go';
      startTime = Date.now();   // top chrono
      setStage('go', UI[lang].goBig, UI[lang].goSub);
    }, delay);
  }

  // Réagit au clic selon l'état courant (c'est le cœur de la "machine à états").
  function onClick() {
    if (state === 'idle' || state === 'result' || state === 'early') {
      startRound();                      // au repos / après une partie : on (re)lance
    } else if (state === 'wait') {
      // Clic anticipé : on ANNULE le minuteur, sinon le "vert" prévu surgirait plus tard par-dessus.
      if (timer) { clearTimeout(timer); timer = null; }
      state = 'early';
      setStage('early', UI[lang].earlyBig, UI[lang].earlySub);
    } else if (state === 'go') {
      var ms = Date.now() - startTime;   // temps de réaction en millisecondes
      lastEl.textContent = ms + ' ' + UI[lang].ms;
      // 1re partie : best vaut null. Le test "best == null || ms < best" gère ce cas.
      if (best == null || ms < best) {
        best = ms;
        try { localStorage.setItem('reaction-best', String(best)); } catch (e) {}
      }
      showScores();
      state = 'result';
      setStage('result', ms + ' ' + UI[lang].ms, UI[lang].resultSub);
    }
  }

  stage.addEventListener('click', onClick);

  /* ---------------------------------------------------------------------------
     5. LA LANGUE + LE DÉMARRAGE.
     --------------------------------------------------------------------------- */
  function setLang(next) {
    lang = next;
    try { localStorage.setItem('reaction-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var k = el.getAttribute('data-i18n'); if (!t[k]) return;
      if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
    });
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });
    // Changer de langue annule une manche en cours, pour repartir d'un état propre.
    if (timer) { clearTimeout(timer); timer = null; }
    showScores();
    toIdle();
  }

  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);
})();
</script>
</body>
</html>

À toi de jouer

La meilleure façon de retenir tout ça, c'est de continuer à bidouiller ce projet. Quelques pistes ouvertes, pour t'approprier le code :

  • Affiche la moyenne des 5 derniers temps (garde un tableau tournant, push et slice).
  • Un mode « best of 3 manches » avec un résultat final : il faut gérer un compteur de manches ET l'état global de la partie.
  • Détecter un double faux départ : deux clics anticipés de suite déclenchent un message spécial (et un état supplémentaire dans ta machine).

À chaque ajout : que se passe-t-il si le joueur fait l'action au mauvais moment ? Un timer reste-t-il en suspens ?

Défi : à toi de coder (sans corrigé sous les yeux)

Ajoute l'affichage de la moyenne des 5 derniers temps. Tente-le seul d'abord : tu ne déplies le corrigé qu'après avoir essayé.

Tu as réussi si :

  • la moyenne s'affiche dès la deuxième partie et se met à jour à chaque manche ;
  • elle ne tient compte que des 5 derniers résultats valides (les faux départs ne comptent pas) ;
  • aucune variable globale ne traîne hors de la fonction : le tableau et son calcul sont encapsulés proprement.
Voir une solution possible
// On ajoute un tableau tournant pour les 5 derniers temps.
var history = [];

// Dans onClick(), là où on enregistre ms :
history.push(ms);
if (history.length > 5) history.shift();   // on ne garde que les 5 derniers

// Calcul et affichage de la moyenne :
var avgEl = document.getElementById('avg');
if (history.length >= 2) {
  var sum = history.reduce(function (a, b) { return a + b; }, 0);
  avgEl.textContent = Math.round(sum / history.length) + ' ms';
} else {
  avgEl.textContent = '—';   // pas encore assez de données, on évite NaN
}

Le réflexe clé : un tableau de taille bornée avec push + shift suffit (pas besoin d'un index tournant complexe). Et avant de calculer la moyenne, on vérifie qu'on a au moins 2 valeurs, sinon history.length === 1 donne une « moyenne » à un seul point, sans intérêt.

The project: click as soon as it's green

Fifth project, we tackle logic and state: a reaction-time game. The zone is red ("Wait…"), then turns green after a random delay. You click as fast as you can, we measure your milliseconds, and we keep your record.

A game is a state machine: idle, waiting, go, result. The AI codes the ideal scenario, but forgets the player will try to cheat, and that the first round has no record to beat. We code, then we review.

What you'll be able to do
  • Model an explicit state machine (idle / wait / go / result) to drive a game's behavior without states bleeding into each other.
  • Keep a timer's id and cancel it with clearTimeout to prevent a race condition — the green can no longer appear after a false start.
  • Handle the "first round" edge case cleanly: best = null and the test best == null || ms < best prevent any NaN or nonsensical comparison.
You've succeeded when…
  • clicking during the red shows "Too soon!" and no green ever appears afterwards;
  • the very first round displays the score cleanly, with no "NaN" or stray dash;
  • the game is fully playable with the keyboard (Tab to reach the zone, Space to click).

Prompt 1: set the frame

We describe the rules to the AI as clearly as we'd explain them to a friend: a zone that turns green after a random delay, a click that measures reaction time, and the best score kept in memory.

Create a standalone web mini-app, a single HTML file (zero dependencies). A reaction game: a zone that turns green after a random delay; the player clicks and we show their reaction time in milliseconds. Keep the best score in localStorage. Bilingual FR/EN. Simple, readable code.

The AI ships a playable version. The core:

function start() {
  zone.style.background = 'red';
  setTimeout(function () {                 // ⚠️ id not kept
    zone.style.background = 'green';
    startTime = Date.now();
  }, 1000 + Math.random() * 2000);
}
zone.onclick = function () {
  var ms = Date.now() - startTime;         // ⚠️ what if you click during red?
  result.textContent = ms + ' ms';
  if (ms < best) best = ms;                // ⚠️ what is best on round 1?
};

It works if you play "nicely". But click during the red: you get a wild negative time, and the "green" pops up a second later anyway. And on the very first try, best doesn't exist.

Predict before reading on

Re-read this code the AI produced to trigger the green phase, without scrolling further:

function start() {
  zone.style.background = 'red';
  setTimeout(function () {
    zone.style.background = 'green';
    startTime = Date.now();
  }, 1000 + Math.random() * 2000);
}

What do you think happens if the player clicks BEFORE the green, then waits? Is the timer still running?

Check my prediction

Without clearTimeout, the setTimeout launched inside start() keeps running in the background: after the false start, the zone turns green a few seconds later anyway. The stopwatch then starts without the player having intended it, and the measurement is corrupted. This is a race condition — two events chasing each other. To prevent it you must store the timer's id (var timer = setTimeout(…)) and cancel it on the early click (clearTimeout(timer)).

My human review: 3 things the AI let slip

The AI's version is playable… as long as the player plays nice. But a player, by nature, tests the limits: clicking before green just to see, showing up for the very first time with no record to beat. Three points to redo, and the first is a genuine little computer-science lesson.

1. The player will click too soon (and you must cancel the timer)

If you click during the wait, two things fail: you compute an absurd time, and above all the scheduled setTimeout keeps going and will turn the zone green later, mid-flow. You must keep the timer id and cancel it (clearTimeout) the moment of the early click. It's a small race condition: two events chasing each other.

2. The score edge cases

On the first round there's no record: comparing ms < best with an undefined best gives nonsense. Initialize best = null and test best == null || ms < best. And like project 3, read the record from localStorage carefully (a parseInt can fail).

3. A game is also played with the keyboard

The play zone is a <button>: you can start and click with Space or Enter, and the focus is visible. The zone is aria-live to announce changes, and animations respect prefers-reduced-motion.

Prompt 2: harden after review

Three fixes to ask for, in the order we found them:

Fix: (1) keep the setTimeout id and cancel it with clearTimeout if the player clicks during the red (show "too soon"). (2) Initialize best to null and test best == null || ms < best; read the record from localStorage carefully. (3) Make the zone a <button> (keyboard), set it aria-live and respect prefers-reduced-motion.
var timer = null, state = 'idle', best = null;

function startRound() {
  state = 'wait';
  timer = setTimeout(function () {        // keep the id
    timer = null; state = 'go';
    startTime = Date.now();
  }, 1000 + Math.random() * 2500);
}

function onClick() {
  if (state === 'wait') {                 // clicked too soon
    if (timer) { clearTimeout(timer); timer = null; }   // cancel the scheduled green
    state = 'early';
  } else if (state === 'go') {
    var ms = Date.now() - startTime;
    if (best == null || ms < best) { best = ms; /* … save … */ }   // round 1 handled
  }
}

New reflex for your toolkit: any scheduled timer must be cancelable. A setTimeout or setInterval you forget to clean up is the classic source of "ghost" bugs that fire at the wrong moment.

Host and test

  • Static file, online in one drop.
  • Play normally, then click during the red: you must see "Too soon!" and no green should pop up afterwards.
  • First visit (or cleared localStorage): the record must show cleanly, not "NaN".
  • Play with the keyboard (Tab to reach the zone, Space to click). Toggle FR / EN mid-game: it must restart cleanly.

The finished result

Open the project full screen

The full code (and downloadable)

The entire file, exactly the one running above.

Download the code (.html · 218 lines)

View the full code
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jeu de réflexe</title>
<meta name="description" content="Clique dès que c'est vert : teste ton temps de réaction. Mini-projet construit avec l'IA — web-developpeur.com">
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">

<!--
  ============================================================================
  JEU DE RÉFLEXE — un seul fichier : HTML + CSS + JS, zéro dépendance.
    1. <style>  : l'apparence (la grande zone qui change de couleur, les scores).
    2. <body>   : la structure (la zone de jeu + les deux scores).
    3. <script> : la logique du jeu, organisée comme une "machine à états".
  Le jeu passe par 4 états : idle (repos) -> wait (rouge) -> go (vert) -> result.
  Point clé : si on clique pendant le rouge, il FAUT annuler le minuteur prévu,
  sinon le "vert" surgirait plus tard, par-dessus. C'est une petite race condition.
  ============================================================================
-->

<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; }
  * { box-sizing:border-box; }
  html,body { margin:0; padding:0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:540px; }
  header { text-align:center; margin-bottom:18px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; }
  .lang-btn[aria-pressed="true"] { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  /* La zone de jeu est un <button> géant. Sa couleur dépend de l'état (classe). */
  .stage {
    width:100%; min-height:260px; border:0; border-radius:18px; cursor:pointer; color:#fff;
    display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; text-align:center;
    font-family:inherit; box-shadow:0 16px 38px rgba(15,25,35,0.16); transition:background 0.15s ease; padding:24px;
  }
  .stage:focus-visible { outline:4px solid rgba(38,125,66,0.6); outline-offset:3px; }
  .stage .big { font-size:1.5rem; font-weight:800; }
  .stage .small { font-size:0.95rem; opacity:0.92; }
  .stage.idle  { background:linear-gradient(135deg,#102a43,#334e68); }   /* repos : bleu */
  .stage.wait  { background:linear-gradient(135deg,#7d2d2d,#a8341f); }   /* attends : rouge */
  .stage.go    { background:linear-gradient(135deg,#0b6b3a,#1fbf6b); }   /* clique ! : vert */
  .stage.early { background:linear-gradient(135deg,#5c1a4b,#a23b86); }   /* trop tôt : violet */
  .stage.result{ background:linear-gradient(135deg,#0f1923,#267d42); }   /* résultat */
  .stage .time { font-size:2.6rem; font-weight:800; letter-spacing:-0.02em; }

  .scores { display:flex; justify-content:center; gap:28px; margin-top:18px; }
  .score { text-align:center; }
  .score-label { font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--muted); }
  .score-val { font-size:1.3rem; font-weight:800; color:var(--accent); font-variant-numeric:tabular-nums; }
  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:24px; }
  .credit a { color:var(--accent); }
  @media (prefers-reduced-motion: reduce){ .stage{transition:none;} }
</style>
</head>
<body>

<main class="app">
  <header>
    <h1 data-i18n="title">Jeu de réflexe</h1>
    <p class="sub" data-i18n="sub">Clique dès que la zone devient verte. Pas avant !</p>
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <button class="lang-btn" data-lang-btn="fr" aria-pressed="true" type="button">FR</button>
      <button class="lang-btn" data-lang-btn="en" aria-pressed="false" type="button">EN</button>
    </div>
  </header>

  <!-- La zone de jeu est un vrai <button> : jouable à la souris ET au clavier (Entrée/Espace). -->
  <button class="stage idle" id="stage" type="button" aria-live="polite">
    <span class="big" id="big">Commencer</span>
    <span class="small" id="small">Clique pour lancer</span>
  </button>

  <div class="scores">
    <div class="score"><div class="score-label" data-i18n="last">Dernier</div><div class="score-val" id="last">—</div></div>
    <div class="score"><div class="score-label" data-i18n="best">Record</div><div class="score-val" id="bestval">—</div></div>
  </div>

  <p class="credit" data-i18n="credit">Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com</p>
</main>

<script>
(function () {
  'use strict';

  /* ---------------------------------------------------------------------------
     1. LES TEXTES (FR / EN) — un libellé par état du jeu.
     --------------------------------------------------------------------------- */
  var UI = {
    fr: { title:"Jeu de réflexe", sub:"Clique dès que la zone devient verte. Pas avant !", last:"Dernier", best:"Record",
      credit:'Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a> — web-developpeur.com',
      start:"Commencer", startSub:"Clique pour lancer",
      waitBig:"Attends…", waitSub:"Reste prêt, ne clique pas encore",
      goBig:"MAINTENANT !", goSub:"Clique !",
      earlyBig:"Trop tôt !", earlySub:"Tu as cliqué avant le vert. Clique pour réessayer.",
      resultSub:"Clique pour rejouer", ms:"ms" },
    en: { title:"Reaction game", sub:"Click as soon as the box turns green. Not before!", last:"Last", best:"Best",
      credit:'A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course — web-developpeur.com',
      start:"Start", startSub:"Click to begin",
      waitBig:"Wait…", waitSub:"Stay ready, don't click yet",
      goBig:"NOW!", goSub:"Click!",
      earlyBig:"Too soon!", earlySub:"You clicked before green. Click to try again.",
      resultSub:"Click to play again", ms:"ms" }
  };

  /* ---------------------------------------------------------------------------
     2. LES ÉLÉMENTS + L'ÉTAT DU JEU.
     --------------------------------------------------------------------------- */
  var stage = document.getElementById('stage');
  var bigEl = document.getElementById('big');
  var smallEl = document.getElementById('small');
  var lastEl = document.getElementById('last');
  var bestEl = document.getElementById('bestval');

  var lang = 'fr';
  try { lang = localStorage.getItem('reaction-lang') || 'fr'; } catch (e) {}
  if (lang !== 'fr' && lang !== 'en') lang = 'fr';

  var state = 'idle';   // l'état courant : idle | wait | go | early | result
  var timer = null;     // l'identifiant du setTimeout en cours (pour pouvoir l'annuler)
  var startTime = 0;    // l'instant où la zone est devenue verte
  var best = null;      // le meilleur temps (null tant qu'on n'a pas encore joué)
  // On relit le record en mémoire. parseInt peut échouer -> on vérifie avec isNaN.
  try { var b = parseInt(localStorage.getItem('reaction-best'), 10); if (!isNaN(b)) best = b; } catch (e) {}

  /* ---------------------------------------------------------------------------
     3. L'AFFICHAGE — changer la couleur et les textes de la zone, afficher le record.
     --------------------------------------------------------------------------- */
  function setStage(cls, big, small) {
    stage.className = 'stage ' + cls;   // la classe pilote la couleur (voir le CSS)
    bigEl.textContent = big;
    smallEl.textContent = small;
  }
  function showScores() {
    bestEl.textContent = (best == null) ? '—' : best + ' ' + UI[lang].ms;
  }
  function toIdle() { state = 'idle'; setStage('idle', UI[lang].start, UI[lang].startSub); }

  /* ---------------------------------------------------------------------------
     4. LA LOGIQUE DU JEU — une manche, et la réaction à chaque clic.
     --------------------------------------------------------------------------- */

  // Lance une manche : on passe au rouge, puis au vert après un délai aléatoire.
  function startRound() {
    state = 'wait';
    setStage('wait', UI[lang].waitBig, UI[lang].waitSub);
    var delay = 1000 + Math.random() * 2500;   // entre 1 et 3,5 s, pour qu'on ne puisse pas anticiper
    // On GARDE l'id du minuteur : c'est lui qu'on annulera si le joueur clique trop tôt.
    timer = setTimeout(function () {
      timer = null;
      state = 'go';
      startTime = Date.now();   // top chrono
      setStage('go', UI[lang].goBig, UI[lang].goSub);
    }, delay);
  }

  // Réagit au clic selon l'état courant (c'est le cœur de la "machine à états").
  function onClick() {
    if (state === 'idle' || state === 'result' || state === 'early') {
      startRound();                      // au repos / après une partie : on (re)lance
    } else if (state === 'wait') {
      // Clic anticipé : on ANNULE le minuteur, sinon le "vert" prévu surgirait plus tard par-dessus.
      if (timer) { clearTimeout(timer); timer = null; }
      state = 'early';
      setStage('early', UI[lang].earlyBig, UI[lang].earlySub);
    } else if (state === 'go') {
      var ms = Date.now() - startTime;   // temps de réaction en millisecondes
      lastEl.textContent = ms + ' ' + UI[lang].ms;
      // 1re partie : best vaut null. Le test "best == null || ms < best" gère ce cas.
      if (best == null || ms < best) {
        best = ms;
        try { localStorage.setItem('reaction-best', String(best)); } catch (e) {}
      }
      showScores();
      state = 'result';
      setStage('result', ms + ' ' + UI[lang].ms, UI[lang].resultSub);
    }
  }

  stage.addEventListener('click', onClick);

  /* ---------------------------------------------------------------------------
     5. LA LANGUE + LE DÉMARRAGE.
     --------------------------------------------------------------------------- */
  function setLang(next) {
    lang = next;
    try { localStorage.setItem('reaction-lang', lang); } catch (e) {}
    document.documentElement.lang = lang;
    var t = UI[lang];
    document.querySelectorAll('[data-i18n]').forEach(function (el) {
      var k = el.getAttribute('data-i18n'); if (!t[k]) return;
      if (k === 'credit') el.innerHTML = t[k]; else el.textContent = t[k];
    });
    document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
      b.setAttribute('aria-pressed', b.getAttribute('data-lang-btn') === lang ? 'true' : 'false');
    });
    // Changer de langue annule une manche en cours, pour repartir d'un état propre.
    if (timer) { clearTimeout(timer); timer = null; }
    showScores();
    toIdle();
  }

  document.querySelectorAll('[data-lang-btn]').forEach(function (b) {
    b.addEventListener('click', function () { setLang(b.getAttribute('data-lang-btn')); });
  });

  setLang(lang);
})();
</script>
</body>
</html>

Your turn

The best way to retain all this is to keep tinkering with the project. A few open directions to make it yours:

  • Show the average of the last 5 times (keep a rolling array, push and slice).
  • A "best of 3 rounds" mode with a final result — you'll need a round counter and overall game state.
  • Detect a double false start: two early clicks in a row trigger a special message (and one extra state in your machine).

On every addition: what happens if the player acts at the wrong time? Is a timer left pending?

Challenge: your turn to code (no solution in sight)

Add the display of the average of the last 5 times. Try it on your own first — only unfold the solution after you've made an attempt.

You've succeeded if:

  • the average appears from the second round onwards and updates after every round;
  • it only counts the last 5 valid results (false starts don't count);
  • no global variable leaks outside the function — the array and its calculation are cleanly encapsulated.
See one possible solution
// Add a rolling array for the last 5 times.
var history = [];

// Inside onClick(), where you record ms:
history.push(ms);
if (history.length > 5) history.shift();   // keep only the last 5

// Compute and display the average:
var avgEl = document.getElementById('avg');
if (history.length >= 2) {
  var sum = history.reduce(function (a, b) { return a + b; }, 0);
  avgEl.textContent = Math.round(sum / history.length) + ' ms';
} else {
  avgEl.textContent = '—';   // not enough data yet — avoids NaN
}

The key reflex: a size-bounded array with push + shift is enough (no need for a complex rotating index). And before computing the average, verify you have at least 2 values — otherwise a single-entry "average" is meaningless and risks NaN if the array is ever empty.

Accepter ou rejeter le code de l'IA

Tu redemandes à l'IA de corriger le clic trop tôt. Elle renvoie ce onClick. Tu es le relecteur : on accepte tel quel, ou on rejette ?

function onClick() {
  if (state === 'wait') {
    state = 'early';                 // on note le faux départ
    result.textContent = 'Trop tôt !';
  } else if (state === 'go') {
    var ms = Date.now() - startTime;
    if (ms < best) best = ms;        // on garde le meilleur
  }
}
Rejeter. Deux trous exactement ceux relevés en relecture. (1) Sur le clic 'wait', l'IA passe l'état à 'early' mais n'annule pas le timer : le setTimeout programmé va quand même faire surgir le vert une seconde plus tard. Il manque le if (timer) { clearTimeout(timer); timer = null; }. (2) ms < best compare contre un best indéfini à la première partie : il faut best == null || ms < best. Le code tourne, mais les deux bugs visés sont toujours là.
Rappel libre

Sans remonter dans la leçon : si le joueur clique pendant le rouge, pourquoi faut-il clearTimeout, et que doit valoir best à la toute première partie ?

Le setTimeout qui doit passer la zone au vert est déjà programmé : si on ne l'annule pas, il se déclenche quand même plus tard, en plein faux départ (une race condition). On garde donc l'id du timer et on appelle clearTimeout(timer) dès le clic anticipé. Et best vaut null au départ : on teste best == null || ms < best, sinon comparer contre un best indéfini à la première partie donne n'importe quoi.
Prochaine étape

Ton jeu de réflexe mesure ta vitesse au centième de seconde, plutôt grisant. Au projet suivant, tu soignes la présentation : une landing animée avec des éléments qui apparaissent en douceur au scroll.

Leçon 6 : Landing animée →

Une erreur dans cette leçon, un passage flou, une question ? Écrivez-moi : chaque retour améliore ce cours.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement