Aller au contenu

← Tous les projets

2016 → 2021 Self-employed · architecte SDK & backend

Unitale — réseau Minecraft mini-jeux distribué, en self-employed

Cinq ans de dev en solo : un fork Spigot custom, un SDK Java de 88 000 lignes, un framework de mini-jeux à génériques type-safe, un backend Symfony de 21 000 lignes, le tout opéré pour 100 000+ joueurs uniques et 10 000+ connexions simultanées en pic.

  • Java 8
  • Spigot (fork unispigot-server)
  • BungeeCord
  • Redisson
  • Hazelcast
  • Redis pub/sub
  • Symfony 3.3
  • Doctrine ORM
  • MariaDB
  • Predis
  • Maven / Nexus
  • Systèmes distribués

Contexte

Unitale est un réseau Minecraft mini-jeux que j’ai conçu, codé et opéré seul, de juin 2016 à mars 2021. Cinq ans, 5 500 commits cumulés, 122 000 lignes de Java et de PHP réparties sur cinq dépôts qui s’imbriquent : un fork serveur, un SDK Bukkit, un framework de mini-jeux générique, deux mini-jeux complets, un backend stateless. En production : 100 000+ joueurs uniques, 10 000+ connexions concurrentes en pic, 50+ instances Spigot orchestrées en continu.

L’objectif n’était pas de livrer un mini-jeu de plus sur un cluster monolithique à la Mineplex ou Hypixel. C’était de poser une fondation distribuée — pub/sub typé, cache cross-serveur, lifecycle propre — qui permette à un seul dev de pousser un nouveau mini-jeu en quelques jours, et à l’infrastructure de scaler horizontalement sans réécrire le runtime à chaque saison.

Mon rôle

Architecte et dev solo full-stack : Java côté plugins et Spigot, PHP côté backend, ops côté production. Cinq ans, une personne, toute la chaîne — protocole Minecraft, état distribué, économie cross-mini-jeux, monitoring, déploiement.

Ce que j’ai construit

Le fork serveur — unispigot-server

Pas un plugin Spigot. Un build maison du serveur Minecraft 1.10.2, packagé fr.unitale:unispigot-server:1.10.2-R0.1 et distribué via un Nexus privé. Le SDK et tous les jeux dépendent en scope=provided de ce serveur custom, ce qui permettait de patcher le netcode et le rendu d’inventaire sans attendre une release Spigot upstream.

Le SDK — 88 638 lignes, 3 885 commits

La fondation Java sur laquelle tout le reste se construit. Quelques pièces structurantes :

  • Bus Redis pub/sub typé : CommonPubSub et RequestPubSub exposent des enums (ChannelType, RequestType) où chaque valeur porte sa propre Consumer<String> de dispatch. Pour les requêtes inter-joueurs (amis, parties, invitations), la signature du callback est curryfiée — Function<UUID, Function<String, Function<RequestActionType, Function<UniPlayer, Consumer<String>>>>> — ce qui donne un dispatcher polymorphe asynchrone propre, sans avoir à manipuler de map de handlers à la main.
  • Dual-store distribué : Redisson pour la couche transient et pub/sub, Hazelcast pour les caches partagés (parties en cours, codes de confidentialité, historique des requêtes de matchmaking). Les maps Hazelcast parties_dispatching, game_requests_history, privacy_codes sont la mémoire vivante du cluster.
  • 11+ API clients structurés : SpigotPlayerRedisApiClient, SpigotServerRedisApiClient, FeatureRedisApiClient, PartyRedisApiClient, SettingsRedisApiClient, ModoRedisApiClient côté Redis, doublés d’une famille REST (PlayerRestApiClient, BoxRestApiClient, BoostRestApiClient…). Chaque domaine sait fallback REST → Redis quand l’un des deux tombe.
  • Accès NMS direct plutôt que ProtocolLib. Le package fr.unitale.sdk.protocol.packets contient une trentaine de classes UniPacketPlayOut* qui interceptent le rendu côté serveur (inventaires custom, morphs joueur/NPC, entités custom via EntityManager et CustomProjectileEntity). Aucune dépendance externe au protocole, donc aucune surcharge CPU sur le hot path.
  • Internationalisation via ResourceBundle multi-locale (fr_FR, en, es) avec une vingtaine de fichiers .properties et un système de substitution de clés {{example.magic.key}} qui rend les textes éditables sans toucher au code.

Le framework de mini-jeux — GameSDK2, 6 315 lignes

Engine<I extends Instance<I, M, T>, M extends GameMap<I, M, T>, T extends UniTeam<I, M, T>> — un moteur paramétré par trois génériques liés en self-bound, qui force chaque mini-jeu à déclarer son propre triplet de types et garantit zéro cast à runtime. Au-dessus, un système de modules : Module<I, M, T> extends InstanceListener avec un lifecycle en cascade — WaitingModuleKitChoiceModule / KitViewModuleTeamModuleGameModuleDropModuleSpectateModule. Chaque module s’enregistre et se désenregistre proprement sur les transitions d’état, ce qui isole les bugs d’un module sans corrompre l’instance.

