Felt Dojo

Felt Dojo: Architecture

Complément à la présentation du projet. Comment le logiciel est assemblé, comment la production est assemblée, et pourquoi chaque choix a été fait plutôt que l'alternative évidente.

Traduit de la version anglaise.


La forme de l'ensemble, en un paragraphe

Un monolithe Spring Boot détient tout l'état de jeu en mémoire, pilote chaque table en direct sur son propre fil d'exécution, et pousse l'état vers les navigateurs par WebSocket. PostgreSQL conserve tout ce qui doit survivre à un redémarrage ; le front-end React est un paquet statique servi directement par Caddy et ne parle jamais à PostgreSQL. La production, c'est une seule machine virtuelle à 2 vCPU faisant tourner trois conteneurs Docker derrière un processus Caddy qui, lui, n'est pas conteneurisé. Il n'y a pas de file de messages, pas de couche de cache, pas de deuxième nœud, pas d'orchestrateur, et chacune de ces absences est une décision plutôt qu'un oubli.


Partie 1: Le logiciel

La décision centrale : une boucle de main est un fil d'exécution, pas une machine à états

Chaque table cash en direct et chaque tournoi possède un fil d'exécution en arrière-plan exécutant une simple boucle while : distribuer une main, mener les enchères, résoudre, faire une pause, recommencer. Quand c'est au tour de l'humain, la boucle se bloque sur une file d'attente jusqu'à ce que l'action arrive par le WebSocket.

L'alternative évidente est une machine à états événementielle : une table est une ligne, une action est un événement, un gestionnaire fait avancer le tout. C'est ainsi qu'on construirait ça pour passer à l'échelle horizontalement, et cela a été écarté délibérément.

Pourquoi le fil d'exécution gagne ici : le poker est intensément séquentiel et ses règles sont pleines de « et ensuite ». Un tour d'enchères qui se lit de haut en bas comme une boucle avec une lecture bloquante est énormément plus facile à rendre correct que la même logique décomposée en une douzaine de gestionnaires plus un champ d'état explicite, et la justesse est tout le produit : un bogue dans le moteur d'enchères déplace les jetons d'un joueur. La pile du fil d'exécution est la machine à états, maintenue par le langage plutôt qu'à la main.

Ce que ça coûte, dit clairement : l'état vit dans le tas d'une seule JVM, donc un deuxième nœud back-end est impossible sans sortir les sessions du processus. C'est le vrai plafond de mise à l'échelle, et il limite le nombre d'*utilisateurs* simultanés, pas la taille des tables ni celle des champs de tournoi.

Un tournoi pilote toutes ses tables depuis ce seul fil, séquentiellement, ce qui n'est abordable qu'à cause de la décision suivante.

Simulation à deux niveaux : seule la table regardée est réelle

Un tournoi de 10 000 participants ne fait pas tourner 1 100 vraies tables. Seule la table où l'humain est assis joue du poker en pleine fidélité ; toutes les autres sont avancées par un simulateur qui poste les antes et les blindes du vrai palier, attribue le pot, déclenche parfois une confrontation, et conserve les jetons exactement.

Le réalisme vient de l'utilisation de la vraie structure de blindes plutôt que d'une courbe de décroissance inventée, et c'est ce qui fait fondre le champ à un rythme crédible. Mesuré : 10 000 participants, 1 162 mains, environ six secondes de bout en bout.

La couture qui rend ça possible est un seul prédicat, un humain regarde-t-il cette table ?, et il est délibérément la porte unique pour trois choses distinctes : est-ce que la table joue du vrai poker ou est simulée, est-ce que l'état est diffusé du tout à ce cycle, et quelle est la durée de la pause entre les mains. Garder ces trois choses branchées sur un seul test est ce qui les empêche de diverger ; quand l'une d'elles a un jour été oubliée, un client reconnecté a regardé tout un tournoi abandonné se simuler jusqu'au bout en accéléré.

La même discipline régit la taille des diffusions. La charge utile de tournoi par main ne transporte que les noms de la table de l'humain plus des statistiques de synthèse, jamais tout le champ. Le classement complet vit derrière son propre point d'entrée paginé, récupéré à la demande plutôt que poussé à chaque main.

Les bots, structurellement

Trois niveaux derrière une seule interface, qui diffèrent par la range contre laquelle ils croient jouer plutôt que par la qualité de leurs seuils. La couche de stratégie renvoie une distribution d'actions avec des fréquences, pas une action unique, conçue ainsi dès le départ pour qu'un niveau quatrième niveau puisse être un échange de données plutôt qu'une réécriture, et pour que la future couche d'analyse (« Revue », pour les joueurs) dispose d'une interface où comparer l'action d'un humain.

