Unitale — distributed Minecraft mini-games network, self-employed
Five years of solo dev: a custom Spigot fork, an 88K-line Java SDK, a type-safe mini-games framework, a 21K-line Symfony backend — operated for 100K+ unique players and 10K+ concurrent peak connections.
- Java 8
- Spigot (unispigot-server fork)
- BungeeCord
- Redisson
- Hazelcast
- Redis pub/sub
- Symfony 3.3
- Doctrine ORM
- MariaDB
- Predis
- Maven / Nexus
- Distributed systems
Context
Unitale was a Minecraft mini-games network I designed, built and operated solo from June 2016 to March 2021. Five years, 5,500 cumulative commits, 122,000 lines of Java and PHP across five interlocking repos: a server fork, a Bukkit SDK, a generic mini-games framework, two full mini-games, a stateless backend. In production: 100,000+ unique players, 10,000+ concurrent peak connections, 50+ Spigot instances continuously orchestrated.
The goal wasn’t to ship one more mini-game on a monolithic Mineplex/Hypixel-style cluster. It was to lay a distributed foundation — typed pub/sub, cross-server cache, clean lifecycle — that would let a single dev push a new mini-game in a few days, and let the infrastructure scale horizontally without rewriting the runtime each season.
My role
Solo full-stack architect and engineer: Java on the plugins and Spigot side, PHP on the backend, ops in production. Five years, one person, the whole chain — Minecraft protocol, distributed state, cross-game economy, monitoring, deployment.
What I built
The server fork — unispigot-server
Not a Spigot plugin. A custom build of the Minecraft 1.10.2 server, packaged as fr.unitale:unispigot-server:1.10.2-R0.1 and shipped through a private Nexus. The SDK and every game depend on it as scope=provided, which let me patch netcode and inventory rendering without waiting for an upstream Spigot release.
The SDK — 88,638 lines, 3,885 commits
The Java foundation everything else builds on. A few structuring pieces:
- Typed Redis pub/sub bus:
CommonPubSubandRequestPubSubexpose enums (ChannelType,RequestType) where each value carries its ownConsumer<String>dispatch handler. For cross-player requests (friends, parties, invites), the callback signature is curried —Function<UUID, Function<String, Function<RequestActionType, Function<UniPlayer, Consumer<String>>>>>— yielding a clean polymorphic async dispatcher without hand-rolled handler maps. - Dual-store distributed layer: Redisson for the transient and pub/sub layer, Hazelcast for shared caches (live matches, privacy codes, matchmaking history). The Hazelcast maps
parties_dispatching,game_requests_history,privacy_codesare the cluster’s living memory. - 11+ structured API clients:
SpigotPlayerRedisApiClient,SpigotServerRedisApiClient,FeatureRedisApiClient,PartyRedisApiClient,SettingsRedisApiClient,ModoRedisApiClienton the Redis side, paired with a REST family (PlayerRestApiClient,BoxRestApiClient,BoostRestApiClient…). Every domain knows how to fall back REST → Redis when either fails. - Direct NMS access rather than ProtocolLib. The
fr.unitale.sdk.protocol.packetspackage contains ~30UniPacketPlayOut*classes that intercept rendering server-side (custom inventories, player/NPC morphs, custom entities viaEntityManagerandCustomProjectileEntity). No external protocol dependency, no CPU overhead on the hot path. - Internationalization via multi-locale
ResourceBundle(fr_FR,en,es) with twenty-some.propertiesfiles and a{{example.magic.key}}substitution system that keeps copy editable without touching code.
The mini-games framework — GameSDK2, 6,315 lines
Engine<I extends Instance<I, M, T>, M extends GameMap<I, M, T>, T extends UniTeam<I, M, T>> — an engine parameterized by three self-bound generics, forcing every mini-game to declare its own triplet of types and guaranteeing zero runtime casts. On top of it, a module system: Module<I, M, T> extends InstanceListener with a cascading lifecycle — WaitingModule → KitChoiceModule / KitViewModule → TeamModule → GameModule → DropModule → SpectateModule. Each module registers and unregisters cleanly on state transitions, isolating bugs in one module from corrupting the instance.
Player routing between matches relies on two room types: DispatchingRoom (the waiting area where matchmaking gets computed) and PassingRoom (inter-game transit). The QueueManager reads from a shared Hazelcast IMap, so any instance can reserve a slot on any other instance with no central coordinator.
The mini-games
- LaserGame (1v1 / 2v2, 2,162 lines, 194 commits): a procedural matrix of laser weapons built from 7 lens types (
FireballLens,GradientLens,NormalLens,SpiralLens,TriangleLens,WaveLens…) crossed with 7 explosion types (BurstExplosion,DropItemExplosion,LavaExplosion,NormalExplosion,SmokeExplosion,StarExplosion). Every lens × explosion combination yields a unique weapon, eliminating code duplication while delivering the variety expected from an arena FPS. - Arena (multi-team, 4,052 lines, 346 commits): 8 themed maps (
Endare,Endare_hell,Wow,Pokeball,Mario,Hyperball,Volcan,Masterball), 30+ composable effects (CupidEffect,DuckEffect,FlameThrowerEffect,HammerEffect,MagicWandEffect,PokeballEffect,ToiletBrushEffect…) and 5 polymorphic listeners (BlockPlaceListener,BowShootListener,EntityDamageByPlayerListener,InteractListener,ProjectileHitListener) that dispatch each event to the relevant effects without a giantif/else.
The backend — Symfony rewrite, 21,087 lines, 515 commits
In 2020 I lifted persistence and economy out of the Java monolith into a Symfony 3.3 + Doctrine ORM + MariaDB + Predis backend, broken into 25 modular bundles: PlayerBundle, GameBundle, GameServerBundle, GuildBundle, ShopBundle, UserBundle, PermissionBundle, SanctionBundle, BoxBundle, FeaturesBundle, BoostBundle, SessionBundle, RedisBundle, etc. The contract with Spigot servers became REST by default, with a Redisson pub/sub fallback when REST timed out — a resilience pattern that saved many a peak-hour evening.
That backend runs the cross-server economy: gold (soft currency), emeralds (premium), cosmetics (auras, skins) unlocked through mini-game wins and persisted across every server in the network.
The hard part
Real-time cross-server state sync, in 2016, with no Kubernetes and no modern orchestrator. At the time the competition (Mineplex, Hypixel) ran on monolithic clusters with bespoke middleware; BungeeCord was just a TCP proxy. I chose to pair Redisson (transient, pub/sub) with Hazelcast (distributed maps, locks) to get both instant event broadcast and a coherent cache — the pattern you now find in modern Kafka + Redis stacks, but far from obvious on a Bukkit JVM in 2016.
The other, more structural hard part: keeping a generic mini-games framework healthy without a team. Self-bound nested generics like <I extends Instance<I, M, T>, M extends GameMap<I, M, T>, T extends UniTeam<I, M, T>> are expensive to design — they’re rare in the MC plugin ecosystem, where most networks inherit a monolithic GameEngine and cast aggressively. Holding that discipline solo for five years is what let LaserGame and Arena share 80% of their code without copy-paste. The curried functional signatures used for pub/sub dispatch go in the same direction: make the compiler prove consistency for me, because I had no QA behind me.
Results
- 100K+ unique players, 10K+ concurrent peak connections, 50+ backend instances continuously orchestrated.
- 5 years in production (June 2016 → March 2021), 5,500+ commits, 122,000 lines of Java + PHP, all maintained solo.
- A mini-games framework reused across multiple internal games without copy-paste, validating the SDK-before-framework-before-games bet.
- The longest-running proof in my career that a serious distributed architecture can be held by one person: pub/sub, coherent cache, clean lifecycle, REST/Redis resilience — the same skills that later carried me through Dofus Touch (live MMO backend), Authlete (multi-tenant OAuth SaaS), and today Conexia Tahiti (fractional CTO).