Développer Wakfuli : deux ans de décisions
Wakfuli, vu de l'extérieur, c'est une application web où l'on assemble et partage des builds de personnage pour un MMO. Vu de l'intérieur, c'est un pipeline de données qui se trouve avoir une interface. Presque toutes les décisions qui ont compté sur deux ans venaient de là, pas du front.
Deux ans, côté rythme, ça ressemble à ça : quand et combien j'ai commité sur wakfuli-builder, le dépôt principal du produit.
Ça faisait des mois que je repoussais ce retour, et à chaque fois pour la même raison : je n'arrivais pas à décider de quoi il parlait vraiment. J'ai fini par l'admettre. Ce que j'ai construit n'est pas l'écran qu'on voit, c'est la machinerie qui le nourrit en chiffres justes.
La contrainte qui a tout façonné
L'idée de départ tenait en une phrase : aider un joueur à optimiser son build, avec des chiffres justes. Le mot important, c'est justes. Un optimiseur qui affiche des valeurs approximatives ne sert à rien, il induit en erreur avec l'autorité d'une interface propre.
Le problème, c'est que ces chiffres ne m'appartiennent pas. Ils vivent dans un jeu que je ne contrôle pas, livré sous forme de fichiers binaires opaques, et qui change à son propre rythme. À chaque patch, des effets sont ajoutés, des valeurs bougent, des mécaniques apparaissent. Ma seule certitude, c'était que la donnée serait toujours en mouvement et jamais fournie proprement.
Cette contrainte a façonné le reste. Elle explique pourquoi j'ai passé bien plus de temps sur la chaîne qui produit les données que sur les écrans qui les affichent.
À retenir
Le produit n'est jamais meilleur que la donnée qu'il montre. Sur un outil de calcul, l'interface est la partie facile, la donnée juste et à jour est le vrai produit.
Isoler ce qui bouge de ce qui doit rester stable
Le socle, c'est un monorepo : un front Next.js, un back AdonisJS sur Postgres et Redis. Rien d'exotique. La seule décision d'architecture que je défends vraiment, c'est d'avoir structuré le back en hexagonal, avec pour chaque domaine métier une découpe domain / application / adapters / services.
L'intuition venait directement de la contrainte. Puisque la donnée de jeu allait bouger sans arrêt, je voulais que cette instabilité reste confinée aux bords, dans les adapters, sans remonter contaminer la logique métier. Un port déclare ce dont un service a besoin, un adapter branche l'ORM derrière. Quand un format change, je touche l'adapter, pas le domaine.
// application/ports.ts : le domaine déclare son besoin, sans savoir d'où ça vient
export interface SkinRepository {
findById(id: number): Promise<Skin | null>
search(filter: SkinFilter): Promise<Skin[]>
}Je ne vais pas prétendre que c'est parfait partout.
Le piège
L'hexagonal peut virer au théâtre de dossiers. Dans Wakfuli, certaines entités du domaine sont encore des modèles de l'ORM, pas des objets purs : la frontière protège l'accès à la donnée, pas l'entité elle-même. Et un service se type parfois sur la classe concrète de l'adapter plutôt que sur l'interface, à cause du conteneur d'injection. Je vis avec, mais c'est un vrai écart entre la théorie et le code.
Le signe que la découpe n'est pas dogmatique : le module qui expose des données de référence en lecture seule n'a ni port ni adapter.1 La discipline s'applique où il y a une frontière à défendre, pas partout par principe.
- Il n'y a rien à isoler là, aucune règle métier à protéger, donc je n'ai pas forcé le moule. ↩
Traiter les effets comme des données, pas comme du code
Le cœur métier de Wakfuli, c'est le système d'effets. Un sort ou un objet, ce n'est pas « inflige X ». C'est une petite liste d'effets appliqués dans un ordre, chacun pouvant lire l'état laissé par le précédent. Le sens de chaque effet est décrit par un langage de gabarits que le jeu n'a jamais documenté et qu'il a fallu reconstruire par observation.
La tentation, au début, c'est de coder chaque cas à la main. On s'en sort pour les dix premiers. Au trentième, avec les conditions, les seuils et les références croisées, c'est ingérable. La bonne abstraction, c'est de traiter l'effet comme une donnée, un arbre qu'un moteur générique résout, et non comme du code spécifique.
Et c'est là qu'est tombée la décision la plus contre-intuitive du projet. Mon réflexe initial était d'élaguer à la construction : jeter les effets marqués masqués ou statiquement inatteignables, pour ne garder qu'un arbre propre. Mauvaise idée.
Arbre propre, mais des tooltips cassés en production : un titre parent référence parfois un paramètre porté par un effet que j'avais jeté. Le texte perdait sa valeur, ou affichait un calcul brut non résolu.
Tout conservé dans le dump, filtrage au moment de l'affichage, quand le contexte est enfin connu. Plus lourd à transporter, mais correct.
L'endroit où l'on décide de jeter de l'information compte autant que la décision elle-même. Jeter tôt, c'est rapide et faux. Jeter au dernier moment, c'est un peu plus coûteux et juste.
La donnée : là où tout se complique
Si tu ne lis qu'une section, c'est celle-ci. C'est l'équivalent, pour Wakfuli, de ce qu'est le réseau pour un jeu en ligne : l'endroit où les jolies théories rencontrent la réalité. Ici, la réalité, c'est un jeu qui se met à jour quand il veut et une base de prod qui doit suivre sans casser.
Le principe qui a fini par tout tenir, c'est qu'un numéro de version se propage dans un seul sens à travers toute la chaîne. Un patch produit de nouveaux artefacts versionnés, l'étage suivant les consomme et publie sa propre version, et ainsi de suite jusqu'à la base du site. À aucun moment un étage en aval ne devine une donnée qu'un étage en amont aurait dû lui fournir déjà résolue.
Le moment le plus délicat, c'est le rechargement de la base de prod. « Recharger des données dans une base de prod » est exactement le genre de phrase qui, mal exécutée, se termine par une base vide un samedi soir. La parade a été de ne jamais restaurer directement en prod.
- 1
Sauvegarder
L'état courant, avant de toucher à quoi que ce soit.
- 2
Restaurer en staging
D'abord dans une base jetable, jamais en prod du premier coup.
- 3
Valider
Compter les lignes en staging, et abandonner là si quelque chose est vide.
- 4
Basculer, puis vérifier
Seulement alors restaurer en prod, et refaire un dernier contrôle des comptes.
// Le garde-fou : on abandonne avant de toucher la prod si le staging est vide
const stagingEffects = await countRows(staging, 'effects')
if (stagingEffects === 0) {
throw new Error('Staging vide, restauration prod annulée. Backup intact.')
}Il y a aussi une leçon empruntée mot pour mot aux jeux en réseau.
Ne jamais faire confiance à la source
Le dump extrait du jeu contient des artefacts internes, des objets de test ou d'administration qui n'ont jamais été destinés au public. La première version affichait tout. La bonne architecture traite la donnée extraite comme non fiable : un tier public filtré par une liste d'autorisation, un tier interne complet, et par défaut un nouvel élément reste exclu du public tant qu'on ne l'a pas validé.
Optimiser : mesurer avant de deviner
La phrase la plus chère du projet, c'est « je pense que c'est lent à cause de X ». À chaque fois que je l'ai dite, je me suis trompé. Le vrai coupable n'était jamais celui que je soupçonnais.
L'exemple que je préfère, c'est la recherche par couleur. Le filtre d'apparence laisse trier un catalogue par proximité de teinte. La version naïve comparait des codes hex en mémoire applicative, sur des milliers d'items, à chaque requête. Lente, et perceptuellement fausse, parce que deux hex numériquement proches peuvent paraître très différents à l'œil.
La bonne réponse déplaçait le travail au bon endroit. À l'ingestion, je précalcule pour chaque couleur sa valeur perceptuelle, l'espace Labespace colorimétrique où une distance euclidienne correspond à un écart visuel, contrairement au RGB, stockée dans une colonne indexée. La proximité devient alors une distance géométrique que la base résout avec un index spatial GiSTGeneralized Search Tree, l'index Postgres pour les données multidimensionnelles, au lieu d'un tri en O(n) côté application.
-- La base fait la géométrie, pas l'application
SELECT id
FROM items
ORDER BY color_lab <-> cube(ARRAY[$1, $2, $3])
LIMIT 20;Note
Aucune de mes vraies optimisations n'était astucieuse. Précalculer à l'ingestion, indexer la bonne chose, arrêter de recalculer tout le catalogue quand une seule ligne change. Des évidences, une fois le profil sous les yeux. La compétence n'était pas d'optimiser, c'était de mesurer d'abord.
Les erreurs que je ne referais pas
La première, avoir traité la donnée comme une couche tardive. J'ai commencé par les écrans, en me disant que je brancherais les vraies données plus tard. C'était l'erreur de conception centrale. Le pipeline n'est pas une plomberie qu'on pose sous un produit fini, c'est la contrainte qui aurait dû informer chaque décision depuis le premier jour. Exactement comme le réseau dans un jeu en ligne.
La deuxième, avoir voulu généraliser trop tôt. J'ai construit des abstractions pour des cas qui ne sont jamais arrivés, et elles m'ont ralenti sur ceux qui sont arrivés. L'hexagonal appliqué partout au début, avant de savoir où étaient les vraies frontières, en fait partie.
La troisième, avoir négligé l'outillage. Le suivi des formats à travers les versions du jeu, et le renommage propre des champs qui bougent à chaque patch, je les ai longtemps faits à la main. L'outil qui automatise ça est venu trop tard. Construit plus tôt, il m'aurait fait gagner des semaines à chaque mise à jour.
Ce que j'en retiens
Wakfuli n'est pas parfait, et ça me va. Ce que le projet m'a appris tient moins dans le code fini que dans quelques principes que j'ai fini par ne plus négocier.
Points clés
- Isoler ce qui bouge de ce qui doit rester stable.
- Traiter les données comme des données, jamais comme du code.
- Décider où l'on jette de l'information avec autant de soin que le reste.
- Ne jamais faire confiance à une source qu'on ne contrôle pas.
- Mesurer avant d'optimiser, toujours.
Le fil rouge, s'il y en a un, c'est que j'ai passé deux ans à croire que je construisais une application web, alors que je construisais un système de données qui devait rester juste pendant qu'une source instable changeait sous mes pieds. Le jour où j'ai accepté ça, la plupart des décisions se sont mises à couler de source.