Lesson 5/12 13 min

Project 5: a small reaction game, built with AI

A guided build: a reaction-time game made with AI. The real prompts, and the review that prevents cheating (cancel the timer), handles score edge cases and makes the game keyboard-playable.

FR EN

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.

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.

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

  • Affiche la moyenne des 5 derniers temps.
  • Trois manches, puis un résultat final.
  • Un faux départ deux fois de suite = partie perdue (gère bien l'état).

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

Projet suivant
Une landing animée →

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.

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.

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

  • Show the average of the last 5 times.
  • Three rounds, then a final result.
  • Two false starts in a row = game lost (handle the state carefully).

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

Next project
An animated landing →
Next step

Your reaction game clocks your speed to the hundredth of a second, pretty thrilling. Next up, you polish presentation: an animated landing where elements gently fade in as you scroll.

Lesson 6: Animated landing →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement