
Gemline
Jeu de plateau multijoueur en ligne sur grille hexagonale, écrit en Go et déployé sur un cluster k3s auto-hébergé (Hetzner) avec une chaîne GitOps complète Terraform / Ansible / ArgoCD.
Rôle : Créateur
Vue d'ensemble
Gemline est un jeu de plateau multijoueur en ligne qui se joue sur une grille hexagonale à 11 côtés (331 intersections). De deux à six joueurs posent des gemmes en temps réel, avec deux conditions de victoire parallèles — aligner ses gemmes ou capturer des paires adverses. Un backend Go gère les règles et la persistance ; un frontend Vite + React permet à plusieurs navigateurs de rejoindre la même partie et de jouer en direct. Les joueurs connectés ont un profil, un historique, un Elo et un classement global ; n'importe qui peut rejoindre une partie de façon anonyme via un lien.
Je l'ai construit pour maîtriser tout le chemin du code jusqu'à un cluster en production — le moteur, la couche temps réel et l'infrastructure cloud-native qui le déploie.
Problème & contexte
L'intéressant n'est pas tant les règles que tout ce qui les entoure : garder plusieurs onglets synchronisés en temps réel, survivre à un redémarrage serveur en pleine partie, scaler le backend horizontalement, et déployer l'ensemble de façon reproductible sur un cluster que j'opère moi-même. Gemline est le projet où je suis allé en profondeur sur les systèmes distribués et le côté opérationnel du logiciel.
Architecture & décisions techniques
- Un moteur de règles pur.
internal/gamecontient les règles sans aucune I/O ni concurrence — juste des fonctions sur un état de jeu. C'est donc testable de façon exhaustive (99,3 % de couverture : détection des captures sur les trois axes, chaînage multi-captures, non-capture par « suicide », conditions de victoire, rotation des tours), et exactement le même moteur sert au jeu en direct comme au rejeu de l'état. - Persistance event-sourcée. Le journal des coups est l'unique source de vérité. L'état d'une partie est reconstruit en rejouant ses coups dans le même moteur
ApplyMove— pas de snapshot d'état séparé qui pourrait diverger. Un test d'intégration vérifie qu'une partie rejouée depuis Postgres correspond à l'état en mémoire d'origine, captures comprises. - Auth à double token. Deux tokens circulent indépendamment : un JWT Supabase dit qui vous êtes ; un token de siège dit quel siège de quelle partie vous contrôlez. Le token de siège n'est renvoyé qu'une fois et seul son SHA-256 est stocké — lire la base de données ne permet donc pas d'usurper un joueur, et un joueur conserve son siège après un redémarrage.
- Scalabilité horizontale, sans sticky sessions. Le backend est sans état ; les événements live inter-pods (diffusion WebSocket, invalidation de cache, matchmaking) passent par un backplane Postgres
LISTEN/NOTIFY. Il tourne ainsi en 2+ réplicas derrière un simple load balancer. - Temps réel résilient. Diffusion WebSocket avec pings serveur, reconnexion client (backoff exponentiel + jitter) et rattrapage d'événements via
/events?since=, pour qu'une connexion perdue ne fasse jamais perdre de coups.
Infrastructure & GitOps
Toute la stack se monte en une commande (make deploy), puis c'est Git qui en est propriétaire :
- Terraform provisionne les VMs Hetzner, un réseau privé, un firewall et un load balancer, ainsi que l'enregistrement DNS Cloudflare. L'état distant est sur S3 avec un verrou DynamoDB.
- Ansible transforme les VMs en cluster k3s (control plane HA, etcd embarqué) et installe cert-manager et ArgoCD. Son inventaire est généré dynamiquement à partir des sorties Terraform — aucune IP maintenue à la main.
- ArgoCD fait tourner un app-of-apps avec auto-sync + self-heal : personne ne lance
kubectl applypour l'app — on pousse surmainet ArgoCD déroule le changement. - Les secrets s'enchaînent à partir d'un unique Sealed Secret commité (chiffré), via Infisical et l'External Secrets Operator — aucun secret en clair n'est jamais commité ni créé à la main.
- TLS via cert-manager + Let's Encrypt ; monitoring via kube-prometheus-stack (Prometheus, Grafana, Alertmanager) qui scrape le
/metricsde l'app. - CI/CD : quatre workflows GitHub Actions. Au push sur
main, la CI build les images, les pousse sur GHCR, bump le tag d'image avec Kustomize et le recommit — puis ArgoCD effectue le rollout depuis l'intérieur du cluster. La CI ne détient jamais de kubeconfig, et il n'y a aucun credential cloud longue durée : AWS comme Infisical sont atteints via OIDC. De bout en bout, c'est ~3 minutes entregit pushet la mise en ligne.
Difficultés & apprentissages
- Le bootstrap des secrets. Un cluster neuf génère une nouvelle clé Sealed Secrets, donc le secret de bootstrap commité ne peut pas être déchiffré tant qu'on ne l'a pas restauré ou re-scellé. Concevoir une chaîne à la fois 100 % GitOps et sûre à reconstruire a demandé du soin — et m'a appris à toujours sauvegarder la clé maître avant un teardown.
- Distroless + pods verrouillés. Faire tourner des images distroless en
runAsNonRootimpose de fixer l'UID numérique (kubelet ne peut pas vérifier un user symbolique) ; le Caddy du pod web a besoin decap_net_bind_service, ce qui entre en conflit avec une policy « drop all capabilities ». De petits détails de durcissement très réels. - Livraison en pull. Garder la CI hors du cluster — elle commit un bump d'image, ArgoCD tire — supprime toute une classe de risques de credentials et fait d'un rollback un simple
git revert. - Pas encore : rate limiting applicatif, Postgres in-cluster (le projet s'appuie sur Supabase), région unique.
Stack
- Backend — Go 1.25,
coder/websocket, migrations goose, vérification des JWT Supabase - Frontend — Vite, React, Tailwind, auth Supabase
- Données — PostgreSQL (journal de coups event-sourcé + backplane
LISTEN/NOTIFY) - Infrastructure — k3s sur Hetzner, Terraform, Ansible, ArgoCD, Sealed Secrets + Infisical + External Secrets, cert-manager
- Observabilité — kube-prometheus-stack (Prometheus, Grafana, Alertmanager)
- CI/CD — GitHub Actions, GHCR, OIDC vers AWS + Infisical