Python · JavaScript · neat-python 0.92 · 5 jeux + Shinobi

Les jeux des pandas, le tutoriel

Les 5 exemples de Tuto-IA-3D reposent sur la même idée : une population de réseaux de neurones apprend seule par évolution (NEAT), sans règle programmée à la main. Ce tutoriel suit le code de chaque jeu, puis les problèmes rencontrés et la façon dont ils ont été réglés.

01Le projet en bref

Le dépôt part du tutoriel vidéo Tuto-IA-3D-Youtube de neocodeytb (le parcours 3D). Quatre jeux ont été ajoutés ensuite. Tous sont publiés sur rdv-app.fr/jeux.html, chacun avec une page « jouer » et une page « code expliqué ».

# Jeu Langage Ce que l'IA apprend Jouer Code expliqué
1 Le parcours 3D Python (Panda3D) + démo Three.js Franchir échelle, plateforme et bombe pandas.html pandas2.html
2 Le labyrinthe Python + page web Trouver le chemin le plus court labyrinthe.html labyrinthe2.html
3 Panda ninja JavaScript (navigateur) Finir un niveau à défilement minigame.html minigame2.html
4 Les dames JavaScript + serveur Node Jouer aux dames internationales dames.html dames2.html
5 L'IA et l'or JavaScript + scripts Python Trader l'or sur 25 ans de cours réels trading-or.html trading-or2.html

Organisation des fichiers

Fichier ou dossier Rôle
main.py, config.txt Parcours 3D en Python (Panda3D + NEAT)
labyrinthe.py, config_labyrinthe*.txt Labyrinthe en Python
web/ Pages des jeux et pages de code expliqué
serveur/ Serveurs Node : dames en ligne, cours de l'or en direct
scripts/ Mise à jour des données de l'or et de l'économie
deploy.sh Copie des pages et des données vers /var/www/rdv-app

Point commun à respecter partout : neat-python doit rester en version 0.92. La version 2.0 refuse les fichiers config*.txt du projet.

02Le parcours 3D (main.py)

Vingt pandas apprennent à traverser un parcours 3D. Le résultat est atteint en 30 à 100 générations en mode rapide, soit une à quatre minutes sur un processeur ordinaire.

La grande boucle

Deux boucles cohabitent : celle de Panda3D (taskMgr, une tâche par image) et celle de NEAT (p.run, une évaluation par génération). C'est NEAT qui commande.

  1. initNeat lit config.txt et crée 20 génomes.
  2. p.run(self.playGame, 10000) appelle playGame à chaque génération.
  3. playGame transforme chaque génome en réseau et en panda, puis lance taskMgr.run().
  4. À chaque image : updatePlayerNeat (décider, bouger), updateCollisionsNeat (murs, sols, zones), verifFinPartie.
  5. En fin de partie, taskMgr.stop() rend la main à playGame, qui note chaque génome.
def playGame(self, genomes, config):
    self.players = []
    for _, g in genomes:
        net = neat.nn.FeedForwardNetwork.create(g, config)
        self.players.append(self.generatePlayerNeat(idx, net))
        g.fitness = 0
    self.taskMgr.run()      # la partie se joue ici
    for _, g in genomes:
        g.fitness = ...     # voir la fitness

Le monde, la physique, les capteurs

La carte map_tuto.glb n'est qu'un décor. Le jeu ne connaît que des formes de collision invisibles placées dans initColliders, chacune avec une étiquette : Wall (rebond), Floor, Ladder, Score1 à Score9 (zones de progression), Reward (victoire), Bomb (mort).

La physique est volontairement simple : frottement ×0,93 par image, gravité −0,30 par image en l'air, mort sous z = −100.

Un panda ne voit rien. Il porte trois boîtes de collision devant lui, qui valent 1 si elles touchent le sol. Un capteur à 0 veut dire « du vide devant ».

Le cerveau

Les 9 cases de zone permettent au même réseau d'agir différemment selon l'endroit du parcours, alors qu'il ne voit presque rien.

La fitness

Dans le tuto d'origine, la note est le numéro de la dernière zone franchie (0 à 10). Beaucoup de pandas ont la même note, et NEAT ne sait pas lequel était le plus proche de réussir. La version corrigée ajoute un bonus inférieur à 1 :

bonus = 0 if player["score"] == 10 else 0.99 / (1 + player["distMin"] / 50)
g.fitness = player["score"] + bonus

Il départage les ex æquo sans jamais faire passer une zone 4 devant une zone 5.

Difficultés rencontrées et solutions

Problème dans le code d'origine Conséquence Solution
fitness_threshold = 11 L'entraînement ne s'arrête jamais, best_model.pkl n'est jamais écrit Seuil à 10
Variable self.config pour NEAT Écrase la config interne de Panda3D : plantage à la fermeture Renommée self.neatConfig
Panda tué par la bombe, autres collisions traitées dans la même image Plantage aléatoire (setPos sur un objet supprimé) Collisions d'un panda mort ignorées
Caméra qui suit toujours le panda n°0 Plantage s'il meurt Elle suit le meilleur panda en vie
Échelle : abs(vx) compté deux fois La montée dépend de la direction abs(vx) + abs(vy)
Boîtes de collision jamais retirées Fuite mémoire au fil des générations removeCollider en fin de partie
Physique dépendante des images par seconde Un modèle ne rejoue pas pareil à 60 et à 144 images/s Pas de temps fixe de 1/60 s (ClockObject.MNonRealTime)
Dépendances non précisées .glb illisibles, neat-python 2.0 refuse la config requirements.txt avec panda3d-gltf et neat-python==0.92
Notes ex æquo NEAT ne distingue pas les meilleurs Bonus de distance < 1

Le pas de temps fixe a un second effet : chaque partie devient reproductible. C'est ce qui permet à la démo web (Three.js) de rejouer exactement les parties de l'entraînement exportées par python main.py --rapide --export-web.

03Le labyrinthe (labyrinthe.py)

Des pandas qui ne voient presque rien apprennent à sortir d'un labyrinthe en un minimum d'actions. Le champion final sort de 17 labyrinthes jamais vus sur 20, en 1,00 fois le minimum d'actions en moyenne.

Les règles

Grille de 10 × 10 cases, départ en haut à gauche tourné vers l'est, sortie dans le mur est de la case en bas à droite. À chaque pas, une seule action parmi trois : avancer, tourner à gauche, tourner à droite. Tourner coûte autant qu'avancer.

La partie s'arrête à la sortie, après 4 × taille² actions (400 pour un 10 × 10), ou si le panda repasse plus de 4 fois sur la même case avec la même orientation (il tourne en rond).

Créer un labyrinthe

Les labyrinthes sont « parfaits » (un seul chemin entre deux cases). Ils sont creusés par exploration en profondeur : on perce un mur vers un voisin pas encore visité, et on revient en arrière dans un cul-de-sac.

while pile:
    x, y = pile[-1]
    voisins = [(d, x + DX[d], y + DY[d]) for d in range(4)
               if ... and (x + DX[d], y + DY[d]) not in vues]
    if not voisins:
        pile.pop()          # cul-de-sac : on revient en arrière
        continue
    d, nx, ny = rng.choice(voisins)
    self.murs[y][x][d] = False              # on perce le mur des deux côtés
    self.murs[ny][nx][(d + 2) % 4] = False
    vues.add((nx, ny))
    pile.append((nx, ny))

Le hasard dépend d'une graine : la même graine donne le même labyrinthe. On entraîne sur les graines 1 à 20 et on teste sur d'autres. Deux parcours en largeur calculent ensuite la distance de chaque case à la sortie et le nombre minimal d'actions, virages compris.

Ce que sent le panda (8 entrées, 3 sorties)

Le réseau rend 3 valeurs ; on joue la plus forte (argmax). Contrairement au parcours 3D, une seule action à la fois.

La fitness

Résultat Python (par labyrinthe) Navigateur
Sorti en n actions 1 + (1 − n / 400) 1 + minimum / n
Pas sorti 0,99 × (1 − plus courte distance atteinte / distance au départ) idem

Sortir rapporte toujours plus que ne pas sortir. Entre deux pandas bloqués, celui qui s'est le plus approché gagne : l'évolution progresse avant même la première sortie. En Python, on additionne les 20 labyrinthes d'entraînement (maximum 40).

Deux entraînements

Difficultés rencontrées et solutions

Difficulté Solution
Avec 5 labyrinthes d'entraînement, le panda les réussissait tous mais échouait sur les inconnus (apprentissage par cœur) 20 labyrinthes d'entraînement, bilan sur 20 jamais vus
Sans vraie mémoire, suivre la boussole est rapide mais peut boucler ; suivre un mur est sûr mais lent Fitness qui récompense la vitesse ; essai d'un réseau récurrent avec --memoire
Pandas qui tournent en rond sans fin Arrêt après 4 passages au même endroit avec la même orientation
L'évolution s'arrêtait au premier chemin optimal Option pour continuer, bouton « Continuer »
Perte de diversité Espèces maintenues entre 5 et 10, relance de diversité après 40 générations sans progrès
« Arrêter » puis « Continuer » ne relançait rien Bug corrigé dans le Web Worker
Calcul différent entre Python et JavaScript Copie exacte des couches et de l'activation de neat-python, vérifiée sur le champion

Pour lancer : python labyrinthe.py (300 générations et bilan), python labyrinthe.py --generations 150 --graine 1 --export-web pour régénérer les données de la démo.

04Panda ninja (minigame.html)

Un ninja apprend à traverser un niveau à défilement plein de trous, de caisses, de gardes et d'archers. Tout tourne dans le navigateur : avec 80 ninjas, le premier arrivé apparaît entre la génération 18 et 65 selon le niveau.

Les règles

Obstacle Danger Parade
Trou Tomber : fin de partie Sauter (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 Shuriken ou saut bien placé
Archer Une flèche toutes les ~2 s quand le ninja approche Sauter la flèche, la casser au shuriken, ou abattre l'archer

Une partie s'arrête aussi au bout de 60 secondes, ou si le ninja n'a pas avancé depuis 5 secondes.

Créer un niveau

Un niveau est une liste de morceaux de sol (les trous sont les espaces entre eux), de caisses et d'ennemis, tirée d'une graine. La difficulté augmente le nombre et la taille des obstacles, 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
    ennemis.push({ type: "garde", x1: x, x2: x + l });
  } else {                                            // un archer
    ennemis.push({ type: "archer", x: x + 60 });
  }
}

Le moteur : une seule fonction déterministe

pas(niveau, état, actions) avance le jeu d'une image (1/60 s). Les actions tiennent sur 4 bits : 1 droite, 2 gauche, 4 sauter, 8 lancer. Pas de hasard, pas d'horloge : la même suite d'actions donne toujours la même partie.

Le même code sert à trois choses : faire jouer l'humain, faire jouer l'IA dans le Web Worker, et rejouer une 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²

Ce que sent le ninja (11 ou 12 entrées, 4 sorties)

Le ninja ne voit pas l'écran. Il reçoit des mesures de ce qui arrive devant lui :

Les distances sont divisées par une portée (200 ou 300 px) puis bornées à 1 : « 1 » veut dire « rien en vue ». Chaque sortie positive appuie sur sa touche : le ninja peut courir, sauter et lancer en même temps.

La fitness

Résultat Fitness
Arrivé en t images 1 + (1 − t / 3600)
Tombé, touché, bloqué ou temps écoulé 0,99 × distance la plus loin / longueur du niveau

Difficultés rencontrées et solutions

Difficulté Solution
Un niveau tiré au hasard pouvait être infranchissable Trous limités à 114 px pour un saut de 115 px, caisses bornées
Des ninjas immobiles faisaient perdre du temps de calcul Arrêt après 5 secondes sans avancer
Sur téléphone, la vitesse du jeu dépendait de l'écran La page accumule le temps et appelle pas exactement 60 fois par seconde : l'humain joue avec les mêmes règles que l'IA
Stocker toutes les parties pour les revoir Une lettre hexadécimale par image : 60 s de jeu = 3 600 lettres, rejouées par le même moteur
Afficher 12 parties à la fois alors que chacune a ses propres ennemis abattus Le meilleur est rejoué avec tout son monde, les 11 autres en « fantômes » transparents
Contrôles tactiles Boutons maintenus (on garde le doigt appuyé pour courir)
Avec l'option position, le ninja apprend le niveau par cœur Option laissée au choix : sans elle, il apprend une vraie stratégie (première arrivée à la génération 65 au lieu de 18 sur le niveau 2)
Stagnation Espèces gardées entre 5 et 10 ; après 40 générations sans progrès, la moitié la moins bonne est remplacée

05Les dames (dames-moteur.js)