Le routage des joueurs entre parties s’appuie sur deux types de salles : DispatchingRoom (zone d’attente où on calcule le matchmaking) et PassingRoom (transit inter-game). Le QueueManager consomme une IMap Hazelcast partagée entre tous les serveurs, ce qui permet à n’importe quelle instance de réserver un slot sur n’importe quelle autre sans coordination centralisée.

Les mini-jeux

  • LaserGame (1v1 / 2v2, 2 162 lignes, 194 commits) : une matrice procédurale d’armes laser construite à partir de 7 types de lentilles (FireballLens, GradientLens, NormalLens, SpiralLens, TriangleLens, WaveLens…) croisés avec 7 types d’explosions (BurstExplosion, DropItemExplosion, LavaExplosion, NormalExplosion, SmokeExplosion, StarExplosion). Chaque combinaison lentille × explosion donne une arme unique, ce qui élimine la duplication de code tout en générant la variété attendue d’un FPS arena.
  • Arena (multi-équipes, 4 052 lignes, 346 commits) : 8 maps thématiques (Endare, Endare_hell, Wow, Pokeball, Mario, Hyperball, Volcan, Masterball), 30+ effets composables (CupidEffect, DuckEffect, FlameThrowerEffect, HammerEffect, MagicWandEffect, PokeballEffect, ToiletBrushEffect…) et 5 listeners polymorphes (BlockPlaceListener, BowShootListener, EntityDamageByPlayerListener, InteractListener, ProjectileHitListener) qui dispatchent chaque event vers les effets concernés sans if/else géant.

Le backend — refonte Symfony, 21 087 lignes, 515 commits

En 2020, j’ai sorti la persistance et l’économie du monolithe Java pour les confier à un backend Symfony 3.3 + Doctrine ORM + MariaDB + Predis, découpé en 25 bundles modulaires : PlayerBundle, GameBundle, GameServerBundle, GuildBundle, ShopBundle, UserBundle, PermissionBundle, SanctionBundle, BoxBundle, FeaturesBundle, BoostBundle, SessionBundle, RedisBundle, etc. Le contrat avec les serveurs Spigot devient REST par défaut, avec fallback Redisson pub/sub quand le REST timeout — un pattern de résilience qui a sauvé bien des soirées de pic.

C’est sur ce backend que tourne l’économie cross-serveur : gold (soft currency), emeralds (premium), cosmétiques (auras, skins) que les joueurs débloquent via des victoires en mini-jeu et qui les suivent partout dans le réseau.

Point dur

La synchronisation d’état cross-serveur en temps réel, en 2016, sans Kubernetes ni orchestrateur moderne. À cette époque, la compétition (Mineplex, Hypixel) tournait sur des clusters monolithiques avec leur propre middleware bespoke ; BungeeCord n’était qu’un proxy TCP. J’ai choisi d’associer Redisson (transient, pub/sub) et Hazelcast (distributed maps, locks) pour obtenir à la fois la diffusion instantanée d’événements et un cache cohérent — pattern qu’on retrouvera dans des stacks modernes type Kafka + Redis, mais qui n’allait pas de soi à l’époque sur de la JVM Bukkit.

L’autre difficulté, plus structurelle, a été de maintenir un framework de mini-jeux générique sans équipe. Les génériques imbriqués <I extends Instance<I, M, T>, M extends GameMap<I, M, T>, T extends UniTeam<I, M, T>> sont coûteux à concevoir : c’est rare dans l’écosystème plugins MC, où la plupart héritent d’un GameEngine monolithique et castent à mort. Tenir cette discipline en solo sur cinq ans, c’est ce qui a permis à LaserGame et Arena de partager 80% de leur code sans copier-coller. Les signatures fonctionnelles curryfiées utilisées pour le dispatch pub/sub vont dans la même direction : faire en sorte que le compilateur prouve la cohérence à ma place, parce que je n’avais pas de QA derrière moi.

Résultats

  • 100 000+ joueurs uniques, 10 000+ connexions concurrentes en pic, 50+ instances backend orchestrées en continu.
  • 5 ans en production (juin 2016 → mars 2021), 5 500+ commits, 122 000 lignes Java + PHP, le tout maintenu en solo.
  • Un framework de mini-jeux réutilisé sur plusieurs jeux internes sans copier-coller, validant le pari du SDK avant le framework avant les jeux.
  • Cette mission est la preuve la plus longue de ma carrière qu’on peut tenir une architecture distribuée sérieuse sans équipe : compétences pub/sub, cache cohérent, lifecycle propre, résilience REST/Redis, qui se retrouvent ensuite chez Dofus Touch (back-end MMO live), Authlete (SaaS OAuth multi-tenant) et aujourd’hui Conexia Tahiti (CTO fractional).