minigame.html · JavaScript · NEAT dans un Web Worker

Panda ninja, le code

Comment un ninja qui ne connaît rien au jeu apprend à traverser un niveau plein de trous, de caisses, de gardes et de flèches : le moteur du jeu, ce que sent le ninja, la fitness et l'évolution qui tourne dans votre navigateur.

01Les règles

Un seul personnage part de la gauche et doit atteindre le portique rouge à droite. Il peut courir à gauche ou à droite, sauter, et lancer un shuriken (une fois toutes les demi-secondes). Sur sa route :

ObstacleDangerParade
TrouTomber : la partie s'arrête.Sauter au bon moment (un trou fait au plus 114 px, un saut en couvre 115).
CaisseBloque le passage.Sauter par-dessus, ou dessus.
GardeLe toucher est mortel. Il fait des allers-retours.Le shuriken, ou un saut bien placé.
ArcherTire une flèche toutes les ~2 s quand le ninja approche.Sauter la flèche, la casser au shuriken, ou éliminer l'archer.

Une partie s'arrête aussi au bout de 60 secondes, ou si le ninja n'a pas avancé depuis 5 secondes : sans cette règle, un ninja immobile ferait perdre beaucoup de temps de calcul à l'évolution.

02Créer un niveau

Un niveau est une liste de morceaux de sol (les trous sont les espaces entre eux), de caisses et d'ennemis. Il est généré à partir d'une graine : même graine, même niveau. La difficulté augmente le nombre d'obstacles, la largeur des trous et la hauteur des caisses, sans jamais dépasser ce qu'un saut permet.

minigame.html · niveau()
const nb = 12 + 4 * difficulte;
for (let i = 0; i < nb; i++) {
  const t = r();
  if (t < 0.32) {                                     // un trou
    const w = Math.round(48 + r() * (24 + 14 * difficulte));
    sols.push([debut, x]); x += w; debut = x; x += 130 + r() * 80;
  } else if (t < 0.52) {                              // une caisse
    caisses.push([x, 40, Math.round(24 + r() * (14 + 10 * difficulte))]);
  } else if (t < 0.8) {                               // un garde qui patrouille
    ennemis.push({ type: "garde", x1: x, x2: x + l });
  } else {                                             // un archer
    ennemis.push({ type: "archer", x: x + 60 });
  }
}

03Le moteur

Tout le jeu tient dans une fonction, pas(niveau, état, actions), qui avance d'une image (1/60 s). Les actions sont 4 bits : 1 droite, 2 gauche, 4 sauter, 8 lancer. Pas de hasard, pas de dépendance à l'horloge : la même suite d'actions donne toujours exactement la même partie.

C'est le point clé de toute la page. Le même code sert à vous faire jouer, à faire jouer l'IA dans le Web Worker, et à rejouer n'importe quelle partie enregistrée.

minigame.html · pas() (extrait)
if (a & 1) vx += VIT;                          // droite : 3 px par image
if (a & 2) vx -= VIT;                          // gauche
if ((a & 4) && p.sol) { p.vy = SAUT; }          // sauter : -11,5 px par image
if ((a & 8) && p.cd <= 0) {                   // lancer, si le shuriken est prêt
  s.shurikens.push({ x: p.x + p.dir * 12, y: p.y - 20, v: 8 * p.dir, reste: 40 });
  p.cd = 30;
}
p.vy += GRAV;                                  // gravité : 0,6 px par image²
if (p.y > H + 80) { s.fin = "chute"; return; }

Quand vous jouez, la page accumule le temps écoulé et appelle pas exactement 60 fois par seconde, quelle que soit la vitesse du téléphone. Vous jouez donc avec les mêmes règles que l'IA, à l'image près.

04Ce que sent le ninja

Le ninja ne voit pas l'écran. À chaque image, son réseau de neurones reçoit 11 nombres, presque tous entre 0 et 1, qui décrivent ce qui arrive devant lui :

Devant lui

  1. Distance du prochain trou
  2. Largeur de ce trou
  3. Distance de la prochaine caisse
  4. Hauteur de cette caisse
  5. Distance de l'ennemi le plus proche
  6. Cet ennemi est un archer (0 ou 1)
  7. Distance de la flèche la plus proche