Dames internationales contre une IA, à deux sur le même écran ou en ligne. Ici, pas de réseau de neurones : l'IA cherche ses coups par alpha-bêta et apprend les 7 poids qui servent à juger une position. Entraînée 30 générations, elle marque 69 % des points contre l'IA de départ (contrôle sur 40 parties).

Le damier en mémoire

Seules les 50 cases foncées comptent : un tableau de 50 nombres (0 vide, 1 pion blanc, 2 dame blanche, −1 pion noir, −2 dame noire). La numérotation 1 à 50 est celle des compétitions, ce qui donne directement la notation officielle (32-28, 28x19).

Les « rayons » sont calculés une fois pour toutes : pour chaque case et chaque diagonale, la liste des cases jusqu'au bord. Un pion n'en regarde que la première, une dame les parcourt toutes.

const RAYONS = Array.from({ length: 50 }, (_, s) => DIRS.map(([dr, dc]) => {
  const r = [];
  let l = LIGNE(s) + dr, c = COL(s) + dc;
  while (CASE(l, c) >= 0) { r.push(CASE(l, c)); l += dr; c += dc; }
  return r;
}));

Trouver les coups : la rafle

Une recherche en profondeur essaie toutes les suites de prises depuis chaque pièce. On ne garde ensuite que les rafles qui prennent le plus de pièces (prise majoritaire).

let i = 0;
while (i < ray.length && b[ray[i]] === 0) i++;          // la dame glisse sur les cases vides
const a = ray[i];
if (b[a] * camp >= 0 || prises.includes(a)) continue;  // pas adverse, ou déjà prise
i++;
while (i < ray.length && b[ray[i]] === 0) {             // toutes les cases libres derrière
  dfs(ray[i], [...prises, a], [...chemin, ray[i]]);
  i++;
}

Juger une position et chercher le meilleur coup

L'IA compte 7 critères par camp (pions, dames, avancée, centre, rangée du fond, pions protégés, pions bientôt dame), multipliés chacun par un poids. Elle explore les coups jusqu'à une profondeur donnée (minimax), en coupant les branches inutiles (alpha-bêta), dans un Web Worker.

Force Profondeur Remarque
Débutant 1 demi-coup Avec une part de hasard
Moyen 3 Bat le jeu au hasard 10 fois sur 10
Fort 5 Environ 8 500 positions au premier coup
Expert 7 Environ 140 000 positions, 0,4 s au premier coup

Apprendre par tournois

Un algorithme évolutionnaire fait évoluer 12 jeux de 7 poids. Chacun joue 4 parties à profondeur 2 ; les 3 meilleurs passent tels quels, les autres places vont à des enfants de deux bons parents, légèrement modifiés. L'IA a appris seule à garder sa rangée du fond (poids de 0,1 à 0,4-0,6) et à valoriser les pions proches de la promotion (de 0,3 à plus de 1).

Jouer en ligne

Un serveur Node sans dépendance tient les parties. Chaque salle a un code de 5 caractères. Chaque navigateur reçoit l'état par un flux Server-Sent Events et envoie ses coups par POST. Le serveur vérifie chaque coup avec le même fichier de règles que la page :

if (p.trait !== c) return repondre(res, 409, { erreur: "Ce n'est pas à vous de jouer." });
if (m.index !== salle.coups.length) return repondre(res, 409, { erreur: "Coup déjà joué ou décalé" });
const legal = liste.find((x) => cle(x) === cle({ de: m.de, a: m.a, prises: m.prises || [] }));
if (!legal) return repondre(res, 422, { erreur: "Coup illégal." });
salle.coups.push(legal);
diffuser(salle);

Difficultés rencontrées et solutions

