Ce n’est pas un titre putaclic. Je l’ai vraiment fait. Et c’est pas juste une expérience web cette fois. Non. C’est un vrai jeu, entier, librement explorable, en 3D, directement dans ton navigateur ! Tu me crois pas ?
TLDR
Ho, je sais que ton temps est précieux. Voici la version condensée.
J’ai codé un jeu gratuit en 3D dans le navigateur, librement explorable, à travers des univers infinis générés de manière procédurale en JavaScript. L’objectif ? Aller d’univers en univers et découvrir l’origine de tout.
C’est carrément une histoire en quatre chapitres avec une révélation épique à la fin.
Avant d’aller plus loin dans la lecture de cet article, arrête tout, ouvre Chrome, mets-toi en plein écran, prends des pop-corn et joue à ACROSS THE MULTIVERSE !

Pas le temps de jouer ?
Tu préfères en profiter sur un PC et pas sur un portable ?
Ou tu veux tout simplement en voir plus avant d’y jouer ?
Pas de problème.
J’ai fait une bande-annonce de lancement sur YouTube ! Ça dure seulement 3 min. Ça montre beaucoup de choses. Par contre attention, c’est extrêmement spectaculaire !
J’ai mis beaucoup de mon temps, de mon âme et de mes compétences dans la création de ce jeu gratuit pour les internets.
Si tu as cinq secondes devant toi pour le partager, ça serait merveilleux.
En attendant, il est temps de parler de l’éléphant qui clignote au milieu de la pièce.
Mais comment j’ai fait un truc pareil ?
Talk is cheap. Show me the code
Je sais que beaucoup d’entre vous préfèrent tout de suite plonger dans le code. Avant même de lire mes si belles explications. Et je comprends tout à fait cette démarche.
Pour les plus impatients, voici le code source entier du jeu.
C’est bien sur open source sous licence MIT (pour le code).
Je conseille quand même de suivre l’histoire de la création progressive du projet via cet article. Ça va donner plus de contexte. Et surtout faire beaucoup plus de sens.
Comment construire l’univers ?
Avant même de commencer, il faut savoir que j’utilise la librairie JavaScript Three.js. Cette librairie permet d’utiliser l’api WebGL via JavaScript pour faire du rendu 3D dans le navigateur.
Il est possible que tu ne captes rien à la phrase d’avant et/ou que tu ne connaisses pas Three.js.
Heureusement j’ai pensé à tout le monde.
J’ai fait un article dédié à Three.js et au rendu 3D en JavaScript.
Cet article va te permettre de tout de suite comprendre la base du projet en seulement 5 minutes.
Bref, revenons à nos moutons.
Comment construire l’univers ?
Clairement le problème est trop gros.
Je ne pouvais pas attaquer frontalement ce problème. Et ce n’est pas comme ça qu’on fait quand on est développeur. Il y a un seul réflexe à avoir quand est face à un problème énorme et complexe.
Réduire la complexité
Construire l’univers ? Trop compliqué. Je ne sais même pas par où commencer. OK, alors réduisons ça.
Construire la Voie lactée ? Toujours trop compliqué, réduisons ça.
Construire notre système solaire ? Non, mais non. C’est trop compliqué. Réduisons ça.
Construire un espace vide rempli d’étoile ?
Ha ! Ça me semble faisable ça ! Un simple champ d’étoiles dans les ténèbres de l’espace.
En y réfléchissant un petit peu, je me dis que ce problème est vraiment simple. Ça veut dire que j’ai assez réduit la complexité.
Il est temps de s’y mettre.
Comment construire un simple champ d’étoiles ?
À partir de ce moment-là, j’ai toujours utilisé une image de référence. Une photo ou une représentation réelle de ce que je voulais recréer. Une image à regarder pour me rapprocher le plus possible d’un rendu réaliste.
Pour le champ d’étoiles, j’avais décidé de prendre une photo du satellite Hubble.

Je me dis que j’ai juste à afficher des points blancs de façon random dans un espace noir.
C’est très facile.
Commençons par créer un espace vide, noir, et mettons une caméra dedans.
const scene = new THREE.Scene()
const renderer = new THREE.WebGLRenderer({ powerPreference: "high-performance", antialias: false, stencil: false, depth: false })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setClearColor(0xffffff, 0)
renderer.domElement.id = 'starfield'
document.body.appendChild(renderer.domElement)
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(0, 10, 0)
camera.lookAt(0, 0, 0)
function animate(time) {
renderer.render(scene, camera)
requestAnimationFrame(animate)
}
animate()
OK. Super ton truc. Tout ça pour afficher un écran noir ?!
Oui. Soyons patients. Il faut bien commencer quelque part.
Maintenant, plus dur.
Affichons des points blancs de façon random dans cet espace noir. Tu sais faire ? Moi je savais pas faire. Donc je suis allé voir la doc.
Et bingo !
Il existe une classe dédiée à ça : Points.
Parfait, suivons la doc et écrivons ça.
function getStarsGeometry() {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(getStarsRandomVertices(), 3))
return geometry
}
function getStarsRandomVertices(verticesNumber = 10000) {
const vertices = []
for (let i = 0; i < verticesNumber; i++) {
const x = 2000 * Math.random() - 1000;
const y = 2000 * Math.random() - 1000;
const z = 2000 * Math.random() - 1000;
vertices.push(x, y, z);
}
return vertices
}
function getStarsMaterial() {
const starSprite = new THREE.TextureLoader().load('../images/star.png');
const starMaterial = new THREE.PointsMaterial({ size: 5, sizeAttenuation: true, map: starSprite, alphaTest: 0.5, transparent: true });
return starMaterial
}
function getStars() {
const stars = new THREE.Points(getStarsGeometry(), getStarsMaterial())
return stars
}
scene.add(getStars())
Dans ce bout de code, ce qui va vraiment faire la magie c’est la fonction getStarsRandomVertices.
Notre champ d’étoiles (ici représenté par new THREE.Points) a besoin de deux choses.
1 : Les coordonnées de chacun des points à afficher
2 : Le material de chacun des points. C’est à dire ce qu’on va afficher (pour simplifier) pour chacun des points.
Les coordonnées sont gérées par getStarsRandomVertices.
Notre caméra est placée aux coordonnées 0,0,0. On veut des étoiles tout autour de nous. Donc il faudrait que nos points soient placés entre les coordonnées -1000 et 1000. Tout autour de nous.
Pour faire ça, on va faire un calcul tout simple.
2000 * Math.random() – 1000.
Ce bout de math hyper simple nous donne une valeur random (math.random est pas vraiment random mais admettons) entre -1000 et 1000. On met ce calcul dans chacun des axes (x, y, z) et le tour est joué !
Le material est géré par getStarsMaterial
Pas grand-chose à dire ici. On va simplement utiliser une image d’un rond blanc comme texture et l’appliquer. Pour le moment on n’a pas besoin de grand-chose.
On assemble les deux dans la fonction getStars et on a résolu notre problème.
Bon, pour le moment ça donne juste une image avec des points blanc statique en 2D.
C’est un peu tout nul. On peut mieux faire.
C’est le moment d’itérer sur ce résultat.
Amélioration via itération
Commençons simplement par animer les choses.
L’idée, pour le moment, c’est de simplement bouger la caméra par rapport au mouvement de la souris. Quand j’ai fait ça, je voulais juste bien me rendre compte que je faisais désormais de la 3D.
Écrivons ça.
document.body.addEventListener('pointermove', onPointerMove);
function onPointerMove(event) {
if (event.isPrimary === false) return
mouseX = event.clientX - windowHalfX
mouseY = event.clientY - windowHalfY
}
function animate(time) {
renderer.render(scene, camera)
camera.position.x += (mouseX - camera.position.x) * 0.05
camera.position.y += (-mouseY - camera.position.y) * 0.05
requestAnimationFrame(animate)
}OK c’est cool, ça bouge, c’est de la 3D, on est content.
Maintenant, rendons ça encore plus interactif. Ce que j’aimerais moi c’est me balader là-dedans. Librement. Comme dans un jeu FPS, avec la souris et le clavier.
À ce moment-là, j’avais deux choix :
- Soit je réécrivais moi-même tout un système de navigation FPS.
- Soit je reprenais une classe de contrôle FPS déjà toute faite par Three.js.