Deux notes structurelles qui comptent plus qu'il n'y paraît :

Persistance : ce qui est durable et ce qui délibérément ne l'est pas

où ça vitsurvit à un redémarrage
Comptes, bankroll, historique de mains, statistiquesPostgreSQLoui
TournoisPostgreSQL, instantané tous les 10 cycles de mainsoui ; ils reprennent au démarrage
Matchs duplicate et leurs journaux d'actionsPostgreSQLoui
Tables cash et les jetons dessusPostgreSQL (cash_games, V25), instantané entre les mainsoui ; restaurées au démarrage, en pause

Les tables cash ont été la lacune honnête jusqu'au 2026-08-19, et ne le sont plus. Pendant la majeure partie du projet elles ne vivaient qu'en mémoire : un crochet d'arrêt encaissait chaque table active ou en pause vers sa bankroll lors d'un arrêt propre, ce qui couvrait les déploiements, compose down et SIGTERM, mais pas kill -9, un arrêt pour manque de mémoire, ni la mort de la machine. Cela s'était aggravé quand quitter une table a commencé à la mettre en pause plutôt qu'à la terminer, puisqu'une table survit dès lors couramment à la session qui l'a créée.

cash_games (V25) tient maintenant une ligne par table tant qu'il y a des jetons dessus : la boucle de main prend un instantané à chaque frontière entre deux mains, la création, la recave et la mise en pause écrivent immédiatement, et un service de restauration recrée au démarrage chaque ligne survivante sous forme de table en pause, avec ses tapis, ses noms et son bouton intacts. La ligne est supprimée quand la table se termine ou que le balayage d'inactivité l'encaisse : une ligne signifie « des jetons sont posés sur une table », et c'est la seule question à laquelle cette table existe pour répondre, d'où l'absence de lignes terminées, contrairement à tournaments.

Déployé le 2026-08-19. Le crochet d'arrêt qui encaissait chaque table lors d'un arrêt ordonné a été retiré dans la même fenêtre, délibérément : la persistance a supprimé sa raison d'être déclarée, donc un déploiement ne termine plus la table de personne : elle revient en pause, depuis le même instantané entre deux mains qu'un plantage aurait laissé. Le balayage horaire d'inactivité est désormais le seul règlement automatique, et un test vérifie l'*absence* de tout encaissement à l'arrêt, parce qu'en rajouter un compilerait, passerait tout le reste, et restaurerait silencieusement l'ancien comportement.

Deux décisions de schéma à connaître. Les objets de configuration sont stockés comme colonnes de texte JSON, non normalisées, ce qui rend l'ajout d'un champ gratuit à l'écriture et dangereux à la lecture, parce qu'une ligne écrite avant l'existence d'un champ le désérialise à null. Il y a maintenant un test qui retire chaque champ à tour de rôle et relit la ligne, plutôt qu'une convention que quelqu'un doit se rappeler. Et hand_stats est en cascade depuis hand_history au niveau du schéma, pas dans le code applicatif, précisément pour qu'un travail de purge ou de suppression RGPD écrit plus tard ne puisse pas laisser derrière lui un profil stratégique d'un joueur après la disparition des mains elles-mêmes. Une convention applicative est exactement ce qu'un tel travail saute discrètement.

Le front-end, et pourquoi il est ennuyeux exprès

React + TypeScript + Vite, compilé en paquet statique. Il ne contient aucune logique de jeu ; il affiche ce que le serveur diffuse et renvoie les actions. Le serveur décide de tout, y compris de choses qui pourraient être côté client : la composition de difficulté « surprends-moi » est calculée sur le serveur et n'est jamais envoyée au navigateur, parce que le but est de la dissimuler et qu'une valeur détenue par le client n'est pas dissimulée.

La seule vraie machinerie côté client est une file de révélation qui garde chaque street à l'écran pendant une durée fixe, quelle que soit la vitesse d'arrivée des messages ; sans elle, un déroulement rapide côté serveur fait défiler tout le board d'un coup. C'est à cause de cette file que la pause du serveur entre les mains doit être assez longue pour la vider, et le fait que ces deux constantes vivent dans deux langages sans rien qui les relie est un risque connu et documenté.

Les tests, et les deux choses qu'ils ne peuvent structurellement pas voir

Environ 1 672 tests back-end et 642 tests front-end, plus un travail d'intégration continue sur un vrai PostgreSQL pour la poignée de cas où le dialecte de la base est lui-même testé, et un travail de comparaison à un fichier de référence du comportement des bots, octet pour octet, à chaque poussée.

La partie intéressante, c'est ce que la suite est connue pour ne pas couvrir, parce que les deux ont été appris cher :


Partie 2: La production

Le matériel

Un seul Hetzner CX22 : 2 vCPU, 4 Go de RAM, 40 Go de disque. C'est tout le parc de production.

Il est délibérément petit. Le plafond mesuré est très au-dessus de la charge : une montée en charge sur la machine de production n'a trouvé aucune limite fonctionnelle jusqu'à environ 150 joueurs simultanés, et le plafond configuré est de 40 par choix, pas par limite, pour garder de la marge aux tournois, aux sauvegardes et aux déploiements. Il s'avère que les tables cash sont limitées par le rythme plutôt que par le processeur : chacune a besoin d'environ 0,035 main par seconde, donc une charge moyenne qui grimpe mesure des fils en file d'attente, pas des joueurs qui attendent.

La leçon de capacité la plus utile ici était structurelle plutôt que numérique : le plafond d'admission des tables et le pool de fils de la boucle de main sont deux nombres qui doivent s'accorder, et pendant un temps un seul était configurable. La production tournait à 25 admises contre 12 réellement exécutables, et comme le pool a une file illimitée les tables en trop n'étaient pas refusées ; elles étaient mises en attente derrière des fils qui ne se libèrent jamais. Seize joueurs, douze servis, quatre assis à des tables qui n'ont jamais distribué une main, sans aucune erreur nulle part. Le pool est maintenant dérivé du plafond. Si vous ajoutez un réglage « combien de X », dérivez celui qui en dépend plutôt que de documenter qu'ils devraient correspondre.

La topologie

                    internet
                       │
                   [ Caddy ]  ← natif sur l'hôte, PAS dans Docker
                       │        TLS, fichiers statiques, proxy inverse, journal d'accès
        ┌──────────────┼───────────────┐
        │              │               │
   /assets/*        /api/*          tout le reste
   /index.html      /ws             → repli SPA vers index.html
   (fichiers        /actuator/*
    statiques)          │
                        │  127.0.0.1:8080
                  [ back-end ]  ── Docker
                        │
              ┌─────────┴─────────┐
              │                   │
        [ postgres ]         [ redis ]   ── Docker, tous deux liés à 127.0.0.1 seulement

Rien d'autre que Caddy n'est joignable depuis internet. PostgreSQL, Redis et le back-end publient tous sur 127.0.0.1 uniquement, donc les conteneurs ne sont pas exposés même si le pare-feu était mal configuré.

Caddy tourne nativement plutôt que dans un conteneur, ce qui est une asymétrie délibérée. Il obtient gratuitement la gestion automatique des certificats TLS depuis l'hôte, et le garder hors de compose signifie qu'un déploiement du back-end (qui redémarre des conteneurs) ne peut pas faire tomber la terminaison TLS avec lui. Le coût, c'est que /etc/caddy/Caddyfile sur le serveur et l'exemple dans ce dépôt sont deux copies qui peuvent diverger, ce qui est documenté des deux côtés.

Le front-end est compilé sur le serveur et servi depuis le disque, pas depuis un conteneur. Le try_files de Caddy le sert comme un vrai fichier avant même que le repli SPA n'entre en jeu.

Les choix à l'intérieur de cette image

Docker Compose plutôt que Kubernetes. Trois conteneurs sur un hôte. Kubernetes ajouterait un plan de contrôle coûtant plus de RAM que l'application elle-même, pour orchestrer un système qui ne peut de toute façon pas tourner sur deux nœuds aujourd'hui (voir le plafond mémoire plus haut). Compose est ce qu'un déploiement mono-nœud devrait être.

PostgreSQL 16 dans un conteneur avec un volume nommé, plutôt qu'une base gérée. Une instance gérée est la bonne réponse dès qu'il y a un deuxième nœud ou une rotation d'astreinte. À cette taille, c'est un coût récurrent pour remplacer un pg_dump nocturne qui fonctionne déjà et dont le chemin de restauration a été répété.

Les migrations sont en Flyway, versionnées dans le dépôt (actuellement jusqu'à V30) pour que le schéma soit un artefact révisable plutôt qu'un état appliqué à la main.

La rotation des journaux est configurée explicitement, parce que le pilote json-file par défaut de Docker ne tourne jamais. Sur un disque de 40 Go, un back-end qui journalise une ligne par main remplit le disque avec assez de temps, et un disque plein est la pire sorte de panne : PostgreSQL cesse d'accepter les écritures, le back-end lève une exception à chaque requête, et la cause ressemble à un bogue applicatif plutôt qu'à un problème d'entretien. Trois fichiers × 10 Mo par service.

Les variables d'environnement obligatoires utilisent la forme ${VAR:?message}, pas ${VAR} nu. Dans docker-compose, un ${VAR} nu pour une variable non définie vaut une chaîne vide, pas une erreur, donc renommer une variable obligatoire démarre l'application mal configurée au lieu de refuser de démarrer. Cela a été trouvé avant la mise en production, sur une variable qui aurait envoyé chaque courriel de réinitialisation de mot de passe et d'invitation avec un lien sans origine : cassé seulement pour les gens qui reçoivent le courrier, personne en position de le remarquer vite.

Les secrets ne quittent jamais le serveur. docker/.env n'existe qu'en production et est exclu du dépôt ; le secret JWT et la liste d'origines CORS n'ont aucune valeur par défaut hors du profil local, donc l'application refuse de démarrer plutôt que de démarrer en configuration non sécurisée.

Les ressources statiques sont mises en cache agressivement et index.html ne l'est pas. Servir des fichiers statiques sans en-têtes de cache ne veut pas dire « sans cache » : les navigateurs se rabattent sur une mise en cache heuristique et gardent indéfiniment les visiteurs qui reviennent sur la version précédente. Chaque correctif front-end atteignait les nouveaux visiteurs et personne d'autre, silencieusement, pendant des jours. Les ressources nommées par empreinte reçoivent maintenant un an et immutable ; tout le reste reçoit no-cache, ce qui veut dire revalider, pas ne pas stocker : un 304 d'une centaine d'octets avec l'ETag déjà présent, si bien que le correctif coûte moins de trafic que de n'en avoir aucun.

Redis tourne et l'application ne s'en sert pas

Dit clairement parce que c'est vrai et légèrement gênant : un conteneur Redis tourne en production, le démarrage du back-end est conditionné à son contrôle de santé, et des paramètres de connexion lui sont passés, mais spring-data-redis n'est pas une dépendance et aucun code n'y touche. La limitation de débit, qui est la chose évidente qu'il ferait, est plutôt un limiteur en mémoire délibéré.

Il est là parce que l'architecture d'origine anticipait d'en avoir besoin, et que rien ne l'a retiré. Le coût, c'est de la mémoire sur une machine de 4 Go et une dépendance au démarrage qui peut échouer. Décidé le 2026-08-19 : le retirer. Le retrait est planifié comme son propre petit déploiement plutôt que glissé dans un déploiement de fonctionnalité.

L'exploitation

Les limites honnêtes de cette conception

1. Un seul nœud, parce que les sessions sont dans un seul tas mémoire. C'est le vrai plafond. Le corriger implique de sortir l'état des sessions du processus avant qu'un deuxième back-end soit possible. 2. ~~Les tables cash ne survivent pas à un arrêt brutal.~~ Réglé : construit le 2026-08-19 et déployé le même jour ; voir la section sur la persistance. 3. Un fil par tournoi, donc un grand champ où beaucoup de tables seraient visibles se sérialiserait. Ce n'est pas une limite aujourd'hui, puisque seule la table de l'humain est réelle. Le paralléliser exige d'abord de régler un objet paquet partagé, sûr aujourd'hui uniquement parce que les tables sont servies strictement l'une après l'autre. 4. Un déploiement reste une coupure nette, désormais avec préavis. Tous les sockets tombent, mais depuis le 2026-08-20 un compte à rebours est annoncé d'abord, et rien de ce qui compte pour un joueur n'est perdu : les tables reviennent en pause. 5. L'intégration continue tourne sur H2 par défaut, donc tout bogue propre au dialecte PostgreSQL lui est structurellement invisible. C'est pourquoi le travail sur vrai PostgreSQL existe : pour la poignée de tests où le dialecte est ce qui est testé. Il a attrapé une panne de production du panneau d'administration où un paramètre JPQL nullable voyait son type inféré comme bytea et où chaque requête sans filtre échouait.

Ce qui changerait, et quand

déclencheurchangement
Le nombre d'utilisateurs simultanés approche le plafond mesuréSortir l'état des sessions du processus, puis un deuxième nœud back-end derrière Caddy
~~Les tables cash portent quelque chose qu'un joueur regretterait de perdre~~~~Les persister et les restaurer~~, fait, 2026-08-19
De l'argent réel, un jourUne base de données gérée, une rotation d'astreinte, et une revue de sécurité de portée plus large que celle d'août 2026
Quelqu'un d'autre rejoint le projetUn environnement de pré-production d'abord : il n'y en a pas, et une seule personne qui déploie prudemment est la seule raison pour laquelle c'est resté supportable