Lui-même

  1. Au sol (0 ou 1)
  2. Vitesse verticale
  3. Shuriken prêt (0 ou 1)
  4. Hauteur au-dessus du sol
  5. Option : position dans le niveau (0 au départ, 1 à l'arrivée)

Les distances sont divisées par une portée (200 px pour les trous et caisses, 300 px pour les ennemis et flèches) puis bornées à 1 : « 1 » veut dire « rien en vue ». Avec l'option position, le ninja peut en plus apprendre le niveau par cœur : « à 40 % du niveau, je saute ».

minigame.html · capteurs() (extrait)
const e = [trou, largeur, caisse, hauteur, ennemi, archer, fleche,
           p.sol ? 1 : 0, p.vy / 12, p.cd <= 0 ? 1 : 0, (SOL - p.y) / 100];
if (avecPosition) e.push(p.x / N.arrivee);

05Le cerveau

Le réseau a 4 sorties, une par touche. Contrairement au labyrinthe, où l'on choisit une seule action, chaque sortie positive « appuie » sur sa touche : le ninja peut courir, sauter et lancer en même temps, comme un joueur avec plusieurs doigts.

minigame.html · partie()
const o = activer(capteurs(N, s, avecPosition));
const a = (o[0] > 0 ? 1 : 0) | (o[1] > 0 ? 2 : 0) | (o[2] > 0 ? 4 : 0) | (o[3] > 0 ? 8 : 0);
actions += a.toString(16);     // une lettre hexadécimale par image : la partie entière tient dans une chaîne
pas(N, s, a);

Le calcul du réseau (reseau()) est le même que pour le labyrinthe, lui-même identique à FeedForwardNetwork de neat-python : somme pondérée, puis tanh(2,5 × z).

06La fitness

Résultat de la partieFitness
Arrivé au portique en t images1 + (1 − t / 3600)
Tombé, touché, bloqué ou temps écoulé0,99 × distance la plus loin atteinte / longueur du niveau

Avancer est toujours récompensé, ce qui guide l'évolution dès la première génération : un ninja qui meurt au deuxième trou vaut mieux qu'un ninja qui meurt au premier. Arriver vaut toujours plus que ne pas arriver, et entre deux ninjas arrivés, le plus rapide gagne.

07L'évolution

« Faire apprendre l'IA » lance NEAT dans un Web Worker, un fil d'exécution séparé : la page reste fluide pendant que les générations s'enchaînent. C'est la même version que dans le labyrinthe :

  1. Départ : les 11 (ou 12) entrées reliées directement aux 4 sorties, poids au hasard.
  2. Mutations : poids et biais qui bougent, connexions qui apparaissent ou disparaissent, neurones qui s'insèrent au milieu d'une connexion.
  3. Espèces : les réseaux semblables sont regroupés, et le seuil de ressemblance s'ajuste pour garder 5 à 10 espèces. Chacune a des enfants selon sa fitness moyenne.
  4. Sélection : les 2 meilleurs de chaque espèce passent tels quels, les autres naissent du croisement des 20 % meilleurs.
  5. Relance : 40 générations sans progrès, et la moitié la moins bonne est remplacée par des ninjas neufs.

Les curseurs règlent le nombre de générations (50 à 3 000) et de ninjas par génération (20 à 200). Plus il y en a, meilleur est le résultat, au prix d'un calcul plus long. « Continuer » repart de la population actuelle : rien n'est perdu.

08Revoir les générations

Après chaque génération, le worker envoie à la page les 12 meilleures parties, chacune sous forme d'une chaîne d'actions (une lettre par image : 60 secondes de jeu font 3 600 lettres). Pour les revoir, la page rejoue ces actions avec le même moteur : on retrouve exactement la partie que l'IA a jouée, sans avoir stocké une seule position.

Le meilleur ninja est rejoué avec tout son monde (ennemis, flèches, shurikens). Les 11 autres apparaissent en transparence : chacun a vécu sa propre partie, avec ses propres ennemis abattus, qu'on ne peut pas tous afficher en même temps.

09Résultats

Mesuré avec 80 ninjas et 300 générations, hors navigateur, sur les trois niveaux fixes :

NiveauOption positionPremier ninja arrivéDurée des 300 générations
Niveau 1, facileouigénération 3750 s
Niveau 2, moyenouigénération 1844 s
Niveau 2, moyennongénération 6541 s
Niveau 3, difficileouigénération 1841 s

L'évolution part du hasard : d'un essai à l'autre, la première arrivée peut venir plus tôt ou plus tard. Sans l'option position, le ninja apprend une vraie stratégie (sauter quand un trou approche, tirer quand un garde est proche) qui marche aussi sur d'autres niveaux ; avec, il apprend plus vite, mais surtout ce niveau-là.