J’ai évidemment choisi de reprendre le code déjà fait.
Il est testé et utilisé par beaucoup de monde. Je te conseille de faire la même chose quand cette situation t’arrive. À part si tu es dans une démarche d’apprentissage, ça ne sert à rien de réinventer la roue.
Par contre, les features proposées par le module ne me suffisaient pas.
J’en voulais plus.
Je voulais un système de vélocité. Donner aux joueurs cette impression d’accélération et de décélération. Du coup, il fallait que j’utilise le module existant et que j’étende ces possibilités dans ma propre classe.
OK, écrivons ça.
import { PointerLockControls } from './PointerLockControls.js'
import * as THREE from 'three'
export default class Controls {
constructor (camera, parameters) {
this.parameters = parameters
this.camera = camera
this.pointerLockControls = new PointerLockControls(this.camera, document.body)
this.velocity = new THREE.Vector3()
this.direction = new THREE.Vector3()
this.moveForward = false
this.moveBackward = false
this.moveLeft = false
this.moveRight = false
}
onKeyDown (event) {
if (this.pointerLockControls.isLocked) {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
case 'KeyZ':
this.moveForward = true
break
case 'ArrowLeft':
case 'KeyA':
case 'KeyQ':
this.moveLeft = true
break
case 'ArrowDown':
case 'KeyS':
this.moveBackward = true
break
case 'ArrowRight':
case 'KeyD':
this.moveRight = true
break
}
}
}
onKeyUp (event) {
if (this.pointerLockControls.isLocked) {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
case 'KeyZ':
this.moveForward = false
break
case 'ArrowLeft':
case 'KeyA':
case 'KeyQ':
this.moveLeft = false
break
case 'ArrowDown':
case 'KeyS':
this.moveBackward = false
break
case 'ArrowRight':
case 'KeyD':
this.moveRight = false
break
}
}
}
handleMovements (timePerf, prevTimePerf) {
const delta = timePerf - prevTimePerf
this.direction.z = Number(this.moveForward) - Number(this.moveBackward)
this.direction.x = Number(this.moveRight) - Number(this.moveLeft)
if (this.moveForward || this.moveBackward) {
this.velocity.z -= this.direction.z * this.parameters.controls.velocity * delta
}
if (this.moveLeft || this.moveRight) {
this.velocity.x -= this.direction.x * this.parameters.controls.velocity * delta
}
this.pointerLockControls.moveRight(-this.velocity.x * delta)
this.pointerLockControls.moveForward(-this.velocity.z * delta)
}
}Et juste comme ça, on a construit un champ d’étoiles explorable comme dans un FPS. Tiens aller, je te mets une codesandbox, te peux jouer en live dedans.
Tu as juste à cliquer dans les étoiles pour passer en mode FPS.
Pas mal non ?
C’est pas mal.
Mais il est temps de s’intéresser aux choses sérieuses.
Comment simuler l’infini ?
Jusqu’à maintenant on a juste placé des points autour du joueur. Mais il suffit qu’il bouge un peu et il voit la supercherie. C’est un peu pourri.
Alors comment on fait pour scaler ça ?
Comment on le fait d’une façon qui fait du sens ?
Et surtout comment on le fait sans exploser la mémoire et/ou sans descendre sous les 60FPS.
C’est maintenant que commence le vrai projet.
La grille
À ce moment-là, j’ai arrêté de toucher à mon clavier.
Pour coder une solution pour ce genre de problème, on ne peut pas « tâtonner jusqu’à y arriver ». Non. Il faut résoudre le problème de façon conceptuelle -sur papier- avant de faire quoique soit.
Sinon tu perds ton temps.
Et je déteste perdre mon temps.
C’est le moment de faire des dessins.
Une des premières idées qui m’était venue à l’esprit était le concept de grille pour représenter l’espace.
Concrètement, l’espace serait une grille infinie. Le joueur irait de case en case pour voir ce qu’elles contiennent. Et dans chaque case on met ce qu’on veut.
Des étoiles, des nébuleuses, des galaxies, des soleils, des trous noirs, ce qu’on veut !
Pour mieux comprendre ce que je raconte, je vais te faire un petit dessin.
Résoudre le problème
Commençons par représenter notre code existant. Tout de suite, on a notre joueur dans la case d’origine 0,0,0 qui est entouré d’étoiles. S’il s’éloigne un peu il se retrouve dans le noir complet.
Conceptuellement, ça ressemble à ça.
- Le petit bonhomme représente notre joueur.
- Les étoiles bleues représentent les points déjà placés aléatoirement autour de lui.
- Les chiffres en rouge sont les coordonnées de chaque case.

