Simulateur d'écosystème en TypeScript et Canvas où quatre espèces s'affrontent pour survivre. Code, optimisations, observations.
Un point cyan avance vers un carré vert. Un peu plus loin, un point rouge l'a repéré et trace droit dessus. Les deux veulent la même nourriture. Le rouge arrive en premier et commence à manger. Le cyan le rejoint, partage la table, mais à des conditions qu'il n'a pas choisies : 25 % de la part pour lui, 75 % pour le rouge. Si son score de nourriture finit sous le seuil de survie, il ne verra pas le jour suivant.
Multipliez cette scène par cent, ajoutez quatre espèces aux règles différentes, laissez tourner deux cents jours, et vous obtenez Sim, un simulateur d'évolution que j'ai écrit en TypeScript pour le faire tourner directement dans le navigateur. Pas de moteur, pas de framework lourd. Juste un canvas 2D, une grille logique de 100 × 100 et quelques règles bien posées.
J'avais envie d'un projet où la complexité émerge de règles simples. Pas une IA, pas un réseau de neurones, pas d'apprentissage. Juste des entités qui suivent quatre ou cinq instructions et qui, en interagissant, finissent par produire des cycles, des extinctions, des dominations, des équilibres précaires.
C'est l'idée que Conway avait popularisée avec le Jeu de la Vie, mais en plus incarné : ici les créatures ont une espèce, une vitesse, une faim. Elles cherchent, marchent, mangent, dorment. À la fin de chaque journée, on regarde qui a mangé assez pour survivre et qui a mangé assez pour se reproduire.
feedScoreToute la simulation tient sur un seul chiffre par créature : son feedScore, compris entre 0 et 1. Il est mis à zéro chaque matin et représente la quantité de nourriture qu'elle a réussi à obtenir pendant la journée.
Le matin suivant, deux jets de dés successifs :
Ce qui donne, en pratique, cinq paliers :
| Survie | Reproduction |
|---|---|---|
0 | 0% | - |
0.25 | 50% | - |
0.5 | 100% | - |
0.75 | 100% | 50% |
1 | 100% | 100% |
Une créature qui mange un repas entier survit et se reproduit, à coup sûr. Une créature qui se contente d'un quart de repas a la moitié des chances de voir le lendemain. Une créature qui n'a rien mangé meurt, point.
Cette simplicité est volontaire. Plus la règle est lisible, plus le comportement émergent qu'elle produit est intéressant à observer.
Les créatures se partagent les ressources, mais elles ne sont pas égales devant la table. Quand deux d'entre elles mangent la même unité de nourriture, c'est une matrice de distribution qui décide qui repart avec quoi.
Partage la nourriture. Perd face aux raiders et aux titans.
Vole la nourriture. Bat les seekers, perd contre les titans.
Le plus rapide. Bat les seekers, résiste aux raiders.
Dominant, mais stérile avec les autres titans.
Rencontre | Espèce A | Espèce B |
|---|---|---|
Seeker × Seeker | 0.5 | 0.5 |
Seeker × Raider | 0.25 | 0.75 |
Seeker × Swift | 0.5 | 0.5 |
Seeker × Titan | 0.25 | 0.75 |
Raider × Raider | 0 | 0 |
Raider × Swift | 0.25 | 0.75 |
Raider × Titan | 0 | 1 |
Swift × Swift | 0.5 | 0.5 |
Swift × Titan | 0.25 | 0.75 |
Titan × Titan | 0 | 0 |
Chaque ligne raconte une petite histoire. Deux Raiders qui se rencontrent se battent et perdent tout. Deux Titans qui se croisent sont stériles entre eux. Un Swift face à un Raider défend mieux sa part qu'un Seeker pacifique. Et le Titan, dominant face à tous, paye sa puissance par une incapacité à se reproduire avec ses semblables.
Les espèces définies dans le code :
Quatre couleurs, quatre vitesses, quatre rôles dans l'écosystème. Le reste du gameplay vient de la façon dont ces règles se télescopent quand cent créatures les appliquent en même temps.
À chaque frame, chaque créature qui cherche à manger doit trouver la source de nourriture la plus proche qui n'est pas déjà pleine. Naïvement, ça donne du O(n × m) par frame : 200 créatures qui regardent 200 nourritures, soit 40 000 distances calculées soixante fois par seconde. Le navigateur sature très vite.
La solution classique pour ce genre de problème, c'est une grille spatiale. On découpe le monde en cellules de taille fixe, on indexe chaque nourriture dans sa cellule, et au lieu de scanner tout, on cherche en anneaux concentriques autour de la créature jusqu'à trouver une cible.
L'astuce qui change tout, c'est la ligne if (r > 1 && (r - 1) * this.cellSize > bestDist) break;. Dès qu'on a trouvé une candidate, on arrête de chercher dans des cellules qu'on sait géographiquement incapables de contenir mieux. En pratique, sur la majorité des créatures, la recherche s'arrête au premier ou deuxième anneau. On passe d'un coût linéaire à quelque chose qui se comporte comme O(1) amorti tant que la densité de nourriture reste raisonnable.
Une nourriture peut accueillir au maximum deux convives en même temps. Quand une troisième créature arrive, elle est renvoyée chercher ailleurs. Cette petite contrainte change tout : elle évite que toute la population s'agglutine sur la première source et redistribue mécaniquement les créatures sur la carte.
Quand le repas est terminé, la matrice de distribution est appliquée. Les deux créatures reçoivent leur part respective et passent en sleeping. Si une seule a mangé, elle prend tout : feedScore = 1. Sinon, le partage suit la table décrite plus haut.
Le plus satisfaisant dans ce projet, c'est qu'on n'a pas codé les dynamiques de population. On a codé quatre règles locales, et c'est l'interaction entre elles qui produit les courbes qu'on observe dans le panneau de stats.
Voici quelques motifs qu'on voit revenir.
L'extinction des Raiders sans Seekers. Lancez 30 Raiders sans aucune autre espèce. Au premier jour, tout va bien. Au deuxième, ils commencent à se croiser sur les mêmes nourritures et perdent tout (Raider × Raider = 0/0). En cinq ou six jours, la population s'effondre. Les Raiders sont des prédateurs structurels : ils ont besoin de proies pour exister.
L'équilibre Seeker / Swift. Avec une population mixte de Seekers et de Swifts, la simulation atteint souvent un équilibre dynamique. Les Swifts mangent plus vite (ils arrivent les premiers), mais le partage 50/50 quand ils tombent sur la même nourriture évite leur explosion. Les deux populations oscillent autour d'un palier.
Le piège des Titans. Ajoutez quelques Titans dans une population de Seekers, et au début, c'est la fête. Les Titans gagnent presque tous leurs duels. Mais comme ils ne peuvent pas se reproduire entre eux, leur croissance plafonne. Si les Seekers disparaissent, les Titans s'éteignent à leur tour, victimes de leur propre domination.
Aucun de ces comportements n'est codé en dur. Ils émergent de la matrice et du jet de dés du feedScore. C'est exactement ce qu'on cherche dans une simulation d'écosystème : des règles minimales, des résultats riches.
Le projet tourne sur TypeScript 6 strict, compilé et bundlé par Vite. Le rendu est en Canvas 2D natif, sans bibliothèque graphique. La seule dépendance runtime est Tweakpane pour les contrôles du HUD ; tout le reste est écrit à la main : grille spatiale, vecteurs, boucle de jeu, effets de spawn et de mort, panneau d'inspection cliquable. Le dt de la frame est multiplié par un facteur de vitesse réglable, ce qui découple complètement la simulation du framerate et permet d'accélérer jusqu'à ×50 sans casser la physique.
Trois choses, vraiment.
Une, l'optimisation a un sens même sur du jouet. Sans la grille spatiale, la simulation rame dès 80 créatures. Avec, on monte à 500+ sans broncher. La différence se mesure en secondes par frame.
Deux, les règles asymétriques sont plus intéressantes que les règles symétriques. Si toutes les espèces partageaient 50/50, on aurait une seule grosse population stable et ennuyeuse. C'est la matrice biaisée qui crée du mouvement.
Trois, un bon projet personnel se reconnaît à ce qu'on a envie d'y revenir. Sim me donne encore des idées un mois après l'avoir mis en ligne : ajouter un cinquième type, jouer sur la mémoire des créatures, faire muter les vitesses d'une génération à l'autre. La porte reste ouverte.