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.
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 :
| Obstacle | Danger | Parade |
|---|---|---|
| Trou | Tomber : la partie s'arrête. | Sauter au bon moment (un trou fait au plus 114 px, un saut en couvre 115). |
| Caisse | Bloque le passage. | Sauter par-dessus, ou dessus. |
| Garde | Le toucher est mortel. Il fait des allers-retours. | Le shuriken, ou un saut bien placé. |
| Archer | Tire 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.
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.
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 });
}
}
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.
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.
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 :
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 ».
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);
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.
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).
| Résultat de la partie | Fitness |
|---|---|
| Arrivé au portique en t images | 1 + (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.
« 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 :
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.
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.
Mesuré avec 80 ninjas et 300 générations, hors navigateur, sur les trois niveaux fixes :
| Niveau | Option position | Premier ninja arrivé | Durée des 300 générations |
|---|---|---|---|
| Niveau 1, facile | oui | génération 37 | 50 s |
| Niveau 2, moyen | oui | génération 18 | 44 s |
| Niveau 2, moyen | non | génération 65 | 41 s |
| Niveau 3, difficile | oui | génération 18 | 41 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à.