Normalement, jusqu’ici, rien de complexe.
Et c’est bien l’objectif ! Je veux à tout prix garder cette simplicité. C’est complexe de faire simple. Essayons de garder cette simplicité au niveau de l’algorithme de mise à jour de la grille.
Il nous faut deux grandes fonctions principales.
La fonction d’initialisation.
Cette fonction va créer la matière dans la case d’origine et dans toutes les cases autour du joueur.
L’avantage de la phase d’initialisation c’est qu’on peut se permettre des actions coûteuses. Tant que le joueur n’est pas dans une phase de gameplay, on est assez libre.
Conceptuellement, ça ressemble à ça.
- Les étoiles vertes représentent les points qu’on crées dynamiquement lors de la fonction d’initialisation

La fonction de mise à jour.
Cette fonction va mettre à jour la grille seulement quand le joueur franchit la frontière entre deux cases.
Avec cette fonction on veut deux choses :
- Supprimer le contenu des cases trop éloignées du joueur
- Créer le contenu des cases où le joueur est le plus susceptible d’aller
Conceptuellement, ça ressemblerait à ça.
- Les étoiles bleues représentent les points déjà placés
- Les étoiles vertes représentent les points qu’on crées
- Les étoiles rouges représentent les points qu’on supprime

Et juste comme ça, on a géré la simulation de l’infini.
Le joueur peut aller partout où il veut. Dans le sens et de la façon dont il veut. Il ne verra pas la supercherie. Partout où il ira, on lui aura déjà préparé à l’avance des choses merveilleuses à regarder.
J’aime cette solution, car il y a plusieurs avantages.
- C’est relativement performant
Le fait qu’on crée les contenus des cases à la volée et surtout qu’on en supprime en même temps soulage beaucoup la mémoire. De plus, on ne crée que le minimum de cases nécessaires à chaque fois.
- On n’a pas à gérer la direction du joueur
Peu importe la direction du joueur, l’algorithme sera le même. En effet, on n’a pas besoin de savoir quelles sont les cases en face du joueur. On veut juste savoir quelles sont les cases autour du joueur qui sont vides ! Et donc il peut aller dans le sens qu’il veut, notre algorithme fera exactement la même chose.
- C’est simple à gérer
Pas besoin de structure de données tout droit venue des enfers type graphe ou arbre comme un octree. Non, non. Olala laissez-moi tranquille. Un tableau, deux hashmap et ça suffit. Pas de prise de tête. On reste simple.
Bon bah, y’a plus qu’a.
Coder la solution
On va créer cette fameuse classe qui va gérer la grille. Par souci de longueur et de simplification, je n’explique pas tout. Et surtout, je ne montre pas tout.
Tu as le source code entier si tu veux tout voir.
On regarde juste les parties importantes aujourd’hui.
import MultiverseFactory from '../procedural/MultiverseFactory'
export default class Grid {
constructor (camera, parameters, scene, library) {
this.camera = camera
this.parameters = parameters
this.scene = scene
this.library = library
this.activeClusters = new Map()
this.queueClusters = new Map()
this.multiverseFactory = new MultiverseFactory(this.scene, this.library, this.parameters)
}
getCurrentClusterPosition () {
const currentCameraPosition = this.getCurrentCameraPosition()
const xCoordinate = Math.trunc(currentCameraPosition.x / this.parameters.grid.clusterSize)
const yCoordinate = Math.trunc(currentCameraPosition.y / this.parameters.grid.clusterSize)
const zCoordinate = Math.trunc(currentCameraPosition.z / this.parameters.grid.clusterSize)
const currentClusterPosition = `${xCoordinate},${yCoordinate},${zCoordinate}`
return currentClusterPosition
}
getCurrentCameraPosition () {
this.camera.updateMatrixWorld()
return this.camera.position
}
getClustersStatus (currentCluster) {
const clustersNeighbour = this.getNeighbourClusters(currentCluster)
const clustersToPopulate = this._getEmptyClustersToPopulate(clustersNeighbour)
const clustersToDispose = this._getPopulatedClustersToDispose(clustersNeighbour, currentCluster)
return {
clustersNeighbour,
clustersToPopulate,
clustersToDispose
}
}
getNeighbourClusters (currentCluster) {
const neighbourClusters = [currentCluster]
const currentClusterArray = currentCluster.split(',')
const x = currentClusterArray[0]
const y = currentClusterArray[1]
const z = currentClusterArray[2]
// forward
neighbourClusters.push(`${x},${y},${Number(z) - 1}`)
// backward
neighbourClusters.push(`${x},${y},${Number(z) + 1}`)
// right
neighbourClusters.push(`${Number(x) + 1},${y},${z}`)
// left
neighbourClusters.push(`${Number(x) - 1},${y},${z}`)
// forward right
neighbourClusters.push(`${Number(x) + 1},${y},${Number(z) - 1}`)
// forward left
neighbourClusters.push(`${Number(x) - 1},${y},${Number(z) - 1}`)
// backward right
neighbourClusters.push(`${Number(x) + 1},${y},${Number(z) + 1}`)
// backward left
neighbourClusters.push(`${Number(x) - 1},${y},${Number(z) + 1}`)
return neighbourClusters
}
disposeClusters (clustersToDispose) {
for (const clusterToDispose of clustersToDispose) {
let matter = this.activeClusters.get(clusterToDispose)
matter.dispose()
matter = null
this.activeClusters.delete(clusterToDispose)
}
}
addMattersToClustersQueue (matters, type = 'starfield', subtype = null) {
for (const clusterToPopulate of Object.keys(matters)) {
this.queueClusters.set(clusterToPopulate, {
type: type,
subtype: subtype,
data: matters[clusterToPopulate]
})
}
}
populateNewUniverse () {
const clusterStatus = this.getClustersStatus('0,0,0')
this.buildMatters(clusterStatus.clustersToPopulate)
}
renderMatters (position, cluster) {
const matter = this.multiverseFactory.createMatter(cluster.type)
matter.generate(cluster.data, position, cluster.subtype)
matter.show()
this.queueClusters.delete(position)
this.activeClusters.set(position, matter)
}
_getEmptyClustersToPopulate (neighbourClusters) {
const emptyClustersToPopulate = []
for (const neighbourCluster of neighbourClusters) {
if (!this.activeClusters.has(neighbourCluster)) {
emptyClustersToPopulate.push(neighbourCluster)
}
}
return emptyClustersToPopulate
}
_getPopulatedClustersToDispose (neighbourClusters, currentCluster) {
const populatedClustersToDispose = []
for (const activeClusterKey of this.activeClusters.keys()) {
if (currentCluster !== activeClusterKey && !neighbourClusters.includes(activeClusterKey)) {
populatedClustersToDispose.push(activeClusterKey)
}
}
return populatedClustersToDispose
}
}Et ça marche !
Le contenu des cases est ajouté à la volée quand le joueur s’approche. L’illusion est presque parfaite. Je dis presque parce que malheureusement, on a un gros problème.
Je sais que ça ne se voit pas énormément dans la vidéo.
Les performances à la mise à jour de la grille sont désastreuses.
Ça freeze l’image, c’est juste dégueulasse et injouable en l’état.
Il est donc temps de diagnostiquer puis d’optimiser.
Diagnostiquer & Optimiser
Quand un problème de performance arrive dans une application, le premier réflexe c’est de diagnostiquer avant de faire quoique ce soit.
Diagnostiquer
Dans le cas d’une application web comme la nôtre, on va faire ça avec la chrome dev tools. F12, onglet « Performance » puis CTRL+E pour enregistrer ce qui se passe. Puis on utilise l’application normalement avant d’arrêter l’enregistrement et d’analyser les résultats.
En faisant ça, très vite, j’ai compris ce qu’il se passait.