Difficulté Solution
La rafle : la pièce peut repasser par sa case de départ, et les pièces prises ne disparaissent qu'à la fin Case de départ vidée pendant la recherche ; pièces prises gardées sur le damier (une dame ne peut pas les traverser)
Juger une position au milieu d'un échange de prises est trompeur La recherche continue tant qu'il y a des prises, jusqu'à 6 demi-coups de plus
Voir loin coûte très cher Coupures alpha-bêta et recherche dans un Web Worker (la page reste fluide)
Parties d'entraînement toutes identiques Les 2 premiers coups sont joués au hasard
Comparer des poids de tailles différentes Poids remis à l'échelle pour qu'un pion vaille 1 (« une dame vaut 3,6 pions »)
Courbe d'entraînement bruitée Limite connue : 8 parties par génération, c'est peu ; contrôle final sur 40 parties
Une page trafiquée pourrait envoyer un coup interdit Moteur extrait dans dames-moteur.js, partagé par la page, le Worker et le serveur, qui revérifie tout
Joueur qui recharge la page ou revient plus tard Jeton secret gardé dans le navigateur ; la partie est une liste de coups rejouée à la reconnexion
Coups qui arrivaient par paquets Mise en tampon désactivée dans nginx pour le flux
Code de salle difficile à dicter Ni 0, O, 1 ni I dans les codes
Salles abandonnées en mémoire Ménage après 48 heures sans activité

Le serveur tourne sous pm2 (dames-reseau, port 3077), derrière nginx sur /dames-api/.

06L'IA et l'or (trading-or.html)

Des IA apprennent à trader l'or sur 25 ans de cours réels (simulation, sans argent réel). Résultat principal : sur une période jamais vue (2019-2026), la meilleure IA fait 7 à 14 % par an, quand acheter et garder rapportait 16,4 %.

Les données

Les cours viennent de Yahoo Finance (contrat à terme COMEX, symbole GC=F) : 6 550 clôtures quotidiennes depuis 2000 pour l'entraînement, et le cours minute par minute pour le temps réel. Un relais Node sur rdv-app.fr interroge Yahoo à la place du navigateur et garde les réponses en mémoire (6 heures pour l'historique, 30 secondes pour le direct).

Ce que voit l'IA et ce qu'elle décide

Chaque jour, au moins 13 entrées : variations sur 1, 5, 20 et 60 jours, écarts aux moyennes 20, 50 et 200 jours, volatilité, RSI, recul depuis le plus haut d'un an, et sa position actuelle (vendeuse, neutre, acheteuse). Chaque indicateur est divisé par une valeur typique puis borné entre −3 et 3.

const borne = (v) => Math.max(-3, Math.min(3, v));
f[o]     = borne(lr(i, 1) / 0.015);                 // variation sur 1 jour
f[o + 4] = borne((c[i] / moy(20) - 1) / 0.04);      // écart à la moyenne 20 jours
f[o + 8] = borne((rsi - 50) / 25);                  // RSI centré

3 sorties : vendre, rester neutre, acheter ; la plus forte l'emporte. La décision prise à la clôture du jour i s'applique jusqu'à la clôture du lendemain, frais de 0,1 % par changement.

La fitness et le test

g.fitness = Math.log(r.eq) - 0.5 * r.dd;   // gain en logarithme, moins la moitié de la pire chute

La sélection n'utilise que la période d'entraînement. Après chaque génération, la meilleure IA est rejouée sur la période de test, pour information seulement : c'est ce qui fait du test un vrai examen.

Difficultés rencontrées et solutions

