Cryon
A hot-swappable module framework for Minecraft networks.
On this page 10 sections
- Every feature in its own sandbox
- Swapping a jar while the server runs
- Features that never met, working together
- A broken feature can’t take the server down
- One switch for a whole network
- One server or fifty, one code path
- Handing a player to another server
- Teaching the profiler what a module is
- Numbers that never allocate
- Under the hood
Cryon is a Minecraft network framework written in Kotlin: a Paper plugin, a matching Velocity proxy plugin, and a Geyser Standalone extension that between them run everything from one box in a bedroom to a sharded fleet on Kubernetes, off the same code. None of them does much gameplay itself. Their job is to discover, class-load, wire together, and hot-swap independent feature jars at runtime, and then give those features a way to cooperate across machines once there’s more than one. Every actual feature (an economy, a skill tree, a shop, jump pads) lives in its own separate git repo, compiles only against a small published API, and ships as a thin jar you drop into plugins/Cryon/modules/.
If you’ve never built a server before: normally each feature you add is its own standalone plugin, and the server loads them all as one flat pile with no real relationship to each other. Cryon inverts that. One plugin (Cryon) hosts many small modules, each living in its own repository, so a feature can be built, versioned, shipped, and reloaded entirely on its own without touching the others or restarting the server. The second inversion is that a network of servers is treated as the ordinary case rather than something you bolt on later, so a feature jar behaves correctly whether it’s running alone or as one of forty.
The repo is seven pieces: four published contracts that feature repos compile against (a platform-neutral core, plus a Paper API, a Velocity API and a Geyser API), and the three loaders you actually deploy. Geyser Standalone hosts extensions rather than plugins, so that loader ships as an extension, but it runs the same core underneath: same module manager, same service registry, same database and transport. Everything below is about how those pull it off; the features themselves are almost beside the point.
The shape of the core:
- Per-jar classloader isolation, so two features can’t see (or accidentally depend on) each other’s internals.
- A service registry that still lets features from different repos talk to each other, but only through shared interfaces.
- A two-phase, order-independent lifecycle, so a feature always finds its peers no matter which jar happened to load first, with earlier and later hooks either side for the rare module that needs them.
- Failure isolation down to
Throwable, so a broken or stale jar gets quarantined and the rest of the server keeps running. - Cache-copy hot-reload: drop a jar in and it loads live, replace it and it swaps, delete it and it unloads, no restart at any point.
- Sub-modules, so half a feature can be loaded, disabled and torn down on its own.
- Distributed feature flags that flip a feature off across an entire network of servers at once.
- One code path for one server or fifty, so a feature is never written twice and never branches on how it happens to be deployed.
- Player handoff between servers that doesn’t lose the last thing you did before you walked through the portal.
- A profiler that can actually see modules, which took more work than any of the above.
Every feature in its own sandbox
The naive way to build this would be one big plugin with every feature compiled inside it. Cryon does the opposite: each feature jar is class-loaded in isolation, in a deliberate three-tier hierarchy. At the bottom sits the core’s own classloader (Paper, the framework, the Kotlin standard library). Above it, one shared loader holds every contract jar in plugins/Cryon/api/. Above that, each feature jar gets its own private loader whose parent is that shared contract layer.
The payoff is in what a feature can’t reach. It can see the core, the shared contracts, and Paper, and nothing else, so it’s impossible for one feature to reach into another’s classes even by accident. But the shared contract layer is the load-bearing part. For two separate repos to hand a typed object across the boundary, the interface describing that object has to load from the shared parent, so both sides resolve the exact same Class. If each jar carried its own copy of the interface, passing an object between them would throw a ClassCastException between two classes that look identical but aren’t. That single decision is what makes cross-repo contracts type-safe instead of a pile of reflection.
Swapping a jar while the server runs
The trick I’m proudest of is how a jar reloads without a restart. The JVM locks the file behind a running classloader, and on Windows that means you can’t delete or overwrite a jar while it’s loaded. So Cryon never loads the jar you dropped in the folder: on boot it copies each one into a private .module-cache/ and loads the copy. The original in modules/ is never held open, so you can replace or delete it while the feature is live. A file watcher notices, coalesces the burst of filesystem events a copy produces into a single change, hops back onto the main thread, and swaps the module in place. The cache is wiped clean on boot and on unload.
Reloading a shared contract is the one heavier case. Because the api/ layer is the parent of every feature loader, it can’t be swapped in isolation (the running features are still linked to the old contract classes). So changing a contract jar cascades: every feature unloads in reverse order, the shared loader is rebuilt, and everything reloads in the same two-phase order it booted in. It briefly takes every feature down, but it keeps class identity consistent, which is the whole game. The honest caveat is the usual one for hot-reloading on a JVM: reclaiming the old classes still depends on a feature not leaking references to itself, and the core is upfront about that.
Features that never met, working together
Isolated features still need to cooperate (the shop needs the economy, skills grant permissions, and so on), and they do it through one narrow seam: a service registry keyed by interface. A feature that provides something registers its implementation against a shared interface; a feature that wants it looks it up by that same interface. Nobody ever imports anybody else’s classes.
The only thing that could go wrong is timing, and the lifecycle removes it. The core runs every feature’s load step first, which is where providers publish their services, and only then runs every feature’s enable step, where consumers look them up. Because all the publishing finishes before any of the consuming begins, a feature always finds its peers regardless of which jar loaded first, with no load order to get right. Consumers look peers up with a nullable “find” rather than a throwing “get,” so a missing optional feature degrades gracefully instead of crashing. And when a feature unloads, the registry drops every service that came from its classloader, so a freshly reloaded feature never resolves a dead instance left over from the previous version.
A feature that genuinely can’t work without something else says so, rather than discovering it at runtime. A module declares prerequisites as a list of dependencies, either a plugin by name or a service by class, each marked hard or soft. A missing hard one marks the module failed before its onLoad ever runs, which is a better failure than a half-wired feature throwing on first use. A soft one only pulls the module later in the enable order. The declaration is read once at registration, so it can’t reference anything the module builds later.
A broken feature can’t take the server down
Every place the core calls into feature code catches Throwable, not just Exception, and that’s deliberate. A jar built against an old version of the API doesn’t fail with a tidy Exception; it throws things like NoSuchMethodError, NoClassDefFoundError, or a ServiceConfigurationError from a malformed service file, all of which are Errors, not Exceptions. Catch only Exception and a single stale jar takes the entire server down on boot. Catch Throwable and the bad feature is marked failed, left disabled, and logged, while the server carries on loading everything else. A failed feature clears itself on the next successful reload.
Commands get the same guarantee from the other side. Every feature’s commands carry a live check of whether that feature is currently enabled, re-evaluated on each use, so disabling a feature makes its commands vanish from execution and tab-completion without anything being re-registered. Turn it back on and they reappear.
One switch for a whole network
Feature flags are a runtime kill switch, resolved most-specific-wins: a per-player override beats a per-server override, which beats a global setting, which defaults to on. Checking a flag is a couple of map reads, cheap enough to gate individual events rather than whole features. The interesting part is that it’s built for a network of servers rather than a single box. Every flag is persisted to SQL (PostgreSQL, MySQL, or an embedded H2 file, whichever you point it at), and every change is broadcast over Redis pub/sub, so flipping a feature off on one server flips it off across all of them within a moment, and a server that restarts reads the current state straight back out of the database. Take the database and Redis away and the whole thing quietly falls back to in-memory, per-server flags, so the exact same code runs on a hobby server with no infrastructure at all.
A flag is one of two ways to switch off part of a feature, and the other one is worth knowing about. A module can own child modules, each a module in its own right: its own id, its own state, its own lifecycle, its own row in /cryon modules, and its own listeners and tasks torn down when it disables. The parent always loads first and disables last, and a child can’t enable while its parent is down. The difference is where the off switch sits. A flag is a guard inside a handler that is still wired up; a disabled child isn’t wired at all. Reach for a flag to gate behaviour, and for a child when an admin should be able to load half a feature and leave the rest alone.
One server or fifty, one code path
This is the load-bearing idea of the whole network side, and it’s mostly about what feature authors don’t have to think about.
A deployment declares how many nodes it expects, in network.expect or the CRYON_EXPECT environment variable. ONE_NODE is one server that is the whole world. MANY_NODES is many interchangeable copies behind the proxy, spun up and torn down as players arrive and leave. Separately, and this is the part that matters, you configure a transport: Redis, or nothing at all.
Those two knobs are deliberately not the same knob. The expectation declares intent and nothing branches on it, the transport decides whether state actually crosses a process boundary. A messaging interface and a key-value store are always registered either way, with exactly one implementation behind each: backed by Redis when it’s configured, backed by plain in-process objects when it isn’t. So feature code calls the same method regardless, and there is no if (networked) branch anywhere in a feature jar. Whether a value travels to another machine or stays on the heap is an ops decision made in a config file, not a code decision made months earlier by someone guessing.
The failure mode this creates is a server declaring MANY_NODES with no Redis behind it, which would look fine and silently share nothing, so a loud banner is printed on boot when the expectation and the transport disagree. Stating it up front is the whole point: “I meant to run one” and “I meant to run a pool and my Redis URI is wrong” stop looking identical at boot. Loud, but not fatal: it’s a misconfiguration, not a reason to refuse to start.
Handing a player to another server
On a network of interchangeable servers, saving a player’s data when they quit is a bug, which is not obvious until it bites.
The reason is ordering. When the proxy moves someone from server A to server B, it connects B before it drops A. The quit on A therefore fires after B has already loaded that player, so a save-on-quit writes stale data straight over the top of the live session. Whatever they did in the last few seconds on A is gone, and worse, it’s gone in a way that looks like a random dupe or rollback rather than a race.
So modules don’t save on quit. They register a flush instead, a named callback the core can invoke on demand, and the proxy holds the connection to B open until A confirms its flush finished. The player only lands on B once their data has actually been written. If a module’s flush hangs or throws, the handoff fails open after a timeout and the player still gets through, on the grounds that one broken feature should cost you some data rather than strand somebody on a loading screen forever.
Around that sit the pieces that make the fleet legible: a server registry where every instance heartbeats its own liveness, with entries expiring on a TTL and changes fanned out over pub/sub, so nothing has to poll and a dead server drops out on its own. Routing a player picks the least-loaded instance and reserves the slot atomically before the transfer, so two people arriving at the same moment can’t both be sent to the last seat on a full server. And a proxy-side maintenance mode, Redis-synced with a persisted bypass allowlist, closes the whole network to everyone except the people fixing it.
Teaching the profiler what a module is
spark is the profiler everyone actually uses to find out why a Minecraft server is lagging. It samples stack traces and attributes each frame to whichever plugin it came from, which is the entire point: a flame graph that just says “something is slow” is useless, and one that says “this plugin is slow” ends the argument.
Isolated classloaders break that completely, and it’s worth being precise about why. spark attributes a frame in two steps: something resolves the frame’s class name into an actual Class, and then something else maps that class to a source by walking its classloader chain looking for a plugin loader it recognises. Both steps are blind to Cryon’s module loaders. The first one can’t even find the class, so module frames get dropped before the second step ever runs. The result is that the most interesting code on the server, all the actual gameplay, profiles as a blank.
spark has no API for extending either step. So Cryon reaches in and reflectively replaces the object spark consults, with a proxy that overrides exactly three things: find a class by also asking each module loader, map a module class back to the jar it came from, and declare each loaded feature as its own named, versioned source so the spark web viewer lists them individually. Run a profiler and every feature shows up under its own name and version instead of one opaque “Cryon” blob.
Two details are the difference between this working and this being a liability:
- The class lookup deliberately only asks each loader what it has already loaded, rather than asking it to go and load the class. That sounds like a trivial distinction and isn’t: the delegating version can block on Paper’s classloader lock while spark is mid-export, which is exactly the deadlock that once left profiles hanging at the moment you stopped them.
- It finds spark through spark’s own registered API, not by asking the server for a plugin named “spark”, because modern Paper ships spark bundled as a library rather than as a plugin. Ask for the plugin and you get null on precisely the servers most likely to be running it. Its internal types are picked up from reflection metadata rather than named directly, too, since Paper relocates spark’s packages when it bundles them.
Undoing all this on shutdown is mandatory rather than tidy: spark outlives the plugin, so a proxy left in place would pin every module classloader for the rest of the JVM’s life, which is a memory leak wearing a profiler’s clothes. And the whole thing stays entirely best-effort. If spark is absent, or its internals shift under a future release, Cryon logs a warning and leaves the profiler exactly as it found it.
Numbers that never allocate
Economy and idle-game math get absurd in a hurry, well past what a Long or even a Double holds cleanly, and the usual answers (BigDecimal, or a boxed mantissa-and-exponent object) allocate on every single operation, which is death on a path that runs millions of times a tick. Cryon’s answer is PackedDecimal: an entire floating-point number packed into one 64-bit Long. Because it’s a value class, the JVM keeps it as a bare primitive on the stack with zero allocation per operation. The low 48 bits hold a base-10 mantissa normalized to exactly 14 significant digits, the top 16 bits hold a power-of-ten exponent, and the value is simply the mantissa times ten raised to that exponent. That buys roughly 14 significant figures across a range near ten-to-the-±32767, and the exponent saturates instead of wrapping, so an overflow pins to the maximum rather than silently rolling negative. Keeping the mantissa in base ten also makes formatting exact and multiplication cheap: both mantissas sit in a known range, so their product is always 27 or 28 digits and the extra 13 or 14 get dropped with a single comparison, no logarithms on the hot path. It runs several times faster than the boxed equivalent and it’s the intended type for anything past about a quadrillion.
Under the hood
A few more pieces worth mentioning for the curious:
- Commands are Mojang’s own Brigadier, wired up through a small reflection-based annotation layer, so there’s no heavyweight command framework in the dependency list. Registration is owned by the core rather than done per-module, which is what lets a command spliced in by a hot-loaded jar appear immediately, and lets several unrelated features share one root (
/int drills giveand/int pets listcoming from two different repos) without either of them owning it. - Scheduling goes through Folia-aware helpers that make you name the thread that owns the data (a region, an entity, or a background pool) instead of reaching for one global scheduler, which is the discipline Folia’s sharded threading actually requires.
- Events are functional subscriptions with inline filters that unregister themselves, no annotated listener classes to wire up.
- Text is Adventure + MiniMessage behind a shared palette and a small cache (no dusty legacy color codes).
- Translations are auto-discovered: each feature ships its own language files inside its jar, and the core reads them straight out of the jar, so a feature’s strings just appear with nothing to register. Admins get an override file that the core keeps seeded with every key it knows about, adding the missing ones on boot and never touching a line you’ve already edited. Ships English and German, with Crowdin wired up for the rest.
- PlaceholderAPI, per module. Each feature can claim its own
%namespace_…%and serve its own placeholders without ever touching a PAPI class, which it couldn’t anyway from inside an isolated loader./cryon infolists whatever namespaces a module has claimed alongside its commands. Best-effort in the same way spark is: no PAPI, no bridge, nothing breaks. - Bedrock players get real Bedrock UI. Where a menu would otherwise be an inventory full of item icons, the core sends a native Bedrock form instead, and shared dialogs like a yes/no confirm pick the right one per player automatically. Without Floodgate installed everyone simply reports as a Java player and it’s a no-op.
- The proxy MOTD is pixel-aligned. Six MiniMessage segments, anchored left, centre, and right across two lines, positioned by measuring actual glyph widths in Minecraft’s font and padding to the nearest four pixels, because centring text by counting characters looks fine until somebody uses a capital W.
- Shared state, when a network wants it, runs through SQL over HikariCP (off the main thread, returning futures) against PostgreSQL, MySQL, or an embedded H2 file, plus Redis over Lettuce with a small request/reply layer built on top of pub/sub.
- It ships as infrastructure, not just a jar. Container images for the Paper servers, the Velocity proxy, and Geyser, per-family jar sets baked in at build time, and a Helm chart that runs the whole thing as autoscaling Agones fleets on Kubernetes. There’s a local docker stack too (proxy, two servers, Redis, Postgres) for when you’d rather not.
- No dependency-injection container, no ORM, no code generation. Just the loader and a handful of small, sharp seams.
- Built on Kotlin 2.4 and JDK 25, against the Paper 26.2 dev bundle, Velocity 3.3 and Geyser 2.11.
It lives on GitHub (with the example feature modules in a second repo) if you want to poke around.