On a des gros drop de FPS car on essaye de faire trop de choses en meme temps.
On fait trop de choses pour JavaScript. JavaScript étant single-threaded, ca pardonne pas. On en demande trop, dans un trop petit laps de temps, pour un seul thread.
Tu te rappelles le calcul simple dont je te parlais au début ?
2000 * Math.random() – 1000
On fait ça 300 000 fois. En une frame.
Multiplier par 3 pour chaque axe (x, y x) des coordonnées.
Encore multiplié par 3 pour les trois nouvelles cases qu’on crée à chaque mouvement de case en case du joueur.
Et pour le moment, on fait juste un calcul simple pour les champs d’étoiles. Quand on va créer des nébuleuses ou des galaxies plus tard, les maths vont être beaucoup plus intensives.
C’est coûteux. Très couteux. Tellement couteux qu’on dépasse la limite des 16ms autorisés par frame pour une image fluide. On monte à 33ms. Ça bloque l’event loop, ça freeze l’image et ça devient injouable.
Si on laisse comme ça, notre joueur va lui aussi quitter le jeu en 33ms.
Optimiser
Pour éviter ça, j’ai deux solutions.
- Premièrement, on va se libérer de la limite single thread de JavaScript.
On va le faire en utilisant les Web Workers du navigateur. Je ne vais pas faire un cours là-dessus, c’est très connu, et la page MDN est extrêmement bien foutue pour les comprendre.
Concrètement, on va envoyer aux Web Workers tous les calculs lourds du jeu.
Ces calculs seront alors faits en tâche de fond, par le navigateur. L’objectif c’est de ne pas déranger notre thread principal. Il doit être chargé d’une seule chose : afficher les choses de façon fluide aux joueurs.
Une fois les calculs lourds faits, les Web Workers nous renvoient les résultats dans des événements. Notre thread principal n’a plus qu’à les afficher !
// in worker.js
self.onmessage = messageEvent => {
const heavyMath = _getHeavyMath()
self.postMessage(heavyMath)
}
function _getHeavyMath () {
const positions = []
const colors = []
// heavy math to process positions and colors of particles
return {
positions: new Float32Array(positions),
colors: new Float32Array(colors)
}
}
// in main.js
worker.onmessage = messageEvent => this.showData(messageEvent.data)Et juste comme ça, on divise par dix la charge !
Mais c’est pas suffisant. Pour avoir des performances excellentes, on va soulager un peu plus l’event loop.
- Deuxièmement, on va étaler les phases d’affichage des cases dans le temps.
En l’état, les calculs lourds sont bien faits dans les web workers. Mais il est très possible que l’affichage des trois cases soit demandé exactement au même moment. On veut éviter ça pour avoir une image parfaitement fluide.
Pour ce faire, on va utiliser un petit trick.
On va autoriser un seul affichage de champs d’étoiles au même moment via un boolean. Puis on va étaler les phases d’affichage de chaque case via un setTimeout. Ce qui veut dire que l’affichage de chaque case ne sera pas instantané. Il sera espacé de 50ms. Une par une.
50ms c’est énorme en termes de soulagement pour l’event loop.
Et c’est imperceptible par le joueur.
Exactement ce qu’il nous faut.
isRenderingClusterInProgress = true
const clusterTorender = grid.queueClusters.keys().next().value
setTimeout(() => {
grid.renderMatters(clusterTorender,
grid.queueClusters.get(clusterTorender))
isRenderingClusterInProgress = false
}, parameters.global.clusterRenderTimeOut)Et voilà !
Des champs d’étoiles infinis dans ton navigateur.
Elle est pas belle la vie ?
Et le reste ?
Si tu as jouer au jeu et/ou vu le trailer, tu vois bien qu’il manque l’explication pour 90% du contenu.
Les nébuleuses, les soleils, les trous noirs, les géantes rouges, les putains de trous de ver entre les univers, les quatre univers différents et le final incroyable !
En effet. Mais ce projet est énorme. Trop énorme pour en faire un seul article.
Une flopée d’articles (au moins quatre) sur le sujet arrive. On va s’attarder sur chacun des sujets pour en parler.
Je ne sais pas encore si je vais les poster sur ce blog. Je pense que ca ira plus sur mon profil dev.to. Ça va pas mal dépendre de votre réaction ici.
Épilogue
C’était le plus gros projet personnel que j’ai jamais fait. C’était incroyable à faire. Y’a eu des hauts, des bas, de la détresse et de l’émerveillement. Je vous laisse profiter du jeu. Pas sûr que je refasse un projet aussi gros tout de suite. Hésitez pas à le partager autour de vous, ça me ferait beaucoup plaisir.
Je vais aller dormir un peu maintenant.
clearfix