Difficulté Solution
Le navigateur ne peut pas interroger Yahoo (règle CORS) Relais or-serveur.js sur rdv-app.fr, avec cache ; fichier or.json de secours
Avec range=max, Yahoo renvoyait 269 points mensuels au lieu de 6 550 quotidiens, sans erreur Dates demandées explicitement (period1, period2, interval=1d)
Risque d'utiliser un cours du lendemain (IA « miraculeuse » sur le papier) Indicateurs calculés uniquement avec les cours jusqu'au jour J ; position appliquée au lendemain
Un jour de krach faisait « exploser » les calculs Entrées divisées par une valeur typique et bornées à ±3
Chiffres économiques connus seulement après publication (l'inflation de mars sort vers le 15 avril) Chaque valeur n'est disponible qu'à sa date de publication (chômage vu le 20 mars 2020 : encore 3,5 %)
FRED refuse les requêtes de serveurs Données prises au BLS et chez Yahoo
BLS : 25 requêtes par jour sans clé, 10 ans par requête, « - » pour les mois sans chiffre Cache dans scripts/cache/, seules les 2 dernières années redemandées, dernière valeur gardée
Variable m qui masquait les séries économiques dans une boucle Paramètre renommé macro
Surapprentissage : 21 %/an sur l'entraînement, 7 à 14 % sur le test Montré sur la page ; l'économie (15 critères) l'aggrave : −7,0 points/an contre le marché, décochée par défaut
Les figures de bougies classiques ne prédisent rien (le marteau change de sens entre 2010-2018 et 2019-2026) 5 mesures brutes de la bougie données à l'IA : écart au marché de −1,7 à −0,4 point/an, gain modeste
Un réseau ne dit pas pourquoi il décide On neutralise un critère à la fois et on compte les jours où la décision change
Déplacer la coupure entraînement/test rend les anciens résultats incomparables Les IA déjà entraînées sont effacées

La page liste aussi ses simplifications (exécution au cours de clôture, frais fixes, pas de taux de change, chiffres économiques non révisés).

07En plus : Shinobi (Master System)

Un sixième essai, en cours : l'IA apprend un vrai jeu Sega dans l'émulateur stable-retro, à partir de l'image de l'écran. C'est là que les difficultés ont été les plus nombreuses. Page : shinobi.html.

Le principe

Difficultés rencontrées et solutions

Difficulté Solution
Les états de stable-retro plantaient l'émulateur du navigateur (EmulatorJS) Conversion des états (etat_navigateur.py) pour rejouer chaque partie à l'image près dans la page
Une mort suivie de la réapparition était prise pour une fin de niveau : faux niveaux 3 à 6, tous des copies du 1-2 Fin de niveau détectée par le compteur de stage du jeu (adresse 0xC092) ; faux niveaux mis de côté
La vie n'est pas remplie entre deux niveaux : le 1-2 commençait avec 1 barre Bonus pour la vie restante et passage au niveau suivant seulement avec au moins 4 barres
Pas de bonus shuriken : il faut sauver certains otages, l'IA n'en sauvait que 2 Otages repérés par le signal 0xC079 accompagné de +1 000 points, et récompensés ; l'IA garde la manette pendant la transition
L'IA se faisait toucher sans se baisser Pénalité par barre de vie perdue
En 8 × 8 pixels par case, les ennemis et leurs tirs disparaissaient dans le décor Image plus fine (4 × 4 pixels par case) et population neuve
Le réseau qui avait fini le 1-1 a été écrasé (meilleur_n1.pkl réécrit à chaque génération) Chaque réseau qui finit un niveau est gardé à part (fini_nN_gG.pkl)
Le script de relance croyait l'entraînement déjà lancé et ne démarrait rien Relance depuis une commande qui ne contient pas le nom du script

08Leçons communes

Les mêmes problèmes reviennent d'un jeu à l'autre. Les régler une fois a servi pour tous les suivants.

Leçon Où elle est apparue Ce qu'on fait maintenant
Un jeu déterministe permet de rejouer exactement chaque partie Parcours 3D (pas de temps fixe), ninja (pas() à 1/60 s), Shinobi (émulateur) On n'enregistre que les actions, jamais les positions
La note doit départager les perdants Parcours 3D (bonus < 1), labyrinthe et ninja (distance atteinte) Toujours une récompense partielle pour qui s'approche du but
La note fait le comportement : ce qui n'est pas récompensé n'est pas appris Shinobi (otages ignorés, coups encaissés) Chaque objectif voulu a sa ligne dans la note
Bien réussir sur l'entraînement ne prouve rien Labyrinthe (5 labyrinthes appris par cœur), or (surapprentissage) Toujours un test sur des cas jamais vus
Plus d'informations n'est pas toujours mieux Or (économie : −7 points/an) Ajouter une entrée seulement si elle améliore le test
L'IA doit voir ce qui compte Shinobi (tirs invisibles en 8 × 8) Vérifier à l'œil ce que reçoit le réseau
Arrêter tôt les parties inutiles Ninja (5 s sans avancer), labyrinthe (tourne en rond), Shinobi (bloqué) Gain de temps de calcul important
Le calcul lourd ne doit pas figer la page Labyrinthe, ninja, dames, or NEAT ou alpha-bêta dans un Web Worker
Un seul moteur partagé évite les écarts Dames (page, Worker, serveur), labyrinthe (réseau identique à neat-python) Le même code partout, vérifié sur un champion
Garder ce qui a marché avant de changer Shinobi (réseau du 1-1 perdu) Sauvegarde à part de chaque réseau gagnant, déplacer plutôt que supprimer
Fixer les versions neat-python 2.0 refuse les configs neat-python==0.92 partout